Variables, Outputs & Expressions

Difficulty

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:

  1. -var or -var-file flags passed on the terraform plan/apply command line.
  2. *.auto.tfvars (or .auto.tfvars.json) files in the working directory, processed in alphabetical order.
  3. terraform.tfvars (or terraform.tfvars.json), if present.
  4. TF_VAR_<name> environment variables (e.g., TF_VAR_instance_type=t3.large).
  5. The variable's default value 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), while terraform.tfvars holds non-sensitive, checked-in defaults for local development.
  • Layering *.auto.tfvars files (e.g., common.auto.tfvars, prod.auto.tfvars) lets teams share baseline values while still allowing an explicit -var override for a one-off plan during 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_each whenever 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 count for:
    • 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).

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.

Related Resources

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.