Reading an environment variable with System.getenv() breaks the configuration cache. Why, and what is the correct migration?
answer
- ambient read not in cache fingerprint
- stale value on cache hit
- providers.environmentVariable returns tracked Provider
- systemProperty/gradleProperty/fileContents siblings
- ValueSource = general tracked escape hatch
basics
~10 sSystem.getenv() reads ambient state Gradle can't track, so a cache hit could reuse a stale value. Use providers.environmentVariable("NAME"), which records the variable as a build input and invalidates the cache when it changes.
solid answer
~40 sThe configuration cache reuses a serialized build only when its **inputs** are unchanged. Calling `System.getenv("X")` (or `System.getProperty`, or reading files directly) during configuration reads ambient state that Gradle never observed, so it isn't part of the cache fingerprint. A cached build would silently reuse the old value even after the variable changes. The fix is to read it through the **provider API**: `providers.environmentVariable("X")` returns a `Provider<String>` and registers the variable as a configuration input. Gradle then tracks its value, and if it changes the cache entry is invalidated and configuration re-runs. Equivalents exist for system properties (`providers.systemProperty`), Gradle properties (`providers.gradleProperty`), and file contents (`providers.fileContents`). For arbitrary external sources not covered by these, wrap the read in a `ValueSource`, which is the general escape hatch that still keeps the read tracked.
code
kotlin · 11 lines// BAD: untracked ambient read -> stale on cache hit
val ver = System.getenv("APP_VERSION") ?: "dev"
// GOOD: tracked configuration input
val ver: Provider<String> =
providers.environmentVariable("APP_VERSION").orElse("dev")
tasks.register("printVer") {
val v = ver // capture the provider, not the Project
doLast { println(v.get()) }
}go deeper
Know to use providers.environmentVariable("X") instead of System.getenv("X").
Explain the invalidation contract: untracked reads cause stale cache hits; providers register inputs.
Wire providers lazily into @Input properties; reach for ValueSource when no built-in provider fits.
Codify provider-based config reading as a standard so the codebase is cache-correct by construction.
## The invalidation contract The configuration cache is keyed on a **fingerprint of all inputs** that influenced configuration: the build scripts, the requested tasks, and any external values Gradle was *told about*. On a later run with the same fingerprint, Gradle skips configuration and reuses the stored task graph. The danger with `System.getenv()` / `System.getProperty()` / reading a file via `new File(...).text` during configuration is that Gradle **never sees the read**. The value silently bakes into the serialized graph, but it is not part of the fingerprint — so when the variable later changes, Gradle still gets a cache hit and reuses the **stale** value. Gradle therefore reports these as configuration-cache problems and pushes you to the provider API. ## The provider replacements The `ProviderFactory` (available as `providers` in build scripts, or `@Inject ProviderFactory` in plugins/tasks) exposes tracked readers: - `providers.environmentVariable("NAME")` — `Provider<String>` for an env var - `providers.systemProperty("name")` — for a `-D` system property - `providers.gradleProperty("name")` — for a Gradle property - `providers.fileContents(layout.projectDirectory.file("x")).asText` — for file text Each of these **registers the source as a build input**. If it changes between runs, the cache entry is invalidated. Crucially, these are **lazy**: the value resolves when the provider is queried, and you can chain `.map { }`, `.orElse(...)`, `.getOrElse(...)`. ## Wiring it through a task ```kotlin abstract class StampTask : DefaultTask() { @get:Input abstract val buildNumber: Property<String> @TaskAction fun run() = logger.lifecycle("build=${buildNumber.get()}") } tasks.register<StampTask>("stamp") { buildNumber.set(providers.environmentVariable("BUILD_NUMBER").orElse("local")) } ``` The provider is wired into the task's `@Input` at configuration time, so the env var is a tracked input and the read at execution time is just `buildNumber.get()` — no ambient access. ## When nothing fits: ValueSource For sources the built-in providers don't cover (querying a command, reading a non-file system resource), implement a `ValueSource<T, Params>`. Its `obtain()` runs in isolation and its result is treated as an input. This keeps even custom external reads tracked rather than silently baked in. (Note: a `ValueSource` is re-evaluated to detect changes, so keep it cheap or model the inputs as parameters.) ## Summary Never read ambient state directly during configuration; always go through `providers.*` (or a `ValueSource`) so the read becomes a tracked input and the cache stays correct.
- If you use `providers.environmentVariable` but the variable doesn't change, does configuration re-run?No. The whole point is correctness without losing reuse: an unchanged tracked input keeps the cache entry valid, so configuration is skipped. Only a change to a registered input invalidates the entry.
- What's the difference between `providers.environmentVariable` and a `ValueSource` for reading an env var?For a plain env var, `providers.environmentVariable` is the purpose-built, cheapest option. `ValueSource` is the general-purpose mechanism for sources without a built-in provider (e.g., the output of a shell command); it gives you a custom `obtain()` whose result is tracked.
saying these in an interview costs you the question
- Saying it's just a deprecation — it's a correctness/invalidation bug (stale values).
- Claiming you must call `.get()` immediately — that defeats laziness; wire the Provider into a property.
- Thinking `System.getProperty` is fine while `System.getenv` is not — both are untracked ambient reads.