Skip to content
intermediate Phase 4 · Variables, Locals, and Expression Logic

Locals for Derived Values

Computing intermediate values once with locals instead of repeating expressions.

45m
0 problems
Topic Progress 0%

Naming the Expression You Were About to Write for the Third Time

You are on resource twelve of a migration, and every Name tag still says "${var.project}-${var.environment}-web" by hand. Reviewers cannot see the coupling; you cannot change the naming scheme without twelve edits; and one of the twelve says ${var.projecte} — a typo that plan will happily feed to a provider as a literal string. The locals block is the fix: a place to name derived values once and reference them everywhere.

locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }

  bucket_name = "${local.name_prefix}-logs"
}

resource "aws_s3_bucket" "logs" {
  bucket = local.bucket_name
  tags   = local.common_tags
}

Syntax is minimal: a locals block holds any number of label = expression assignments, and every label becomes available module-wide under the singular namespace local.<label> — local.name_prefix, never locals.name_prefix. Values can be any expression: strings, numbers, booleans, collections, objects, functions, references to variables, data sources, and other resources.

Locals may reference other locals, and that is where readability compounds: name_prefix composes the variable inputs, bucket_name composes name_prefix, and a future log_archive_name can build on both. The order of assignments inside a block is irrelevant — Terraform arranges locals into its dependency graph and evaluates each one only when something needs it. Which gives you the one hard error to know: a cycle.

Error: Cycle: local.name_prefix, local.name_suffix

local.name_prefix references local.name_suffix, which in turn references
local.name_prefix.

A local that (directly or transitively) refers to itself can never be resolved, and Terraform detects the cycle at plan time before evaluating anything. The fix is to break the loop, usually by extracting the shared piece into a third local rather than deleting logic.

Two evaluation facts matter in practice. First, locals are lazy: if nothing references local.unused, it is never evaluated, so an expensive or error-raising expression in an unused local does not break the run. Second, unknown values flow through locals exactly as they do anywhere else — if var.ami_id is unknown at plan time because it comes from another resource's output, local.ami = coalesce(var.ami_id, data.aws_ami.latest.id) stays unknown until apply and then resolves, carrying the dependency edge with it. Terraform does not evaluate locals "before" or "after" resources; it evaluates them wherever the graph places them.

That is the whole mechanism. Two structural details round it out. First, a module may contain multiple locals blocks in any file — Terraform merges them into a single namespace, which is why teams keep a locals.tf for shared derivations next to small blocks beside the resources that use them; labels must stay unique across all of them, and a duplicate name fails at load time rather than shadowing silently. Second, locals are ordinary graph nodes, so they feed count and for_each directly: for_each = local.enabled_envs iterates a derived map, and tags = local.common_tags applies a derived object, with no ordering caveats — the value simply resolves before the argument that consumes it. For what to put inside, the next chapter covers the derivation patterns that make locals worth the keystrokes; meanwhile, Variable Validation Rules remains the gate in front of the variables these expressions consume.

Deriving Once: Tags, Names, and Filtered Maps from for Expressions

A locals block earns its keep when it replaces a repeated transformation — something computed from inputs rather than the inputs themselves. Four derivations cover most of what you will write.

Merged tags. Teams always carry a base tag set plus per-stack extras. The trap is merge order: later maps win.

locals {
  common_tags = {
    Project   = var.project
    ManagedBy = "terraform"
  }
  tags = merge(local.common_tags, var.extra_tags)   # extra_tags may override
}

Callers override deliberately (extra_tags = { ManagedBy = "platform-team" }), and the ordering in merge documents who wins. Reverse the arguments and a caller's override is silently dropped — a bug that only shows up in cost-allocation reports months later.

Name composition. Centralize the scheme so it changes in one place:

locals {
  name_prefix = lower("${var.project}-${var.environment}")
  db_name     = "${local.name_prefix}-postgres"
}

lower(...) and friends run at plan time; the resulting string is what the provider receives.

Filtered and keyed collections. The for expression is the workhorse. Filtering a map by an attribute, re-keying a list, building a set of allowed CIDRs — all are one-liners that used to require a data source or a script:

