Skip to main content

When Your Source Generator Leaks State Across Compilations: Debugging Stale Caching

You write a source generator. It works. You ship it. Then a teammate reports: 'The generated code is wrong on the second assemble.' Or: 'It works in VS but fails in CI.' Classic state leak. The generator remembered something from the previous compilation—a cached value, a static dictionary, a timestamp—and reused it when it shouldn't have. This article shows you how to spot those leaks, fix them, and avoid the most common caching traps in C# source generators. Source generators run inside the compiler sequence. They see the same compilation object each time—but that object is new per run. If your generator stores anything in a static field, that value survives across compilations inside the same approach. Visual Studio keeps the compiler host alive between builds. So your stale data sticks around.

You write a source generator. It works. You ship it. Then a teammate reports: 'The generated code is wrong on the second assemble.' Or: 'It works in VS but fails in CI.' Classic state leak. The generator remembered something from the previous compilation—a cached value, a static dictionary, a timestamp—and reused it when it shouldn't have. This article shows you how to spot those leaks, fix them, and avoid the most common caching traps in C# source generators.

Source generators run inside the compiler sequence. They see the same compilation object each time—but that object is new per run. If your generator stores anything in a static field, that value survives across compilations inside the same approach. Visual Studio keeps the compiler host alive between builds. So your stale data sticks around. On the command line, each dotnet assemble starts a fresh approach, so the leak might disappear—until you run two builds in the same method (like with dotnet watch). That inconsistency is the first clue.

Where State Leaks Hit You: Real-World Scenarios

The static cache that worked in tests but failed in VS

You write a generator. It holds a static ConcurrentDictionary to avoid re-parsing the same syntax tree twice. Unit tests pass—green, every time. But the moment you open the solution in Visual Studio, rebuild twice, and touch a single file—compilation breaks with stale symbols. That static field you trusted? It lives in the host approach. Visual Studio keeps the generator instance alive across design-time builds. Your cache doesn't clear. So the second construct reuses dictionary entries from the first—entries keyed to syntax trees that no longer match the edited source. You don't see the bug until you run a clean construct from the command line, which spawns a fresh approach. Then it works. That inconsistency—IDE vs. CLI—is the hallmark of a state leak. I have seen teams burn three days chasing this before someone added a [Generator] attribute check and realized the generator was never even re-instantiated. The check suite had been instantiating a new generator per trial, masking the problem entirely.

The real trap: your trial harness reused GeneratorDriver across probe methods—same driver, same cache. Wrong.

Shared analyzer and generator pairing that corrupted state

Generators often pair with analyzers. The analyzer writes to a shared ConcurrentDictionary keyed by project GUID. The generator reads it. That sounds fine until you realize the analyzer runs before the generator in some assemble pipelines and after in others—especially across different MSBuild targets. Now the generator reads stale keys from a prior compilation's analyzer pass. Or worse: the analyzer writes for project A, but the generator reads for project B because the dictionary key didn't include the project identity. We fixed this once by adding [ThreadStatic] on the cache field—only to discover that Roslyn's parallelism doesn't guarantee thread affinity per compilation. So the value hopped threads mid-run. Honestly—that's when you start questioning your career choices. The safer pattern is to store state inside the GeneratorInitializationContext or pass it via SyntaxReceiver, both of which are tied to the compilation lifetime.

‘A generator that outlives its compilation is a generator that will silently miscompile your code.’

— seasoned Roslyn contributor, after a five-hour debugging session

GeneratorDriver reuse in unit tests causing cross-check contamination

Most check harnesses for generators look like this: create a GeneratorDriver, add your generator, run, assert. Then the next check reuses that driver because it's expensive to rebuild. The driver itself holds a reference to the previous compilation's SyntaxTree set. Even if you call RunGeneratorsAndUpdateCompilation with a fresh Compilation, the driver's internal generator results cache may still return old outputs. I have watched tests pass in isolation but fail when the suite runs in alphabetical order. The fix? Instantiate a new driver per probe—or, if performance hurts, at least per test fixture. The catch is that teams often skip this because 'it works on my machine'. And it does—until the CI runner hits a different ordering. That hurts. One team I know lost a whole sprint to a cache that only cleared when the runner had low memory pressure. The generator's OnExecute method was writing to a static List<string> scoped to the generator class. Multiple tests appended diagnostics to the same list. The assertions passed because each test checked its own entry—but the list never shrank. By test 47, it held 800 stale entries. Memory grew. The construct agent OOM'd. State leaks don't just corrupt logic; they corrupt infrastructure.

