Locals, Collections, and Functions
Scenario: The same three-line tagging expression is copy-pasted into twelve different resource blocks, and over a year of small edits by different engineers, the twelve copies gradually diverge into eleven subtly different tagging policies.
A locals block lets you name a reusable expression once within a module and reference that name everywhere instead of repeating the underlying computation. It's worth being precise about what a local is not: despite the name's similarity to "variable" in other languages, a Terraform local is not mutable state - you can't reassign it partway through a configuration, it's simply a named shortcut for one fixed expression, evaluated once per plan.
Built-in functions are what make those expressions powerful without turning HCL into a general-purpose language: merge combines maps (with later arguments overriding earlier ones on key collisions), lookup safely reads a possibly-missing map key with a fallback, try and can let you attempt an expression and gracefully handle it not being valid, coalesce picks the first non-null value from a list of candidates, flatten collapses nested lists into one, and setproduct computes every combination across multiple sets (handy for generating one resource per region-per-environment pair). for expressions let you filter and reshape a whole collection into a new one in a single readable line, as shown below.
Analogy: Locals are labeled preparation bowls lined up on a kitchen counter, each holding one clearly-named ingredient that's already been measured and prepped. A reviewer can glance at the bowls and understand the recipe. One enormous nested expression, by contrast, is a blender with an opaque lid - you can see something went in and something came out, but you can't verify what happened in between without taking it apart.
A worked configuration
locals {
required_tags = { ManagedBy = "terraform", Env = var.environment }
effective_tags = merge(var.extra_tags, local.required_tags)
enabled_names = [for n, cfg in var.services : n if cfg.enabled]
}
A deceptively small detail with real governance consequences: merge(a, b) lets later arguments override earlier ones on a key collision. Put required, non-negotiable tags last in the merge() call if callers must never be able to override them (as in the example above), and reverse that order deliberately when you do want caller-supplied values to win. This one-line ordering choice is effectively a policy decision, and it deserves the same scrutiny (and ideally a test or validation rule) that any other governance control would get.
Common mistakes in production
- Copy-pasting the same multi-line expression into many resource blocks instead of naming it once as a local - the copies inevitably diverge as different people edit different call sites over time.
- Getting the argument order backwards in a governance-relevant
merge()call, silently letting caller-supplied values override a value that was meant to be mandatory.
Goal: Treat the plan as a change contract: understand every create, update, replacement, and destroy before approving it. Reinforce this lesson in the tf-count-for-each Terraform lab. Open/labs/terraformand choose slugtf-count-for-each; the lab runs real Terraform against the offline FakeCloud provider.