skip to content

You inherit a Terraform module where nearly every resource argument is a reference to a local, some chained three deep through other locals. What does that cost the team, and when is a local genuinely earning its place?

level: seniorimportance: nice to knowfreq 32%

answer

  1. indirection has a reader cost
  2. plan review is the real audience
  3. used once means inline it
  4. one definition, many readers, wide blast radius
  5. empty plan proves the refactor

basics

~20 s

Every layer of indirection is another jump a reviewer must make to see the real value, and a widely-shared local silently fans one edit out across many resources. A local earns its place when it removes genuine repetition or names a complex expression, not when it merely renames a variable.

solid answer

~60 s

The cost is review latency and blast radius. A reviewer reading `bucket = local.artifacts_name` cannot tell what will actually be created without chasing the chain, and in a plan review — where the whole point is spotting the destructive change — that friction is expensive. The second cost is fan-out: because a local is one definition with many readers, changing it touches every reader at once, which is a feature for a tag map and a hazard for anything that feeds a resource's name or identity. My rule of thumb is that a local earns its place when it removes real repetition (used three or more times), when it names a genuinely hard expression so the resource block reads as intent, or when it builds the map that drives a `for_each`. A local that just renames a single variable, or that is read exactly once, is pure indirection — inline it. And since locals are neither stored in state nor part of a module's interface, inlining or renaming one is a free refactor that produces an empty plan.

go deeper

for a junior

Understand that a local is worth adding when the same expression would otherwise be repeated, and that a local used once often reads better inlined.

for a middle

Explain both the benefit and the cost concretely: one definition means one edit, but it also means one edit changes every reader, and each layer is another hop for a reviewer.

for a senior

Demonstrate the refactor discipline — small commits, each verified by an empty plan — and the identity-versus-tags distinction that decides how risky editing a given local is.

for a principal

Own the line between an internal local and a shared versioned module: once a derivation is copied across root modules it needs a real interface, not a duplicated locals block that drifts.

## Why this is a real question and not a style opinion Terraform configuration is read under a specific pressure that ordinary application code is not: someone is comparing a diff against a plan, at speed, deciding whether it is safe to apply to production. Anything that increases the distance between what the code says and what will exist raises the chance a destructive change slips through. Indirection has a genuine cost in that setting, which is why over-abstraction shows up as an interview topic at all. ## The three costs **Reading distance.** `bucket = local.artifacts_name` tells a reviewer nothing. If `local.artifacts_name` is `"${local.name_prefix}-artifacts"` and `local.name_prefix` is `"${var.project}-${local.env_short}"` and `local.env_short` maps the environment through a lookup table, the reviewer needs four hops to answer "what bucket is this". Multiply by the number of resources in the diff. **Fan-out.** A local exists precisely so that many places share one definition. That is the value for a tag map, where you want every resource updated together. It is a hazard when the local feeds an attribute that is part of a resource's identity — a bucket name, an instance name used in a `name` argument, a DNS record — because a one-character edit then plans as destroy-and-create across everything downstream. The fix is not to avoid locals but to know which of your locals feed identity and to treat edits to those as high-consequence changes. **False configurability.** A repository where everything routes through locals often gives the impression of being parameterised when it is not. A local cannot be set from outside; if the intent was "this should be tunable per environment", the local is the wrong construct and the value should be an input variable with a default. ## When a local is genuinely earning its place Four tests, any one of which is sufficient: 1. **Repetition.** The expression appears three or more times. One definition, one edit, no chance of the copies drifting. 2. **Complexity.** The expression is hard to read inline — a nested `for`, a `cidrsubnet` calculation, a chain of `try`/`coalesce` — and a name turns it into intent: `local.private_subnet_cidrs` reads better than the arithmetic that produced it. 3. **Structure for repetition.** The local builds the map or set that a `for_each` consumes. This one is close to mandatory: putting that expression inline in `for_each` is unreadable and makes the key structure impossible to review. 4. **Single point of policy.** A tag map, a naming convention, a boolean like `local.is_prod` that several conditionals consult. Centralising the *decision* is the point. Against those, the antipatterns are easy to name: a local that aliases exactly one variable under a different name; a local used exactly once, where inlining the expression costs nothing and saves a hop; a chain of locals that exists because each step was added by a different person; and a local whose name is less informative than the expression it replaces (`local.val`, `local.cfg`). ## Refactoring safely The good news is that locals are cheap to change. They are not recorded in state, they are not part of the module's interface, and nothing outside the module can reference them. So inlining a one-use local, renaming a badly named one, or collapsing a three-deep chain is a refactor whose correctness you verify the same way you verify anything else here: run `terraform plan` and require it to come back empty. An empty plan proves the computed values are identical. If the plan is not empty, you changed behaviour, and the diff tells you exactly where. That verification loop is the practical answer to "how do you clean this up without breaking production". Do it in small commits, each one plan-empty, rather than one large restructuring whose plan you can no longer read — which would be repeating the original mistake in a different direction. ## The boundary worth stating Locals are module-scoped, so this abstraction does not travel. Copying a clever locals chain into a second module produces two copies that will drift. If the derivation is genuinely shared policy across many root modules, the thing to share is a versioned module with a declared input and output interface, not a copied `locals` block. Recognising when a local has outgrown its module and should become a module boundary is the senior judgment this question is really probing.

  • How do you verify that collapsing a chain of locals changed nothing?
    Run `terraform plan` and require it to be empty. Locals are not stored in state and are not part of the module interface, so the only thing that matters is whether the computed argument values are identical — an empty plan proves that directly. Do it as a series of small commits, each individually plan-empty, so a non-empty plan points at one change rather than fifty.
  • When should a shared derivation stop being a local and become something else?
    When more than one root module needs it. Locals do not cross module boundaries, so sharing them means copying, and copies drift. At that point the derivation belongs in a versioned child module with declared inputs and outputs, which gives you a real interface, a version constraint, and a single place to fix a bug in the logic.
  • Is there a case where a local read only once is still the right call?
    Yes — when the name carries meaning the expression does not. A single-use `local.is_prod = var.environment == "prod"` consumed by one `count` still documents intent better than the raw comparison, and it is the natural place for the next conditional to attach. The test is whether the name teaches the reader something, not the reference count alone.

saying these in an interview costs you the question

  • Treats more locals as automatically cleaner code
  • Uses locals hoping to make values configurable per environment
  • Copies a locals chain into a second module instead of sharing a module
  • Ignores that a shared local edit fans out to every reader
  • Assumes renaming a local is as risky as renaming an output

context