How do you catch this early? Write a test that runs the same generator twice on different sources, then asserts the second output is not influenced by the first. If it passes, you're probably safe. If it fails—you have a leak, and you'll see it first in the IDE, not in the single-run unit test your team ships to review.

What Developers Get Wrong About Generator State

Compilation-Bound vs. approach-Wide State

The first mistake is almost architectural: treating the generator approach like a short-lived batch job. It isn't. Your generator runs inside the same dotnet.exe or devenv.exe method that hosts the IDE—or the form server daemon. That sequence can live for hours, across dozens of compilations. So when you stash data in a static ConcurrentDictionary, you're not caching for one assemble. You're caching for every assemble that sequence touches until it restarts. I have seen teams debug for two days before realising their "per-compilation" cache was actually shared between the afternoon's fifteen rebuilds. The fix wasn't a cache invalidation strategy—it was understanding that static means method-wide, not compilation-wide.

'A static field in a source generator is like a guest who arrives at a party and never leaves—even after the party ends.'

— seasoned .NET developer after a two-day debugging session

The Illusion of Isolation: Why Static Fields Feel Safe

Developers reach for static fields because they're convenient, fast, and—on first glance—they work. The catch is that they work too well. Your memoization pattern (check if key exists, return cached result) returns stale data when the next compilation should produce a different result. Most teams skip this: checking whether the generator's process actually reset between builds. It hasn't. The IDE keeps the process warm. Your static HashSet of "already processed symbols" grows silently; new compilations skip types that should be regenerated. That hurts. You lose a day, maybe two, before someone runs dotnet assemble --no-cache and the bug disappears. The real fix? Never assume a new compilation equals a new process.

Reality check: name the development owner or stop.

  • Static collections survive process restarts only if the process doesn't restart—which it very often doesn't
  • Concurrent dictionaries feel thread-safe but offer zero isolation guarantees across compilation boundaries
  • Lazy singletons in generators? That's a leak waiting to happen

Incremental Generators Are Not Immune by Default

Here's the painful truth: migrating to IIncrementalGenerator does not automatically solve state leakage. The incremental pipeline handles output caching—it skips re-executing your transform if inputs haven't changed. But it can't reach inside your Initialize method and purge your static variables. Wrong order: you wired up incremental caching while keeping process-wide state in your helper classes. The result? The generator's output cache says "skip, inputs unchanged," but your static fields already mutated from a prior compilation. That seam blows out when a developer adds a new source file, expects regeneration, and gets silence. One concrete anecdote: we fixed this by removing every static field from the generator assembly and pushing all shared state into the Compilation object's context dictionary—where it dies with the compilation. Not pretty, but it works. The incremental API is a tool, not a guarantee.

— experience from shipping a multi-module code generator at scale

Patterns That Actually Keep State Isolated

Using ISyntaxReceiver with per-compilation instances

The first pattern that actually works—and I mean works without phantom bugs—is giving every compilation its own receiver instance. Most teams skip this: they declare a singleton receiver in the generator attribute, and it accidentally lives across the host process lifetime. That sounds fine until your IDE triggers two back-to-back builds and the second run inherits syntax nodes from the first. The fix is brutal but simple—instantiate your ISyntaxReceiver inside Initialize(), not as a static field. Wrong scope? Wrong cache. We fixed a production outage this way: a stale List<ClassDeclarationSyntax> from a previous edit kept generating wrappers for methods that no longer existed.

The tricky bit is that Roslyn reuses generator instances for efficiency—so your ISyntaxReceiver> will live longer than one compilation. The moment you store state on the receiver itself, you have a global variable in disguise. Most teams miss this: they check the receiver in debug mode, see it's empty after a clean, and assume it's fine. It's not fine after the tenth incremental form. One concrete anecdote: a teammate spent two days chasing "impossible" duplicate partial declarations—turned out the receiver was appending to a List that never cleared between compilations. The fix? Create the receiver inside Initialize() and let it die with each pipeline run.

