Variables, Outputs and Locals
Make configurations reusable: typed input variables with validation, locals for derived values, and outputs for sharing. Step through variable precedence to see which value wins, and learn how unknown values flow through a plan.
🎯 What you'll learn
- Typed input variables, validation and sensitive values
- Variable precedence from defaults to -var flags
- Locals for naming conventions and derived values
- Outputs, and how 'known after apply' values work
📚 Before you start
1Input Variables
Variables make a configuration reusable across environments and modules.
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "instance_count" {
type = number
default = 2
validation {
condition = var.instance_count >= 1 && var.instance_count <= 20
error_message = "instance_count must be between 1 and 20."
}
}
variable "db_password" {
type = string
sensitive = true # redacted in plan/apply output (still stored in state!)
nullable = false
}
variable "services" {
type = map(object({
image = string
cpu = optional(number, 256) # optional attributes with defaults
memory = optional(number, 512)
public = optional(bool, false)
}))
}Types: string, number, bool, list(T), set(T), map(T), object({...}), tuple([...]), any. Strong types + validation blocks catch mistakes at plan time instead of during a half-finished apply.
2Setting Variable Values (Precedence, Low → High)
defaultin the variable block- environment variables
TF_VAR_name terraform.tfvars, then*.auto.tfvars(alphabetical)-var-file=prod.tfvars-var 'name=value'on the command line
# prod.tfvars
environment = "prod"
instance_count = 6
services = {
api = { image = "ghcr.io/acme/api:1.4.2", cpu = 512, memory = 1024, public = true }
worker = { image = "ghcr.io/acme/worker:1.4.2" }
}Never put secrets in .tfvars files committed to Git; pass them via TF_VAR_ from a secret store in CI, or better, have Terraform reference the secret manager directly (data source or managed passwords).
3Locals
locals are named expressions — computed once, used many times. They keep configurations DRY and readable:
locals {
name_prefix = "${var.project}-${var.environment}"
is_prod = var.environment == "prod"
common_tags = {
project = var.project
environment = var.environment
owner = var.team
}
public_services = { for k, s in var.services : k => s if s.public }
azs = slice(data.aws_availability_zones.available.names, 0, local.is_prod ? 3 : 2)
}Variables are inputs from outside; locals are internal derivations. If a value shouldn't be overridden by callers, make it a local.
locals {
name_prefix = "petpal-${var.env}"
common_tags = {
project = "petpal"
env = var.env
owner = "platform"
}
}
resource "aws_s3_bucket" "uploads" {
bucket = "${local.name_prefix}-uploads" # petpal-prod-uploads
tags = local.common_tags
}4Outputs
Outputs expose values to the CLI, to calling modules, and to other configurations:
output "alb_dns_name" {
description = "Public DNS name of the load balancer"
value = aws_lb.main.dns_name
}
output "db_endpoint" {
value = aws_db_instance.main.endpoint
sensitive = true # hidden in CLI output; still in state
}
output "service_urls" {
value = { for k, s in local.public_services : k => "https://${k}.${var.domain}" }
}terraform output -json makes them consumable by scripts and CI (e.g. pass the ALB name to a deploy job).
5Useful Patterns
- Environment maps:
lookup(local.sizes, var.environment)withlocal.sizes = { dev = "t4g.small", prod = "m7g.large" }. - Feature toggles:
count = var.enable_bastion ? 1 : 0(and reference withone(aws_instance.bastion[*].id)). - Preconditions/postconditions on resources and outputs to assert assumptions:
resource "aws_instance" "web" {
# ...
lifecycle {
precondition {
condition = data.aws_ami.app.architecture == "arm64"
error_message = "The selected AMI must be arm64 for Graviton instances."
}
}
}Under the HoodEvaluation and Unknown Values▾
Terraform evaluates expressions while walking the dependency graph. Variables are known at the start; locals and resource attributes become known as their dependencies are evaluated. Some values are only known after apply (a generated ID, an IP assigned by AWS). In a plan these propagate as unknown: "arn:aws:s3:::${aws_s3_bucket.x.id}" is unknown if the bucket doesn't exist yet. Unknowns matter in two places: count/for_each must be known at plan time (Terraform must know how many instances to plan — "Invalid for_each argument… depends on resource attributes that cannot be determined until apply"), and provider configuration can't depend on unknown values. The fix is to drive count/for_each from variables or known data (e.g. a map of names) rather than from attributes of resources created in the same run.
⚠️ Common Pitfalls
- Untyped
anyvariables — errors surface deep inside resources. - Secrets in tfvars committed to Git.
- Believing
sensitiveencrypts state. for_eachover values unknown until apply.- Variables for things callers should never change — use locals.
- No descriptions — modules become guesswork for users.
📋 Quick Reference
variable { type, default, description, validation, sensitive, nullable } · optional(T, default) in objects
precedence: default < TF_VAR_ < terraform.tfvars < *.auto.tfvars < -var-file < -var
locals { name_prefix, tags, derived maps } · output { value, description, sensitive } · terraform output -json
precondition / postcondition for assumptions · count/for_each must be known at plan time🧠 Key takeaways
- Declare variables with types, descriptions, validation and
sensitive = truewhere needed. - Precedence (low → high): default < TF_VAR_ env < terraform.tfvars < *.auto.tfvars < -var-file / -var.
- Locals remove repetition (naming, tags, derived values); outputs expose values to users and other units.
- Values unknown until apply show as (known after apply); they can't drive count/for_each keys.
🎤 Interview Q&A
Q1What are variables, locals and outputs?core▾
Variables are inputs (with types, defaults, validation). Locals are named expressions computed inside a module to avoid repetition. Outputs expose values to callers, other stacks, or the CLI.
- Where do variable values come from? tfvars files,
-var,TF_VAR_env vars, or defaults.
Q2How do you validate variables?core▾
With type constraints (object({...}), list(string)) and validation blocks with conditions and error messages, e.g. restricting environment to dev/staging/prod.
- Optional object attributes?
optional(type, default)in object types.
Q3How do you manage per-environment values?senior▾
Separate tfvars files per environment, separate root modules/directories per environment calling shared modules, or workspaces for identical environments.
- Which scales best? Separate root configurations per environment with shared modules.
Q4How do you handle sensitive variables?core▾
Mark them sensitive = true, pass them from CI secrets or a secrets manager data source, never commit them in tfvars.
- Ephemeral values? Newer Terraform supports ephemeral resources/variables not stored in state.
Q5When should something be a local instead of a variable?core▾
When it's derived from other values or fixed conventions (naming prefixes, common tags) that callers shouldn't override.
- Common local? A
tagsmap merged into every resource.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.