TerraformXIII · Variables, Outputs, and LocalsProduction Terraform
Locals: Internal Variables
What you'll learn
- Use `locals` to compute values that the operator does not set
- Choose between a variable, a local, and an output for any given value
- Reduce duplication of complex expressions across multiple resources
- Avoid over-abstraction: locals that add indirection without clarity
- Recognise the scope and reference rules of locals within a module
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A locals block is where Terraform stores values that are derived from
other values. The configuration produces them, not the operator. They
are not exposed to the operator. They are not in .tfvars. They are
not in the environment. They are the configuration’s own scratchpad,
and the discipline is to use them when the value is genuinely computed
— not when it is convenient.
A real production incident: the team wrapped every value in a locals
block “for testability.” A 200-line module became 800 lines; the
resources at the bottom referenced local.x_y_z for every attribute.
A junior engineer tried to find where instance_type was set and
gave up. The refactor that extracted the locals back into the resources
took a week. The lesson: locals are a tool, not a style.
What locals do
locals are for derived values. They reduce duplication of complex
expressions and document values that have business meaning but do not
come from the operator.
# CONFIGURATION
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Owner = var.team
CostCentre = var.cost_centre
ChangeRef = var.change_request_id
}
name_prefix = "${var.environment}-${var.region}"
app_subnets = {
for idx, cidr in var.app_subnet_cidrs :
"app-${idx}" => {
cidr_block = cidr
availability_zone = var.availability_zones[idx]
}
}
}
resource "aws_instance" "app" {
count = length(var.app_subnet_cidrs)
ami = data.aws_ami.app.id
instance_type = var.instance_type
subnet_id = aws_subnet.app[count.index].id
tags = merge(
local.common_tags,
{
Name = "${local.name_prefix}-app-${count.index}"
},
)
}
resource "aws_subnet" "app" {
for_each = local.app_subnets
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
vpc_id = aws_vpc.main.id
tags = merge(
local.common_tags,
{ Name = "${each.key}" },
)
}
The common_tags local removes the temptation to inline five lines of
tags into every resource. The name_prefix local captures the
environment-region prefix that would otherwise be repeated in every
Name tag. The app_subnets local transforms a list of CIDRs into a
map suitable for for_each.
When to use locals
Use a local when:
- The same expression appears in three or more resources.
- A value is derived from multiple variables (composite tags, name prefixes, naming conventions).
- A
for_eachmap needs to be built from a list of inputs. - A complex conditional expression would obscure a resource argument.
- The derived value has business meaning that deserves a name
(
production_environment_name,backup_window_utc).
Do not use a local when:
- The value comes from the operator — use a variable.
- The value is consumed by another stack — use an output.
- The expression is used once and is already simple.
- You are building an abstraction layer over a single value.
- The local is referenced exactly once (inline it).
Variables vs locals vs outputs
| Source | Set by operator | Computed by config | Consumed by other stacks | Scope |
|---|---|---|---|---|
| Variable | yes | defaults may be expressions | passed through module inputs | module / root |
| Local | no | yes | no — internal only | module / root |
| Output | no | yes | yes — remote_state and modules | module / root |
Operator provides Configuration computes Configuration exposes
| | |
v v v
variable local output
| | |
+-----------> resource argument <-------------------+
The decision flow:
Does the operator set the value?
yes -> variable
no -> Does the value cross to another stack?
yes -> output
no -> Is the value derived from other values?
yes -> local
no -> inline literal
WhyThisMatters
WhyThisMatters The locals block is the configuration’s only place
to express business logic. The team that inlines every expression
loses the opportunity to document. The team that locals-everything
loses the opportunity to read. The middle is the discipline.
Reference scope
Within a module, locals can reference:
- Other locals (declared in the same block or earlier blocks)
- Variables in the same module
- Built-in functions and literals
Locals cannot reference:
- Resources (not yet known at the locals evaluation stage)
- Data sources (resolved later)
- Outputs from other modules
# CONFIGURATION
locals {
# OK: reference to another local
full_name = "${local.name_prefix}-app"
# OK: reference to a variable
region_tags = {
Region = var.region
}
# ERROR: reference to a resource
# vpc_arn = aws_vpc.main.arn
# ERROR: reference to a data source
# account_id = data.aws_caller_identity.current.account_id
}
Locals are evaluated after variables and before resources. The order
within a single locals block is irrelevant for evaluation; Terraform
sorts references topologically.
Failure modes
-
Local references a variable that does not exist. The plan fails with
Reference to undeclared input variable. The error is clear; the fix is to declare the variable. -
Local used in
for_eachproduces duplicates. The map keys are not unique;for_eacherrors withDuplicate object key. The fix is to deduplicate or rename keys. -
Local shadows a variable name. A local named
regionshadowsvar.regionwithin its scope. The plan succeeds but the operator reads the wrong value. The fix is to rename the local (resolved_region). -
Local computed from a circular reference. Local A references local B which references local A. The plan fails with a cycle error.
-
Local used to wrap every single-use value. The reader cannot follow the indirection. Refactor: inline single-use locals.
-
Local used as a configuration-management layer. A local reads
var.environmentand produces afor_eachmap of forty keys. The local has become a hidden DSL. Refactor: lift the logic to an explicit input or a data source.
Production guidance
- One
locals.tffile per module, grouped by purpose (tags, naming, subnet maps, IAM policy documents). - Comment every local whose computation is not obvious in five seconds. The comment is the contract.
- Do not reference resources or data sources in locals — they are evaluated too early.
- Do not mark locals sensitive — the value flows into the state file regardless of the flag. Use a data source for sensitive derived values.
- Audit locals quarterly: single-use locals should be inlined.
What comes next
The next lesson is Expressions for Production Configurations — the complexity cap for variable defaults, the reference scope that defaults can use, and when to lift a complex expression into a local or a data source.
Verification
- Can you set a local’s value with
terraform apply -var=local.x=...? Why or why not? - A local is referenced in exactly one resource. Should it be inlined?
- What is the difference between a local and an output?
- A local references
data.aws_caller_identity.current.account_id. What happens at plan time, and why?
Knowledge check · 7 questions
Q1. Who provides the value for a `locals` block entry?
Q2. Locals can be overridden by `-var` CLI flags at apply time.
Q3. Which of the following is the right use of a `locals` block?
Q4. A local references a variable that does not exist. What happens at plan time?
Q5. Which of the following are valid uses of a `locals` block? (Select all that apply.)
Q6. A local is used to derive a database password from another variable. What is the production issue?
Q7. A module has 5 locals and 30 resources. Each local is referenced exactly once. What is the right action?
Passing score: 75%. Answers are checked in this browser.