Sprint drills, plyometric hops, tempo runs, mobility circuits, and cool-down walks load joints differently after travel weeks.

Heddle selvedge weft drifts left.

Incremental generator pipeline with value providers

The incremental generator API—IIncrementalGenerator paired with IncrementalValuesProvider—is Roslyn's actual answer to state leaks. Not a marketing answer, a real one. The pipeline forces you to treat state as immutable: instead of a mutable accumulator in a receiver, you define transforms that produce new cached values. The catch is the learning curve: most developers write incremental generators that look exactly like their old source generators, minus the ISyntaxReceiver. That defeats the purpose. You lose the isolation guarantees the moment you capture a reference to a Compilation object inside a RegisterSourceOutput lambda—that reference can pin memory across cycles.

What usually breaks first is the cache key. Roslyn's pipeline caches outputs by comparing your provider's equality. If your provider returns a complex object without a proper Equals override, the pipeline recomputes everything every assemble—or worse, caches stale outputs. We debugged this once by adding a GetHashCode that included the syntax tree's FilePath and version stamp. Not glamorous, but it stopped the random stale-code generation that appeared only on the third F5 in a row. Honest opinion: use value providers with simple types—strings, integers, enums—and let Roslyn handle deduplication. Nested objects are where the seam blows out.

Capturing compilation identity in cache keys

Proper cache keys require the compilation's identity, not just its content hash. Compilation identity—a combination of the assembly name, the targeting pack version, and the Compilation.SourceModule GUID—changes whenever any input changes. Most teams use the source tree content hash alone. That fails silently when a referenced project rebuilds but your generator doesn't re-run—suddenly you're emitting code that references old public API signatures. The pattern: extract context.Compilation.Identity inside RegisterSourceOutput and append it to every cache entry. Then flush entries where the identity doesn't match the current compilation.

There's a performance trade-off here: checking identity on every output adds measurable overhead on large solutions. We saw a 12% slowdown on a 200-project solution after adding identity checks. That hurts. But the alternative—silent stale output that corrupts a release build—costs more. One team I consulted wrapped their cache in a ConcurrentDictionary<CompilationIdentity, CacheEntry> and used TryAdd per compilation cycle. The identity key guaranteed that each compilation's cache partition stood alone. It wasn't fast. It was correct. In source generators, correct beats fast every time—because a fast wrong answer ships bugs.

“The most expensive cache is the one you forget to invalidate—it runs fast right up until it ships a production outage.”

— overheard at a .NET meetup after a particularly painful rollback

Anti-Patterns That Teams Eventually Revert

Mutable Static Dictionaries for Caching

The most popular trap in source-generator land: a static ConcurrentDictionary<string, MyCachedData> sitting at class level, collecting results across compilations. I have seen teams ship this pattern and celebrate the performance win—until the second build returns stale symbols. The dictionary lives for the process lifetime, meaning every design-time build, every Roslyn analysis pass, every test-run inherits whatever the previous compilation left behind. That sounds fine until you realize the generator never sees a cache-invalidation signal; it just fetches keys that should have expired. What usually breaks first is incremental builds: a developer edits one file, the generator picks the cached value from a different compilation context, and suddenly the output is wrong—silently. The catch is that clearing the dictionary manually (say, on Initialize) only works if you control the lifecycle, but Roslyn may reuse the generator instance across projects in a solution. You end up with race conditions between concurrent analysis passes, and teams eventually revert because the cache—honestly—saves milliseconds but costs hours of debugging.

Shared State Through Analyzer References

Another anti-pattern: threading mutable state through analyzer references or AdditionalFiles that act as global singletons. The idea is to share parsed data between generators—avoid re-parsing a configuration file a dozen times. Wrong order. What developers miss is that analyzer references are not isolated per-compilation; they persist across the host process, so one project’s generator can corrupt another’s state. I fixed this once by adding a Guid key per compilation—until I realized the shared dictionary still held stale entries from a previous solution load. The seam blows out when you have a CI pipeline running multiple projects in parallel: the static holder becomes a concurrency nightmare, and you can't reliably reset state because you don’t own the instantiation point. Most teams strip this pattern inside two sprints—not because it doesn't work in isolation, but because the failure mode (wrong generated code deployed to staging) is too expensive to ignore.

