Variables, Locals, and Outputs
Input variable types and validation, every place a value can come from (tfvars, auto.tfvars, TF_VAR_ env vars), when to reach for locals instead of repeating an expression, and how to design outputs that hold up across environments.
- Chapter 3: Configuration Language (HCL) Basics
- Declare typed input variables with defaults, validation, and descriptions
- Know every precedence layer Terraform checks for a variable's value, in order
- Use locals to eliminate repeated expressions without overusing them
- Choose complex types (object, map, list, tuple) correctly for real inputs
Variables, Locals, and Outputs
1. Why does this exist?
Chapter 1’s mini project hardcoded a bucket name and a region directly into resource blocks. That’s fine for a five-minute experiment — but the moment you need a second environment, or a teammate needs to run the same configuration with different values, hardcoding breaks down. Variables are how a Terraform configuration accepts input instead of encoding every value as a literal; locals are how you name a computed expression once instead of repeating it; outputs are how a configuration hands values back out — to a human reading terraform output, to a CI pipeline, or to another Terraform configuration entirely (Chapter 9 covers this last case with modules).
2. Concept
| Variable | Local | Output | |
|---|---|---|---|
| Direction | Input, comes from outside the configuration | Internal, computed from other values | Output, exposed to whoever runs/consumes this configuration |
| Declared with | variable "name" { ... } |
locals { name = ... } |
output "name" { ... } |
| Referenced as | var.name |
local.name |
(not referenced within the same configuration — consumed externally) |
| Typical use | Environment name, instance size, region | A computed naming convention, a filtered list, a merged tags map | A resource’s ID, a connection endpoint, a computed URL |
Variable Types
| Type | Example | Use case |
|---|---|---|
string |
"us-east-1" |
Region, name, environment |
number |
3 |
Instance count, port number |
bool |
true |
Feature flag, enable/disable toggle |
list(string) |
["a", "b", "c"] |
Ordered set of AZ names, CIDR blocks |
map(string) |
{ env = "prod", team = "platform" } |
Tags, arbitrary key/value config |
object({...}) |
object({ name = string, size = number }) |
A structured, typed group of related settings |
tuple([...]) |
tuple([string, number, bool]) |
A fixed-length, mixed-type sequence (rare in practice) |
any |
— | Escape hatch when you genuinely don’t want to constrain the type — use sparingly |
Precedence Order (highest wins)
-varand-var-fileflags on the command line*.auto.tfvarsfiles (loaded automatically, alphabetical order)terraform.tfvars(loaded automatically)TF_VAR_<name>environment variables- The variable’s
defaultvalue in its declaration
3. Architecture Diagram
flowchart TD
CLI["-var / -var-file<br/>(highest precedence)"] --> Merge
Auto["*.auto.tfvars"] --> Merge
TFVars["terraform.tfvars"] --> Merge
Env["TF_VAR_* env vars"] --> Merge
Default["default in variable block<br/>(lowest precedence)"] --> Merge
Merge["Final resolved value"] --> Config[Used throughout configuration as var.name]
4. Visual Explanation
Variable Block Anatomy
variable "instance_type" {
type = string
default = "t3.micro"
description = "EC2 instance type for the web tier"
validation {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
error_message = "instance_type must be one of: t3.micro, t3.small, t3.medium."
}
}
Hint: what happens if validation fails?
Terraform stops at plan time with your custom error_message — before ever contacting a provider. This is meaningfully better than letting an invalid value reach the cloud API and fail with a generic, less helpful error, or worse, silently succeed with unintended infrastructure.
5. Example
# variables.tf
variable "environment" {
type = string
description = "Deployment environment name"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "tags" {
type = map(string)
default = {}
description = "Additional resource tags"
}
# main.tf
locals {
common_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
})
}
resource "aws_s3_bucket" "demo" {
bucket = "aetoso-${var.environment}-demo"
tags = local.common_tags
}
# outputs.tf
output "bucket_arn" {
value = aws_s3_bucket.demo.arn
description = "ARN of the demo bucket, for cross-account bucket policy references"
}
local.common_tags merges caller-supplied tags with two computed ones — every resource in this configuration can reference local.common_tags once, instead of repeating the merge logic per resource.
6. Production Example
environments/
├── dev/
│ ├── terraform.tfvars # environment = "dev", smaller instance sizes
│ └── main.tf
├── prod/
│ ├── terraform.tfvars # environment = "prod", larger instance sizes
│ └── main.tf
# environments/dev/terraform.tfvars
environment = "dev"
instance_type = "t3.micro"
instance_count = 1
# environments/prod/terraform.tfvars
environment = "prod"
instance_type = "t3.large"
instance_count = 3
Same main.tf in both directories (often symlinked or generated from a shared module — Chapter 9), different terraform.tfvars per environment. This is the simplest version of the “directory-per-environment” pattern covered fully in Chapter 10.
7. Code Walkthrough — Complex Types
variable "database_config" {
type = object({
engine = string
instance_class = string
storage_gb = number
multi_az = bool
})
description = "Structured RDS configuration"
}
variable "subnets" {
type = list(string)
description = "List of subnet IDs for the ASG"
}
variable "team_tags" {
type = map(string)
default = {}
description = "Arbitrary team-supplied tags, merged into every resource"
}
Using object({...}) for database_config instead of four separate variables groups genuinely related settings together and gives Terraform a schema to validate against — passing a string where storage_gb expects a number fails immediately with a clear type error, not a confusing downstream provider error.
8. Behind the Scenes
Terraform resolves every variable’s final value before building the dependency graph — variables are inputs to the graph, not nodes within it the way resources and data sources are. TF_VAR_<name> environment variables are read directly from the process environment at startup; .tfvars files are parsed as a restricted subset of HCL (key-value pairs only, no expressions referencing other variables); -var and -var-file are parsed last and win any conflict. This is also why you can’t reference var.other_variable inside a variable’s own default — defaults are resolved independently, before any inter-variable relationship could be evaluated.
9. Common Mistakes
- Wrong: Using
type = anyby default instead of a specific type. Why: you lose Terraform’s built-in type-checking, and errors surface later and less clearly. Correct: useanyonly when a variable’s shape genuinely varies by caller in a way no concrete type can express. - Wrong: Repeating the same three-line tag-merging expression in every resource block instead of a
local. Why: any future change to the convention requires finding and updating every copy — exactly the failure mode in this chapter’s practical scenario. Correct: compute it once inlocals, referencelocal.common_tagseverywhere. - Wrong: Putting secrets directly into a
.tfvarsfile that gets committed to Git. Why:.tfvarsfiles are plain text, and once committed, the secret is in Git history forever, even if later removed. Correct: useTF_VAR_*environment variables sourced from a secrets manager in CI, or Chapter 13’s secrets-management patterns.
10. Production Tips
- 🚀 Best Practice: Give every variable a
description—terraform-docsand similar tools can auto-generate module documentation directly from these. - 💡 Tip: Use
*.auto.tfvars(loaded automatically, no-var-fileflag needed) for values that should always apply in a given directory, and reserve explicit-var-filefor values you want to intentionally opt into per invocation. - ⚠️ Warning: Never commit a
.tfvarsfile containing real secrets — add a.gitignoreentry for*.tfvarsin any directory where secrets might land, and use environment variables or a secrets manager instead. - 🚀 Best Practice: Name outputs descriptively and consistently across a codebase (
<resource>_<attribute>, e.g.db_endpoint,vpc_id) so consumers of your module don’t have to guess.
11. Debugging
| Symptom | Likely Cause | Fix |
|---|---|---|
Error: No value for required variable |
A variable has no default and no value was supplied anywhere |
Supply it via -var, a .tfvars file, or a TF_VAR_* env var; or add a sensible default if one exists |
| Variable value isn’t what you expected | Another precedence layer is overriding what you set | Check all five precedence layers, highest to lowest, for conflicting values |
Error: Invalid value for variable (custom message) |
A validation block’s condition evaluated to false |
Read your own custom error_message — it should tell you exactly what’s expected |
Error: Variables not allowed inside a .tfvars file |
Tried to reference var.x or a function inside a .tfvars file |
.tfvars files only support literal values, not expressions — move any computed logic into locals in a .tf file instead |
12. Interview Questions
Q — Walk through Terraform’s variable precedence order and explain why command-line flags win over everything else.
From lowest to highest: the variable’s own default, then TF_VAR_* environment variables, then terraform.tfvars, then *.auto.tfvars files, then -var/-var-file flags. Command-line flags win because they represent the most explicit, most immediate intent of the person or pipeline running Terraform right now — every other layer is a more ambient, less specific source of the value.
Q — When would you use a local instead of a variable?
When the value is computed from other values already available in the configuration (other variables, resource attributes, data source results) rather than supplied externally. A local never accepts outside input — it exists purely to name an expression once so it isn’t repeated, improving both readability and single-source-of-truth maintenance.
Q — Why can’t a variable’s default reference another variable?
Terraform resolves all variables’ values before the configuration’s dependency graph is built, and variables aren’t part of that graph the way resources are — there’s no defined evaluation order between two variables’ defaults. If you need one value derived from another, that’s exactly what a local is for, since locals are evaluated within the graph and can safely reference variables.
13. Hands-on Lab
Goal: build a small variable-driven configuration with validation, using local_file (no cloud account needed).
- Declare a variable
environment(string, no default) with avalidationblock restricting it todev/staging/prod. - Declare a
localsblock computing a filename:"${var.environment}-config.txt". - Create a
local_fileresource using that computed filename, with content describing the environment. - Run
terraform plan -var="environment=qa"— observe the validation error and its custom message. - Run
terraform plan -var="environment=staging"— observe it succeeds.
Solution
Step 4 should fail at plan time with your custom error_message, never reaching a provider call — this is the concrete benefit of validation blocks over letting an invalid environment name flow through to unpredictable downstream behavior.
14. Mini Project
Parameterize the Chapter 1 mini project fully. Take the S3 bucket configuration:
- Add variables for
environment,bucket_suffix, andtags(map, with a sensible default). - Add a
localsblock computingcommon_tagsby mergingvar.tagswith a computedEnvironmentandManagedBytag. - Create two
.tfvarsfiles —dev.tfvarsandprod.tfvars— with differentenvironmentandbucket_suffixvalues. - Run
terraform plan -var-file=dev.tfvarsandterraform plan -var-file=prod.tfvars, confirming each produces a differently-named bucket.
16. Summary
- Variables accept external input; locals compute internal values once for reuse; outputs expose values to whoever consumes this configuration.
- Precedence, highest to lowest:
-var/-var-file→*.auto.tfvars→terraform.tfvars→TF_VAR_*env vars → the variable’s owndefault. - Use
validationblocks to reject bad input at plan time with a clear message, instead of a confusing downstream error. - Complex types (
object,list,map) give Terraform a schema to validate against — prefer them overanywhenever the shape of an input is actually known. - Never commit real secrets in
.tfvarsfiles — use environment variables or a secrets manager instead.
17. Cheat Sheet
variable "name" {
type = string
default = "value"
description = "..."
validation {
condition = ...
error_message = "..."
}
}
locals {
computed = "${var.a}-${var.b}"
}
output "name" {
value = local.computed
description = "..."
}
terraform plan -var="key=value"
terraform plan -var-file="dev.tfvars"
TF_VAR_region=us-east-1 terraform plan
18. Related Topics
Next: Chapter 5 covers the full expression language — functions, conditionals, count/for_each, dynamic blocks — building directly on the variable types introduced here. Chapter 9’s modules chapter is where variables and outputs become a module’s actual public interface, not just internal configuration knobs.