TerraformXIII · Variables, Outputs, and LocalsProduction Terraform
Expressions for Production Configurations
What you'll learn
- Write default expressions that are simple, readable, and predictable
- State the reference scope available inside `default = ...`
- Lift complex default expressions into a local or a data source
- Validate environment-specific defaults with a `validation` block
- Recognise the failure modes of clever defaults in production
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 default = ... clause in a variable block is an expression, not a
constant. It can reference other variables, call functions, and use
conditionals. The line between “useful default” and “obscure default” is
where production operators lose the ability to predict what the
configuration will do.
A real production incident: the team wrote a default expression that
walked a list of regions, picked the cheapest, and returned the
matching AMI ID. The default compiled. The default was also wrong: the
“cheapest” region was not the team’s contracted region, and the AMI
lookup ran against the wrong partition. The apply provisioned 200
machines in the wrong account. The fix was to lift the logic out of
the default and require the caller to pass an explicit AMI ID.
What a default can reference
The reference scope for a default expression is narrower than for a
resource argument and narrower than for a validation block.
variable default MAY reference
|
+-- other variables in the same module
|
+-- literals (string, number, bool, list, map)
|
+-- Terraform built-in functions
|
+-- type constructors (list(), map(), tomap(), etc.)
|
variable default MAY NOT reference
|
+-- resources (not yet known)
|
+-- data sources (not yet resolved)
|
+-- locals (fragile — locals may be declared later)
|
+-- outputs from other modules
|
+-- count or for_each from the same module
# CONFIGURATION
variable "environment" {
type = string
default = "production"
description = "Deployment environment."
}
variable "instance_count" {
type = number
default = var.environment == "production" ? 6 : 2
description = "Number of app instances. Higher in production."
}
variable "common_tags" {
type = map(string)
default = {
ManagedBy = "terraform"
Env = var.environment
CostCentre = "platform"
}
description = "Tags applied to every resource in this module."
}
variable "availability_zones" {
type = list(string)
default = ["eu-west-2a", "eu-west-2b", "eu-west-2c"]
description = "AZs to spread subnets across."
}
The instance_count default shows a conditional that is one line and
obvious. The common_tags default shows a map literal that references
a variable. Both are within the complexity cap. Anything more elaborate
should be lifted.
The complexity cap
The default is evaluated every time the configuration is loaded. It is
also evaluated by every terraform plan, by every terraform console
session, and by every IDE that parses the configuration. A default that
takes a week to understand is a default that ships with bugs.
Complexity ladder (simplest to most complex):
literal -> "production"
literal + var reference -> var.environment
conditional -> var.environment == "production" ? 6 : 2
map literal + var -> { Env = var.environment }
function call -> format("%s-%s", var.env, var.region)
for expression -> [for az in var.azs : "${az}-${var.region}"]
nested conditionals -> env == "prod" ? (region == "eu" ? 6 : 4) : 2
list comprehension + cond -> [for x in xs : x if x.enabled]
data source reference -> (not allowed in default)
Production rule: keep the default readable in five seconds. If the reader has to think, lift the expression to a local in the calling module and pass the result in.
# CONFIGURATION — calling module
locals {
instance_count = var.environment == "production" ? 6 : 2
instance_type = var.environment == "production" ? "m5.large" : "t3.medium"
}
module "app" {
source = "../modules/app"
environment = var.environment
instance_count = local.instance_count
instance_type = local.instance_type
}
The complex logic now lives in the calling module’s locals, where the reader expects to find derivation. The module receives a simple, typed input. The contract is clean.
Validation on environment-specific defaults
A default that varies by environment must be validated. The validation runs at plan time and catches a bad value before any provider API call.
# CONFIGURATION
variable "environment" {
type = string
default = "production"
description = "Deployment environment."
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be one of: dev, staging, production."
}
}
variable "log_level" {
type = string
default = "info"
description = "Application log level."
validation {
condition = contains(["debug", "info", "warn", "error"], var.log_level)
error_message = "Log level must be one of: debug, info, warn, error."
}
}
The validation block can reference other variables. The default cannot. Keep the two responsibilities distinct: defaults set values; validations restrict them.
WhyThisMatters
WhyThisMatters The default expression is the most-read line in a
variable block. It is the first thing the operator looks at when they
ask “what does this default to?”. If the answer is not obvious, the
operator does the wrong thing in production.
Failure modes
-
Default references a local declared later. The configuration fails to parse or evaluate; the error message points at the local block, not the variable block. The fix is to lift the local out of the default.
-
Default references a resource. The error is
Reference to resource in variable default. The fix is to use adatasource or to pass the value in explicitly. -
Default uses a function not in the allowed set. Some functions (
formatdate,timestamp) are not legal indefault. The plan errors withFunction not allowed in default value. -
Default is too complex to predict. The operator cannot tell what the value will be without running
terraform console. The fix is to lift the logic or require an explicit value. -
Default produces a different type than
typedeclares. A conditional returns6(number) in one branch and"6"(string) in the other. The plan errors with a type mismatch. -
Default references a
nullablevariable that is unset. Thenullable = truevariable isnull; the default expression evaluates against null and produces a runtime type error.
Recovery
When a default fails to evaluate or produces a wrong value:
# READ-ONLY — print the resolved value
terraform console
> var.instance_count
6
# READ-ONLY — verify the source of each variable
terraform plan
# Look for "var.environment" in the plan output
If the default is too complex, refactor: lift the expression to a local in the calling module, add the local’s name to the module interface, and document the new input.
Production guidance
- Keep defaults simple. A default is a literal, a reference, or a one-line conditional.
- Validate environment-shaped variables with
validationblocks. - Document the default in
description. The description explains what the default is and when to override it. - Lift complex expressions to a
localsblock in the calling module. The module receives a simple input. - Do not reference resources or data sources in defaults. The evaluation order does not allow it.
- Audit defaults quarterly. Replace clever defaults with required inputs.
What comes next
The XIII-Variables module concludes with a synthesis lesson on input discipline across the whole configuration — the contract that holds variables, locals, and outputs together.
Verification
- Can a variable
defaultreference a resource attribute? Why or why not? - The default expression needs a
forloop to build a list of AZs. What is the right place for that expression, and why? - A
validationblock onvar.environmentreferencesdata.aws_caller_identity.current.account_id. Does the validation run at plan time? Why or why not? - The default for
instance_countdiffers between production and non-production. What is the right way to express this and how do you keep the default readable?
Knowledge check · 7 questions
Q1. What may a variable `default` expression reference?
Q2. Variable `default` expressions are evaluated before data sources are resolved, so a default may not reference a `data` source.
Q3. What is the recommended complexity cap for a `default` expression in production?
Q4. A default needs a `for` expression to build a list of AZs from a region name. What is the right pattern?
Q5. Which of the following may a variable `default` reference? (Select all that apply.)
Q6. A default references a `nullable = true` variable that the operator has not set. What happens?
Q7. A team wants region-specific instance types baked into a variable default. The default needs an `if`/`else` that switches on `var.region`. What is the right pattern?
Passing score: 75%. Answers are checked in this browser.