Local Values
A local value is a named expression computed once and reused — Terraform's answer to "I am writing the same constructed string in nine places." Where a variable is an input the caller supplies, a local is an intermediate the configuration derives: the common-labels map every Hatch resource carries, the "${var.project}-events-raw" bucket name, the environment-derived flags.
Locals exist to make a configuration DRY and readable, not to be parameterized. The caller cannot set them — that is the whole point. A value derived from other inputs belongs in a local precisely so two callers cannot supply it inconsistently and produce mismatched resource names.
The locals Block
A locals block defines one or many named values, each referenced as local.<name>. Unlike a variable, a local can reference other locals, variables, resources, and functions freely — it is a computed expression, not a declared input with a fixed shape. One configuration can have several locals blocks; they merge into a single namespace.
locals { name_prefix = "hatch-${var.environment}" } resource "google_pubsub_topic" "events" { name = "${local.name_prefix}-events" # derived once, reused everywhere }
The prefix is computed in one place and read as local.name_prefix wherever a name is built. Change the derivation once and every resource that references it follows.
Local vs Variable
A variable is a knob the caller turns; a local is a value the config computes and the caller cannot override. The test is simple: if the value is supplied from outside, it is a variable; if it is derived from other inputs, it is a local. Exposing a derived value as a variable just invites someone to set it inconsistently with the inputs it was supposed to follow from.
The Common-Labels Pattern
The canonical use is a local.common_labels map defined once and merged into every resource's labels. Define { team = "data", environment = var.environment, managed_by = "terraform" } in one place, then merge(local.common_labels, { role = "raw" }) on each resource to add its specific label. Changing the team or environment label across the whole pipeline is then a one-line edit, not a hunt through every resource.
locals { common_labels = { team = "data" environment = var.environment managed_by = "terraform" } } resource "google_storage_bucket" "raw" { name = "${local.name_prefix}-events-raw" location = var.region labels = merge(local.common_labels, { role = "raw" }) }
Every Hatch resource carries the same base labels plus its own role, and the base set lives in exactly one place. Inlining that map on ten resources instead means ten edits to change one label, and one you will miss.
Computed Intermediates and DRY
A local also earns its place pulling a complex expression out of a resource block and naming it. A jsonencode'd policy, a constructed resource name, a filtered list — lifted into a named local, the resource block reads as intent rather than mechanics, and the expression is written once instead of duplicated. The name itself documents what the expression produces.
When a Local Hurts
A local is not free: every one is a layer of indirection the reader has to resolve. A local aliasing a single value used once — local.bucket = google_storage_bucket.raw — adds that layer with no DRY payoff, since the original reference was already clear. Locals earn their place when the expression is genuinely reused or genuinely complex, not as a rename.
The same caution applies to depth. A chain of locals referencing locals referencing locals, five deep, defeats the readability they exist for — no one can trace where a value comes from. Keep them shallow, and reach for one only when reuse or complexity justifies the indirection.
Local — a value the configuration derives and the caller cannot override. Use it for anything computed from inputs: the name prefix, the common-labels map, a constructed policy. Its un-overridability is a feature — it keeps derived values consistent.
Variable — a value the caller supplies from outside. Use it only for genuine inputs that differ between environments. If you find yourself wanting a variable's default to be "derived from another variable," you wanted a local.
- Promoting a derived value to a
variableso it "can be configured," then having two callers set it inconsistently and produce mismatched resource names — a derived value belongs in alocalprecisely so it cannot be overridden. - Duplicating the labels map literal across ten resources instead of a
local.common_labelsmerged in — changing the team or environment label then means ten edits and one you will miss. - Building a deep chain of locals referencing locals referencing locals until no one can trace where a value comes from — locals are for clarity, and a five-deep chain defeats that.
- Reaching for a
localto alias a single-use value, adding a layer of indirection the reader must resolve for no DRY payoff. - Putting an environment-specific value that should differ per deploy into a
local, then being unable to override it from a.tfvars— that value was a variable all along.
- Use a
localfor any value the config derives rather than the caller supplies, and avariableonly for genuine inputs. - Define a
local.common_labelsonce andmergeit into every resource'slabels, so the team, environment, and managed-by labels are set in exactly one place. - Pull complex expressions — a
jsonencode'd policy, a constructed name, a filtered collection — into a namedlocalso the resource block reads as intent. - Keep locals shallow and reuse-justified; do not introduce a local that is referenced once and just renames a value.
- Name a local for what it produces, not how it is built, so the reference documents the resource block that uses it.
Knowledge Check
When does a value belong in a local rather than a variable?
- When the config derives it from other inputs rather than the caller supplying it — and you want it un-overridable so it stays consistent
- Whenever the value is a plain string rather than a number or a structured type
- When it needs to differ between the dev, staging, and prod environments and the caller should be able to override it freely on each separate run
- Only when the value is marked
sensitiveand kept out of plan output
Why is a local.common_labels map merged into every resource better than inlining the label map on each one?
- Changing the team or environment label is a one-line edit instead of ten edits with one you will miss
- Inlined label maps are rejected by the google provider at plan time
- A merged local applies the labels measurably faster than repeating an inline map on every single resource
- Only a local can hold more than one label key per resource
What is the failure mode of a local that just aliases a single-use value, like local.bucket = google_storage_bucket.raw?
- It adds a layer of indirection the reader must resolve with no DRY payoff, since the original reference was already clear
- Terraform errors at plan time because a local value is not allowed to reference a managed resource attribute like that one
- It silently overrides the bucket resource it aliases, mutating its arguments
- It forces the aliased bucket to be destroyed and recreated on every apply
A teammate exposes a derived bucket name as a variable so it "can be configured." What goes wrong?
- A caller can set it out of step with the inputs it should follow from, producing mismatched resource names — a derived value belongs in a local so nobody can override it
- Nothing goes wrong — a variable and a local are fully interchangeable for any derived value, so exposing the computed bucket name as a configurable variable is perfectly fine here
- The variable will not resolve at all, because a derived value can never be a variable
- Terraform automatically forces the derived variable to be marked
sensitive
You got correct