locals {
  enabled_envs = { for k, v in var.environments : k => v if v.enabled }
  instance_by_name = { for i in var.instances : i.name => i }
  private_cidrs    = [for i, c in var.public_subnet_cidrs : cidrsubnet(var.vpc_cidr, 8, i + 8)]
}

The for form <result> for <iterator> in <collection> [: <key> =>] <value> [if <condition>] produces a map when you name a key expression and a list otherwise. keys(local.enabled_envs) and values(...) then feed for_each blocks — this is the standard pipeline for turning a variable list into a set of per-object resources without a hard-coded count.

Lookup chains. Region-specific settings collapse into a single indexed map:

locals {
  instance_type_by_size = { small = "t3.micro", large = "m6i.large" }
  instance_type         = lookup(local.instance_type_by_size, var.size, "t3.micro")
}

The default in lookup prevents an unknown key from being a hard failure — pair that with a validation rule from Variable Validation Rules when you would rather reject the input outright.

Structured documents. IAM and bucket policies are JSON; building them with jsonencode inside a local gives you string interpolation, plan-time type checking, and no heredoc quoting bugs:

locals {
  bucket_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
      Action    = "s3:GetObject"
      Resource  = "${aws_s3_bucket.logs.arn}/*"
    }]
  })
}

The output is single-line JSON that a policy resource consumes directly, and because it is built from references, changes to the bucket ARN or account id flow into the policy in the same plan. Compare that with pasting a multi-line heredoc where a missing comma surfaces as a provider error after the resource begins creating.

The payoff is a property reviewers feel but rarely name: diffs shrink to the truth. When the naming scheme changes, the plan shows one changed local feeding several attributes, not twelve independent edits you must re-approve by eye. And because every reference goes through one label, a typo (local.nme_prefix) fails at plan with an undefined-reference error instead of silently becoming a literal tag value. That is the practical difference between an expression repeated twelve times and a value with a name.

What Locals Are Not: State, Outputs, and Module Boundaries

Locals are configuration, and understanding exactly what that means prevents three recurring mistakes.

Locals never appear in state. Open terraform.tfstate and grep for local. — nothing. State tracks managed objects and their attributes; a value derived inside the configuration is not an object, so it is never persisted, versioned, or refreshed. Two consequences follow. Changing a local's value produces a diff in whatever references it (a tag, a bucket name) but no state entry to update, which is why editing locals can never orphan a resource by itself. And a local is not a secret store either — it lives in your git history like any other configuration, and anything derived from it shows up in plan output and the provider's API calls.

Locals do not cross module boundaries. A child module sees only its own local.* labels; the parent's locals are invisible to it, and the child's locals are invisible to the parent. Data flows one way in and one way out: input variables in, outputs out.

module "network" {
  source = "./modules/network"

  vpc_cidr = local.vpc_cidr              # parent local -> child input variable
  subnets  = module.network.subnet_ids   # child output -> parent value
}

When another module needs a derived value, the correct move is to promote it: compute it in the module that owns the derivation and expose it through an output, then pass that output into the consuming module as a variable. A local that three modules all duplicate is a local that will drift in one of them.

Locals are not variables, and the distinction is interview-standard. A comparison makes it precise:

variable local output
Set by The caller (tfvars, flags, env) The configuration itself Exported to callers
Reference syntax var.name local.name module.x.name
In state As its value, if referenced by a managed resource argument Never Yes, in the outputs section
Crosses module boundary Into the module No Out of the module

The stock interview question — "difference between a variable and a local?" — is answered by the first two rows: a variable is an interface consumed from outside, a local is an internal named value. Follow-up: "when would you turn a local into an output?" When a different module needs the derived value, not when you merely want to see it; for inspection, terraform plan and terraform console already show computed values.

Debugging derived values does not require a plan either: terraform console in the module directory, then local.common_tags, prints the computed object — including references to data sources — without touching infrastructure. When a tag looks wrong in a plan diff, reading the local first tells you whether the bug is in the derivation or in the resource argument that consumed it, which is exactly the distinction that turns a fifteen-minute hunt into a one-minute one.

One last myth: locals are sometimes avoided "for performance." There is nothing to optimize. Each local is evaluated at most once per run, lazily, only if referenced — a hundred locals cost nothing measurable. The reason to use them is correctness and legibility: one name, one definition, one place to change. For branching inside a local, the next topic — Conditional Expressions — is the natural tool.