Odd bit about development: the dull step fails first.

Trying to Clear Cache on Compilation Start

Then there is the desperate approach: hooking into CompilationStartAction or Initialize and calling .Clear() on your static store. That hurts. The generator instance might be reused across multiple compilations within the same process—think IDE background analysis—so a single clear call at initialization doesn't reset state for subsequent passes. Worse, you can't differentiate a fresh compilation from a re-analysis without brittle timestamp checks. One team I worked with added a Compilation.SyntaxTrees.Count heuristic to detect changes. It failed: they collected stale items from a removed file, and the generator still emitted the old partial class. The inability to reliably reset state forces developers into hacks like AppDomain reloading or custom process isolation—neither is a realistic fix for a library you ship to others. Eventually, you revert to stateless design, because a cache you can't trust is worse than no cache at all.

“We added a static cache, shipped it, and spent a month chasing phantom symbols. The fix was deleting 40 lines of shared state.”

— former team lead, after migrating back to per-compilation Dictionary instances

The Long-Term Cost of Leaky State

Intermittent build failures that erode trust

The worst kind of build failure is the one that disappears when you look at it. A teammate pushes a change to a different generator—completely unrelated—and suddenly your output is missing a method. No red squiggle in the IDE. No compiler error. Just an empty build log and a feeling that you're being gaslit by your own tooling. I've watched teams spend three sprints chasing a bug that ultimately boiled down to one static ConcurrentDictionary living past a single compilation, poisoning every subsequent run. That's not a bug—that's a slow-motion productivity drain. Each rebuild becomes a coin flip: will this work, or will someone have to dotnet clean and restart the IDE? After the fifth time, nobody trusts the generator anymore.

Hard-to-reproduce bugs wasted on the wrong people

These bugs rarely hit the person who wrote the leaky code. They surface in CI, on a developer's machine two timezones away, or only when the build cache is in a particular state. The junior on the team spends three hours trying to reproduce it locally—can't. The senior says "clear your obj folder," and that works, but nobody knows why. That's the insidious cost: you're burning your most expensive resource—developer attention—on workarounds instead of features. One concrete example from a project I inherited: the generator cached a file hash per-compilation in a static field. Worked fine on a cold build. But on the second incremental build, it reused stale metadata and emitted a partial partial class that didn't compile. The fix took ten minutes. Finding it took three weeks. Meanwhile, the team had added a cleanup step to every CI pipeline as a band-aid. That band-aid is a permanent tax now.

Migration pain when switching from non-incremental to incremental

The real reckoning comes when you finally decide to make your generator IIncrementalGenerator compliant. Suddenly every lazy static cache is a landmine. The incremental pipeline expects state to be scoped per-compilation, because that's the contract. Leaky state doesn't just fail—it fails silently, producing wrong output that passes unit tests but breaks in production. I've watched teams abandon the migration halfway through because untangling the state dependencies was harder than rewriting the generator from scratch. That's the long-term cost nobody budgets for: not the initial leak, but the rewrite you didn't plan.

'We thought a static cache was just an optimization. Turned out it was a time bomb with a two-year fuse.'

— Lead dev on a code-gen project that was eventually scrapped

That scrapping is the final cost. When your generator leaks state across compilations, it stops being a tool and starts being a liability. Teams either invest in a full rewrite—betting that the second version won't repeat the same mistakes—or they pull the generator entirely and go back to manual code or T4 templates. Neither path is cheap. Neither path is fast. And both paths start with a conversation nobody wants to have: "Our generator used to save us time. Now it's the thing slowing us down."

When You Shouldn't Use a Generator at All

When File Timestamps Aren't Enough

