skip to content

Reading an environment variable with System.getenv() breaks the configuration cache. Why, and what is the correct migration?

level: middleimportance: must knowfreq 65%

answer

  1. ambient read not in cache fingerprint
  2. stale value on cache hit
  3. providers.environmentVariable returns tracked Provider
  4. systemProperty/gradleProperty/fileContents siblings
  5. ValueSource = general tracked escape hatch

basics

~10 s

System.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 s

The 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
kotlin
// 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

for a junior

Know to use providers.environmentVariable("X") instead of System.getenv("X").

for a middle

Explain the invalidation contract: untracked reads cause stale cache hits; providers register inputs.

for a senior

Wire providers lazily into @Input properties; reach for ValueSource when no built-in provider fits.

for a principal

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.

context