Quiz

1. A locals block contains `a = local.b + 1` and `b = local.a - 1`. What happens and why?

Question 1 options

2. Where do local values appear in Terraform's state file?

Question 2 options

3. A parent module computes `local.subnet_ids` and a child module needs it. What is the correct mechanism?

Question 3 options

4. You change a local that is referenced by the bucket name of one resource. What does the next plan show?

Question 4 options

Flashcards

Question

How is a local value referenced, and what namespace mistake do beginners make?

Answer

As `local.<label>` — singular — e.g. local.name_prefix, from anywhere in the declaring module. `locals.name_prefix` is invalid: `locals` is only the block keyword for declarations, not a reference namespace.

Question

In what order are locals evaluated, and what does a Cycle error mean?

Answer

They have no positional order: Terraform builds a dependency graph and evaluates each local when needed, lazily — an unreferenced local is never evaluated. A Cycle error means a local depends on itself transitively, so no evaluation order exists; break the loop by extracting a shared third local.

Question

What does merge(local.base, var.extra) guarantee about overrides, and what happens if you reverse the arguments?

Answer

Later arguments win, so var.extra can override keys in local.base. Reversed — merge(var.extra, local.base) — the base silently overwrites the caller's overrides, which surfaces only later in tags or cost reports, never as an error.

Question

How does a for expression decide between producing a map and a list?

Answer

With a key expression — `{ for i in var.list : i.name => i }` — it produces a map keyed by that expression; without one — `[for i in var.list : i.name]` — it produces a list. An optional trailing `if <cond>` filters elements, which is how enabled-only maps for for_each are built.

Question

Do locals appear in state, and does that make them a safe place for secrets?

Answer

No, locals are never written to state. That does not make them safe for secrets: they live in git history and show up in plan output and API calls; use a secret manager plus a data source, and mark the consuming input variable sensitive for display masking.

Revision Notes

Key Takeaways

  • 1. Locals name derived values once under the singular local. namespace, replacing repeated expressions and turning typos into plan-time reference errors
  • 2. Evaluation is lazy and graph-ordered; the only structural failure is a dependency cycle, reported at plan before anything is computed
  • 3. Locals never appear in state and never cross module boundaries — cross-module sharing is output → input variable, promoting the value to a module interface
  • 4. merge order decides override behavior: later maps win, so base-then-extra reads correctly and extra-then-base silently clobbers callers
  • 5. for expressions inside locals are the standard bridge from variable lists to keyed maps for for_each, including filtered subsets

Interview Tips

  • • "What is the difference between a variable and a local?" — variable is an input interface set by the caller (tfvars/flags/env) and can appear in state as a referenced value; local is an internal named expression, never stored, never crossing module boundaries
  • • "When do you turn a local into an output?" — when another module needs the derived value; otherwise plan and console already expose computed values, and gratuitous outputs widen the module's interface for no consumer
  • • "How would you change a naming scheme across forty resources safely?" — centralize composition in one local, reference it everywhere, and the plan shows one derivation feeding the changed attributes; contrast with twelve hand-edited strings and one typo
  • • "What does a Cycle error tell you, and how do you fix it?" — a local depends on itself transitively so no evaluation order exists; extract the shared component into a third local rather than deleting logic

Cheat Sheet

Locals

Declare and reference:

locals {
  name_prefix = "${var.project}-${var.environment}"
  tags        = merge(local.common_tags, var.extra_tags)  # later wins
  enabled     = { for k, v in var.envs : k => v if v.enabled }
}
# reference: local.name_prefix (singular namespace)

Evaluation: lazy + graph-ordered; unreferenced locals are never evaluated; self-reference transitively = Cycle error at plan; unknown values propagate through locals until apply.

Boundaries:

Question Answer
In state? Never
Visible in child module? No — pass via output → variable
Positional order in block matters? No
Performance concern? No — each evaluated at most once
Secret-safe? No — lives in git and in plans

var vs local vs output: variable = interface in · local = internal named value · output = interface out.

Continue Learning

Before You Start

Related Reading