I once watched a team burn three sprints trying to force a source generator to re-run when a configuration file changed on disk. The generator cached parsed JSON, perfectly isolated per-compilation—until someone touched the file without rebuilding. The compiled output stayed stale. Generators live inside the compiler's world; they don't watch file-system watchers, they don't poll directories, and they definitely don't rerun because a timestamp shifted. The compiler decides when to invoke them, not your data's freshness. Most teams skip this: a generator that reads external files must treat every read as potentially stale. You can't cache file contents safely unless you accept that the cache may outlive the file. That hurts. If your logic depends on File.GetLastWriteTime or environment variables that change between builds—not between edits—you're better off with a build task that runs unconditionally.

Buttonholes, snaps, zippers, hooks, rivets, eyelets, and magnetic closures each need discrete QC steps before boxing.

Fjords kelp basalt look wild.

Runtime Logic That Shouldn't Be Compile-Time

Not every clever transformation belongs in a generator. Consider a feature that formats user-provided strings based on a locale file shipped at deployment. Why would you bake that into source generation? The locale changes, the generator produces old output, and suddenly your French users see English fallbacks. Pure runtime logic—where data arrives after compilation—belongs in a service, not in [Generator]. The catch is obvious: generators optimize for zero runtime overhead, but they introduce a compile-time dependency on data that might not exist yet. What usually breaks first is the developer who expects the generator to "just work" when a new translation file lands mid-sprint. It won't. A handwritten factory or a lazy-loaded dictionary costs microseconds at startup and saves days of debugging stale caches. I have seen teams rewrite generators as console apps precisely because they needed file-system awareness that the compiler can't give them.

'We thought the generator would detect file changes automatically. It wasn't until production that we realized the generated code was two weeks old.'

— Lead developer on a failed CI pipeline, paraphrased from a retrospective I attended

When a T4 Template or Console App Is Actually Simpler

Honestly—T4 templates get a bad reputation, but they solve exactly this problem. They re-run on save, they can read external files, and they don't pretend to be stateless. A generator that leaks state across compilations is harder to debug than a T4 template that regenerates every time you hit Ctrl+S. Console apps, too. Write a dotnet tool that reads your schema, emits code, and exits. No caching, no compiler integration, no mysterious staleness. The trade-off: you lose incremental build speed. But if your generator's incremental step is broken—returning cached data from last Tuesday instead of fresh output—the speed is a mirage. Wrong order. Fast compilation with wrong output is slower than slow compilation with correct output. Next time you find yourself writing a Dictionary inside a generator "for performance," ask whether that dictionary will ever hold outdated keys. If the answer isn't a confident "no," switch to a tool that doesn't pretend to be stateless.

Not every development checklist earns its ink.

Open Questions & FAQ

Does the compiler pool generator instances?

Short answer: yes, and that's exactly where your state leak festers. The Roslyn compiler does cache generator instances—it creates one object per generator type per compilation session, then reuses it. Most teams miss this: they assume a fresh `Initialize()` call means a clean slate. It doesn't. Static fields, `ConcurrentDictionary` caches, or even a lazy `DateTime` stamp from the first run survive across source files and—critically—across incremental builds. I have seen a team spend three days chasing an "impossible" bug because their generator's `static HttpClient` held a stale auth token from the initial compilation. The compiler never told them. It just kept handing back that same instance, token and all.

What usually breaks first is your incremental caching marker. You decorate a method with `[Generator]`, implement `IIncrementalGenerator`, and think the pipeline resets per source file. Wrong order. The pipeline resets per compilation *session*, but the generator object itself persists. If you store anything in a non-static field that outlives a single `Execute()` call—like a `HashSet` of processed syntax trees—you're sharing state between files the compiler processes sequentially. That feels like a feature until a file gets deleted and your HashSet still references it. Then you get a ghost output. Not fun.

Can I share state between generator runs intentionally?

You can, but I wouldn't. The compiler offers no sanctioned mechanism for cross-compilation sharing—no `ISourceGenerator` hook, no `GeneratorExecutionContext` extension that lets you stash data for the next build. Some teams hack it with environment variables or file-system artifacts: write a JSON blob to `%TEMP%` on first run, read it on the second. That works until your CI pipeline parallelizes builds and two generators fight over the same temp file. The seam blows out silently. You'll see intermittent output differences that vanish on retry—the classic "works on my machine" trap.

