Terraform Variables, Loops, and Outputs: The Complete Guide

Most Terraform questions are not really about resources. They are about moving values around: getting a value in (variables, tfvars, environment), reshaping it (locals, maps, lists, loops), and getting it out (outputs). The pieces are simple; the confusion comes from how they interact, and from a handful of errors that make no sense until you know what the language is doing underneath.
This guide collects the whole value pipeline in one place, including the errors that bring most people here: Invalid for_each argument, Variables may not be used here, and the mystery of outputs on counted resources.
TL;DR
variables.tfdeclares inputs;terraform.tfvarsassigns them. Precedence, lowest to highest: defaults, environmentTF_VAR_*,terraform.tfvars,*.auto.tfvars,-var/-var-fileflags.- Variables cannot reference other variables. That is what locals are for.
- Grow lists with
concat(), pick objects out of lists withindex()or aforfilter, and iterate lists of objects withfor_eachkeyed on a stable attribute. for_eachneeds a map or set of strings known at plan time; resource-derived values triggerInvalid for_each argument.- With
count, output all instances with the splat[*]; withfor_each, usevalues(). sensitive = truehides values in plans;terraform output -jsonornonsensitive()reveals them deliberately.- Backend blocks and provider
required_versionrun before variables exist, henceVariables may not be used hereduringterraform init.
Prerequisites
- Terraform 1.x installed
- A working configuration you can run
planagainst - Basic familiarity with HCL resource syntax
Declaring vs assigning: variables.tf and tfvars
The naming trips everyone at first: both files have "var" in them, but they do opposite jobs. variables.tf declares that an input exists, its type, and optionally a default. terraform.tfvars assigns values to those declarations:
# variables.tf — the contract
variable "environment" {
type = string
description = "Deployment environment"
}
variable "instance_count" {
type = number
default = 1
}
# terraform.tfvars — the values for this workspace
environment = "production"
instance_count = 3
Assigning an undeclared variable behaves differently per source: in a tfvars file it is a warning, an unmatched TF_VAR_* is silently ignored, and only -var with an undeclared name is a hard error. Declaring without assigning falls back to the default or prompts interactively. Keep declarations stable in version control and vary the values per environment with -var-file:
terraform apply -var-file="environments/production.tfvars"
Where values can come from, and who wins
Terraform merges values from several sources. Precedence from lowest to highest:
- The
defaultin the declaration - Environment variables prefixed
TF_VAR_(TF_VAR_environment=staging) terraform.tfvars*.auto.tfvars(alphabetical order; the.jsonvariants of tfvars files work the same way)-varand-var-filecommand-line flags (last one wins)
The TF_VAR_ prefix is the whole story for environment variables: there is no function that reads arbitrary environment variables inside a configuration, by design, so values stay declared and typed. In CI this makes secrets injection clean:
export TF_VAR_db_password="$SECRET_FROM_VAULT"
terraform apply # picked up as var.db_password, never on the command line
For file inputs, file() reads raw UTF-8 text (an SSH public key, a policy document), and pairing it with a decoder turns structured files into usable values:
locals {
ssh_key = file("${path.module}/keys/deploy.pub") # raw text as-is
settings = jsondecode(file("${path.module}/settings.json")) # structured
# yamldecode() works the same way for YAML
}
Two caveats: file() only reads files that exist before the run starts (it is not part of the dependency graph), and when the data must come from a program rather than a file, the external data source runs any executable that prints JSON and exposes its result.
Locals: the answer to "variables within variables"
Sooner or later you try this and it fails:
variable "bucket_name" {
default = "${var.environment}-assets" # error: variables can't reference variables
}
Variable defaults must be static. Anything derived belongs in locals, which exist precisely to compose values:
locals {
bucket_name = "${var.environment}-assets"
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket" "assets" {
bucket = local.bucket_name
tags = local.common_tags
}
The division of labor is clean: variables are the module's public inputs, locals are its private computed values. If you are copying an expression between resources, it should be a local.
Maps make locals genuinely powerful, and variable keys work with the lookup syntax:
variable "instance_types" {
type = map(string)
default = {
dev = "t3.micro"
production = "m5.large"
}
}
locals {
instance_type = var.instance_types[var.environment]
# or with a fallback:
# instance_type = lookup(var.instance_types, var.environment, "t3.micro")
}
On Terraform 1.9+, a validation block can check the selector against the map's actual keys, turning a bad environment name into a clear error instead of a lookup failure:
variable "environment" {
type = string
validation {
condition = contains(keys(var.instance_types), var.environment)
error_message = "environment must be one of: ${join(", ", keys(var.instance_types))}"
}
}
Lists and objects: append, pick, iterate
Appending is concat(), because lists are immutable values, not mutable arrays:
locals {
base_rules = ["allow-ssh", "allow-https"]
all_rules = concat(local.base_rules, var.extra_rules, ["deny-all"])
# conditional append: the ternary picks a one-element or empty list
with_icmp = concat(local.base_rules, var.allow_icmp ? ["allow-icmp"] : [])
}
Picking one object out of a list has two idioms. When you know the position, index it. When you know an attribute, filter with a for expression:
locals {
# by attribute — returns a list, take the first match
admin_user = [for u in var.users : u if u.role == "admin"][0]
# safer with a length guard if the match may not exist
admin_or_null = length([for u in var.users : u if u.role == "admin"]) > 0 ? [for u in var.users : u if u.role == "admin"][0] : null
# repeated lookups? re-key the list into a map once, then index directly
users_by_name = { for u in var.users : u.name => u }
db_owner = local.users_by_name["db-admin"]
}
Iterating a list of objects to create resources is where count goes wrong and for_each goes right. With count, removing the first element shifts every index and Terraform wants to destroy and recreate everything after it. Key for_each on a stable attribute instead:
variable "users" {
type = list(object({
name = string
role = string
}))
}
resource "aws_iam_user" "this" {
for_each = { for u in var.users : u.name => u } # list -> map keyed by name
name = each.value.name
tags = { role = each.value.role }
}
Now aws_iam_user.this["alice"] survives reordering, and removing one user touches one resource.
The for_each error everyone hits
Error: Invalid for_each argument
The "for_each" set includes values derived from resource attributes that
cannot be determined until apply...
for_each keys must be known at plan time, because they become resource addresses in the state. Two triggers cover nearly every case:
- Keys derived from another resource's attributes.
for_each = toset(aws_instance.web[*].id)cannot work: the IDs do not exist until apply. Key on something you already know (names, the input variable itself) and reference the resource attributes in the body instead. - Wrong type.
for_eachtakes a map or a set of strings, not a list. Wrap lists:for_each = toset(var.names). null. An optional variable that arrives asnullis invalid, while an empty collection is fine (it just creates zero instances). Normalize:for_each = var.names == null ? toset([]) : toset(var.names), keeping both branches the same type.
The fix is almost always restating the loop over input data rather than over computed results:
# broken: keyed on computed IDs
# for_each = toset(aws_subnet.private[*].id)
# works: keyed on the same input the subnets were built from
for_each = var.private_subnet_cidrs # a map like { a = "10.0.1.0/24", ... }
subnet_id = aws_subnet.private[each.key].id # computed values are fine in the BODY
Outputs: counted resources, loops, and sensitive values
With count, a bare reference is an error because the resource is a list. The splat expression outputs all of them:
output "instance_ips" {
value = aws_instance.web[*].private_ip # all instances
}
output "first_ip" {
value = aws_instance.web[0].private_ip # or one of them
}
output "named_ips" {
# a labeled map is friendlier than a bare list in shared outputs
value = { for i, inst in aws_instance.web : "web-${i}" => inst.private_ip }
}
Splat and for expressions also behave when count = 0: they return an empty collection instead of erroring, so conditional resources need no special guard in outputs.
With for_each, the resource is a map, so shape the output with values() or a for expression:
output "user_arns" {
value = { for k, u in aws_iam_user.this : k => u.arn }
}
The same pattern applies to module outputs: a module called with for_each is addressed as a map, and values(module.env)[*].vpc_id flattens it.
Sensitive outputs show as (sensitive value) in plans and in the full terraform output listing; asking for one by name (or with -raw/-json) prints it, which is the intended escape hatch rather than a bug. When you legitimately need the value:
terraform output -json db_password | jq -r # -json bypasses redaction
Or, inside the configuration, wrap with nonsensitive() when you can justify that the derived value is safe. The redaction is a guardrail against accidental shoulder-surfing and CI logs, not encryption: anyone with state access can read the value, which is one more reason state files do not belong in git.
Two errors that are not about your syntax
Variables may not be used here during terraform init means you used var.* in a place Terraform evaluates before variables exist: the backend block, required_version, or version constraints. Note the scope: ordinary provider arguments are fine with variables (region = var.aws_region is perfectly legal, as is terraform.workspace, and most providers also read their own environment variables like AWS_REGION if you leave the argument out entirely). The static zone is the backend and version constraints. For backends, the escape hatch is partial configuration, either from a file or inline:
terraform init -backend-config=backend-prod.hcl
# or key by key:
terraform init \
-backend-config="bucket=my-terraform-state" \
-backend-config="key=prod/terraform.tfstate" \
-backend-config="region=us-east-1"
Beyond that: a wrapper like Terragrunt, or accepting the duplication. No syntax makes bucket = var.state_bucket legal inside a backend block.
Account-specific values you did not declare. Needing the AWS account ID everywhere tempts people to add variable "aws_account_id". Do not: it is derivable, and derived beats declared because it cannot drift from reality:
data "aws_caller_identity" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
ecr_url = "${local.account_id}.dkr.ecr.${var.region}.amazonaws.com"
}
The same "ask the provider, not the operator" pattern applies to region (data.aws_region), partition, and the caller's ARN.
Attribute access, and reading error messages
One final habit that makes all of the above easier to debug: Terraform references always read RESOURCE_TYPE.NAME.ATTRIBUTE (aws_instance.web.private_ip), and with count or for_each an index or key sits in the middle (aws_instance.web[0].private_ip, aws_iam_user.this["alice"].arn). When an error says an attribute does not exist, terraform console is the fastest truth-teller: paste the reference and it prints the actual structure, which settles nine out of ten "why is this a tuple" arguments immediately.
Summary
- Declare in
variables.tf, assign in tfvars, and remember the precedence chain ends at-varflags. TF_VAR_is the only door for environment variables;file()+jsondecode()/yamldecode()is the door for file data.- Derived values live in locals, never in variable defaults.
concat()to grow lists,forfilters to pick from them, andfor_eachkeyed on stable input attributes to iterate them.for_eachkeys must be plan-time-known maps or string sets; loop over inputs, not over computed results.- Splat (
[*]) forcountoutputs,values()/forforfor_eachoutputs,-jsonwhen you need a sensitive value on purpose. - Backend blocks evaluate before variables exist; account IDs come from data sources, not variables.
For the expression side of the language, strings, conditionals, and type juggling, the companion guide is Terraform Strings and Conditionals.
Try it hands-on
Run the commands from this article in the browser. Nothing to install.
We earn commissions when you shop through the links below.
Svix
Webhooks as a service
Svix Dispatch sends your webhooks for you: retries with exponential backoff, signed payloads, idempotency keys, and a delivery log your customers can see.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?
Related Posts
Also worth your time on this topic
Best practices when using Terraform?
Discover essential best practices for using Terraform to manage your infrastructure effectively and securely.
AWS VPC with Terraform
Build a complete AWS VPC infrastructure using Terraform with public/private subnets, NAT gateway, and security groups.
120 minutes
Infrastructure as Code Patterns
What are the key principles and patterns of Infrastructure as Code? How do you structure IaC for multiple environments?
mid