Input variables are how a Terraform configuration accepts external parameters instead of hardcoding values, making the same code reusable across environments, regions, and teams.
Declaring a variable
variable "instance_type" {
type = string
description = "EC2 instance type for the web tier"
default = "t3.micro"
}
Referenced elsewhere as var.instance_type.
The precedence order
Terraform must decide which value "wins" when a variable could be set in multiple places. From highest to lowest precedence:
-varor-var-fileflags passed on theterraform plan/applycommand line.*.auto.tfvars(or.auto.tfvars.json) files in the working directory, processed in alphabetical order.terraform.tfvars(orterraform.tfvars.json), if present.TF_VAR_<name>environment variables (e.g.,TF_VAR_instance_type=t3.large).- The variable's
defaultvalue in configuration, if no other source provides one.
(Within a given precedence level, later definitions on the command line override earlier ones.)
Why this matters in practice
- CI/CD pipelines typically inject environment-specific and sensitive values via
TF_VAR_*environment variables (pulled from a secrets store), whileterraform.tfvarsholds non-sensitive, checked-in defaults for local development. - Layering
*.auto.tfvarsfiles (e.g.,common.auto.tfvars,prod.auto.tfvars) lets teams share baseline values while still allowing an explicit-varoverride for a one-offplanduring debugging. - If a variable has no default and no value supplied from any source, Terraform will interactively prompt for it (or fail in non-interactive/CI contexts) — which is a useful safety net for variables that must always be explicitly set (like an account ID), but a nuisance if forgotten in automation.
Getting this precedence order right is what lets teams keep sane defaults in version-controlled code while still safely overriding per-environment or per-run values without editing the configuration itself.
Related Resources
These three constructs — variables, locals, and outputs — form the complete "interface" of any Terraform module: inputs in, computed helpers in the middle, values out.
Variables — inputs
variable "environment" {
type = string
}
Set from outside the module: CLI flags, tfvars files, environment variables, or (for a child module) the calling module's module block arguments. A module's author defines what variables exist; the caller decides their values.
Locals — internal, derived values
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
Locals compute a value once, inside the module, from variables/other locals/resource attributes, and are referenced as local.name_prefix. They exist purely to avoid repeating the same expression across many resource blocks — they cannot be set or overridden from outside the module; they're private implementation detail.
Outputs — what the module exposes
output "bucket_arn" {
value = aws_s3_bucket.assets.arn
description = "ARN of the created assets bucket"
}
Outputs expose values out of a module for three audiences:
- The CLI, via
terraform output(handy for scripting or quick inspection). - A parent module, referenced as
module.<name>.bucket_arn. - Anyone consuming this configuration's remote state via
terraform_remote_state, letting a completely separate configuration read values it produced.
Putting it together
variables (external input)
│
▼
locals (internal computation, DRY helpers)
│
▼
resources (actually created, using vars + locals)
│
▼
outputs (external-facing results)
A well-designed module minimizes what it exposes as variables/outputs (a clean, stable public interface) while doing as much repeated computation as possible in locals — keeping the resource blocks themselves simple and readable.
Related Resources
Both count and for_each let a single resource block create multiple instances, but they key those instances differently, and that difference has real consequences during updates.
count — indexed by position
resource "aws_instance" "web" {
count = 3
ami = var.ami_id
instance_type = "t3.micro"
tags = { Name = "web-${count.index}" }
}
This creates aws_instance.web[0], aws_instance.web[1], aws_instance.web[2]. The problem: if you remove the first item from a list-driven count (say, count = length(var.names) and you delete names[0]), every subsequent index shifts down by one — Terraform sees instance [1]'s new contents differ from what used to be at [1], and may plan to destroy/recreate resources that didn't conceptually change at all, just moved position.
for_each — keyed by a stable identifier
resource "aws_instance" "web" {
for_each = toset(["blue", "green"])
ami = var.ami_id
instance_type = "t3.micro"
tags = { Name = "web-${each.key}" }
}
This creates aws_instance.web["blue"] and aws_instance.web["green"]. Removing "blue" from the set only affects aws_instance.web["blue"] — "green" is untouched, because its identity is its key, not its position.
When to use which
- Use
for_eachwhenever instances are distinguishable — different names, different configuration per instance, or anything from a map/set that could grow or shrink over time. It's the safer default for anything beyond the simplest cases. - Use
countfor:- A simple conditional resource:
count = var.create_bucket ? 1 : 0(0 or 1 instances — no ordering issue since there's at most one). - Truly identical, position-independent replicas where you genuinely don't care about individual identity (rare in practice).
- A simple conditional resource:
Interview-ready summary
count = "how many, indexed by number"; for_each = "which ones, indexed by stable key." Prefer for_each for anything with real identity, to avoid unintended destroy/recreate churn when the underlying collection changes.
count and for_each (from the previous question) let you repeat an entire resource block. dynamic blocks solve a related but different problem: repeating a nested block inside a resource, based on the size of some input data.
The problem it solves
Consider a security group with a variable number of ingress rules:
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# ...how many more `ingress` blocks do we need? Depends on input data.
}
You can't apply count/for_each directly to the ingress { ... } nested block itself — those meta-arguments only work on top-level resource/module blocks.
The dynamic block
variable "ingress_rules" {
type = list(object({
port = number
cidr_blocks = list(string)
}))
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
}
For each element in var.ingress_rules, Terraform generates one ingress { ... } block, with ingress.value referencing the current element (the iterator variable name matches the block label, "ingress", unless overridden with iterator).
When to use it (and when not to)
- Use it when the number of nested blocks genuinely depends on input data — a variable list of firewall rules, a variable number of load balancer listener rules, etc.
- Avoid it when the set of nested blocks is fixed and known — writing them out explicitly is more readable and easier to diff in a PR. Dynamic blocks trade some readability for flexibility, so reach for them only when the alternative is truly impractical (e.g., hardcoding a variable-length list).
Interview-ready summary
dynamic blocks are to nested configuration blocks what for_each is to whole resources — a way to generate a variable number of them from a collection, used sparingly because they add a layer of indirection to otherwise-static configuration.
Related Resources
HCL has several expression-level features for handling optional or conditional values without verbose null-checking logic, since Terraform configuration has no if statements in the imperative sense — everything is an expression.
Conditional (ternary) expressions
resource "aws_instance" "web" {
count = var.create_instance ? 1 : 0
instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
}
condition ? true_val : false_val is HCL's ternary operator. Extremely common for feature toggles (create a resource only if a flag is set) and for picking a value based on environment, without needing separate resource blocks per case.
try(...)
locals {
# Some objects in the list may not have an "owner" attribute at all.
owner = try(var.config.owner, "unassigned")
}
try() evaluates each argument in order and returns the first one that doesn't produce an error (as opposed to the first one that's merely non-null). It's especially useful when accessing a potentially-missing attribute on an object/map where a plain lookup would error out entirely rather than just returning null.
coalesce(...)
locals {
# Use an explicit override if given, otherwise fall back to a computed default.
instance_name = coalesce(var.custom_name, "${var.project}-${var.environment}")
}
coalesce() returns the first argument that is not null and not an empty string, ideal for "use this value if provided, otherwise fall back to a default" — a very common pattern for optional module inputs.
Why these matter together
Modules frequently need to support optional inputs gracefully — a variable that might be null, an object that might be missing a field, a value that should have a sensible fallback. try, coalesce, and conditional expressions let module authors handle these cases declaratively and concisely, instead of forcing every caller to always supply every possible value explicitly.