If you absolutely must share state—for example, a global registry of all partial types across projects—use the build's own intermediate output path (`$IntermediateOutputPath`) and a well-known filename. Even then, you're betting that no two generators write at the exact same millisecond. That bet loses daily. The safer pattern is to generate *local* state per compilation and let MSBuild's target ordering handle aggregation. It's slower. It's more code. But it doesn't corrupt your output when a second developer kicks off a build on the same agent.

'We shipped a generator that cached a List of all enum types across projects. Two months later, the list contained enums from three different solution branches. The compiler had never recycled the AppDomain.'

— senior dev, after reverting the feature

Why does my generator work on first build but fail on the second?

Classic stale-cache symptom. First build: your generator initializes, populates a `static Dictionary` with transformed output, and produces correct code. Second build: the compiler reuses the instance, the dictionary still holds keys from the first run, but the new source files have different names—or the same names with different content. Your generator sees the old key, skips regeneration because it "already handled" that file, and the build succeeds with stale output. You don't notice until someone adds a property and the generated code still shows the old version. That hurts in production.

Here is where most debugging strategies fail: developers look at the generated file, see it's wrong, and blame the generator logic—not the caching layer. They rewrite the pipeline, but the static state remains. The fix is brutal but effective—never store compilation-specific data in static fields. Ever. Use `ValueProvider` nodes and let the incremental pipeline manage memoization. If you must cache, key *everything* off the compilation identity. Not the name. Not the timestamp. The full `Compilation` object reference from `GeneratorExecutionContext`. That reference changes every session, so your cache auto-invalidates. Took us two weeks to learn that lesson. One line of code to fix it.

Next Experiments: Test Your Generator's State Isolation

Logging pipeline keys to detect caching issues

The fastest way to catch a leak is to instrument your generator's pipeline key. I've debugged three separate source generator failures where the root cause was a missing or stale AdditionalText key — and every single time, the team had zero logging around what the compiler actually cached. Drop a Console.WriteLine inside your Initialize method? That's useful but not enough. You need to log the composite key: the combination of syntax tree version, analyzers config hash, and every embedded file path. Without that, you're guessing. The tricky bit is that incremental generators cache aggressively — if your key doesn't change, the generator doesn't run. Wrong order of checks? Your key stays static while your source files evolve. That hurts. Log it, then force a clean build and compare the output. Most teams skip this step until a production pipeline starts emitting stale partial classes — don't be that team.

Writing integration tests with GeneratorDriver in isolation

Unit-testing a generator in isolation means using CSharpGeneratorDriver.Create() — not the full MSBuild pipeline. Why does that matter? Because the driver lets you control the exact set of syntax trees, analyzer config options, and parse options. No project file interference, no implicit GlobalUsings bleeding in. The catch: you have to reset the GeneratorDriver instance between each test run. One team I worked with kept a static driver in their test fixture — every test after the first one ran against a polluted cache. Took them three days to spot it. The fix is trivial: new driver per test method. Also test the incremental scenario — run two passes with a single file change between them. If the output doesn't change, you've proved your key is correct. If it does change when you only added a comment, your key is too broad. That's a different kind of leak — performance-wise, it'll kill you.

“A generator that passes every unit test but fails in CI is a generator that leaks state through the test harness itself.”

— overheard at a .NET meetup after someone's third bisect session, 2024

Adopting incremental generation from day one

Start with the [IncrementalGenerator] attribute — even for prototypes. Porting a non-incremental generator to the incremental model later is painful; the API shapes diverge in subtle ways (no more SyntaxReceiver, everything goes through IncrementalValueProvider). What usually breaks first is state that lives outside the pipeline: static dictionaries, file-system caches, or singleton ConcurrentDictionary instances used to avoid recomputation. Those are landmines. The incremental model forces you to declare your dependencies explicitly — your source, your options, your additional files. Nothing else. If you can't express a computation purely as a function of those inputs, you probably shouldn't use a generator at all. I've seen teams bolt on IMemoryCache inside their generator, convinced it was an optimization. Honestly — that's the fastest way to create a leak that only reproduces after the tenth compilation inside the same IDE session. Don't do it. Embrace the stateless pipeline; your future self (and your team's CI pipeline) will thank you.

Share this article:

Comments (0)

No comments yet. Be the first to comment!