Advanced HCL & Functions

Difficulty

for expressions are HCL's answer to "map over a list" or "build a dictionary from these elements" — the kind of data reshaping that comes up constantly once you're passing computed values from one resource into another's for_each or a structured output.

List-to-list

variable "subnets" {
  type = list(object({
    id     = string
    public = bool
  }))
}

locals {
  subnet_ids = [for s in var.subnets : s.id]
  # -> ["subnet-aaa", "subnet-bbb", "subnet-ccc"]
}

Square brackets ([...]) produce a list, with one output element per input element (unless filtered out).

List-to-map

locals {
  subnets_by_id = {for s in var.subnets : s.id => s}
  # -> { "subnet-aaa" = {...}, "subnet-bbb" = {...}, ... }
}

Curly braces ({...}) with a key => value pair instead produce a map — this single syntax change ([...] vs {...}) is what determines whether you get a list or a map out.

Filtering with if

locals {
  public_subnet_ids = [for s in var.subnets : s.id if s.public]
}

The optional trailing if clause skips elements that don't satisfy the condition — here, only including subnets where public == true.

Iterating over maps

locals {
  # var.tags is a map(string)
  tag_list = [for k, v in var.tags : "${k}=${v}"]
}

When iterating a map, for k, v in ... gives you both the key and value per iteration.

Why this matters practically

for expressions are the standard glue between resources: turning a list of objects a data source returned into the exact map shape a for_each needs, or reshaping a module's raw output into something a consuming resource can use directly — without writing a single line of imperative loop code, since HCL has no for statement, only this expression form.

Related Resources

The splat operator ([*]) shows up constantly in configurations using count, and it's easy to use without realizing it's really just a special case of the more general for expression.

Basic usage

resource "aws_instance" "web" {
  count = 3
  ami   = var.ami_id
}

output "instance_ids" {
  value = aws_instance.web[*].id
}

aws_instance.web[*].id returns a list of every instance's id attribute — ["i-aaa", "i-bbb", "i-ccc"] — one entry per count index.

The equivalent for expression

output "instance_ids" {
  value = [for i in aws_instance.web : i.id]
}

These two produce identical results. Splat is purely a shorthand for "give me this one attribute from every element" — nothing more.

What splat can't do

  • Filtering: aws_instance.web[*].id always includes every instance; there's no if clause equivalent. To get only some instances' IDs, you need a real for expression.
  • Key transformation / map output: splat only ever produces a list. If you need a map (e.g., keyed by instance name), you must use {for ... : key => value}.
  • Nested attribute chains beyond simple property access: more complex per-element transformations (string formatting, combining multiple fields) generally read more clearly as an explicit for expression than a splat chain.

Legacy syntax note

Older Terraform configurations sometimes use the "legacy" splat form aws_instance.web.*.id (dot-based rather than bracket-based) — functionally equivalent to [*], but the bracket form is the modern, recommended syntax.

Interview-ready summary

Splat is a convenience shorthand covering the single most common for-expression pattern (pluck one attribute from every element of a list); reach for a full for expression the moment you need filtering, a map result, or any transformation beyond a plain attribute lookup.

Related Resources

Without validation, a bad input variable's failure mode is often confusing: an invalid string might sail straight into a resource block and only fail deep inside a provider's API call, with an error message that has nothing to do with the actual root cause (a typo in an environment name, say).

Declaring a validation rule

variable "environment" {
  type = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

variable "instance_count" {
  type = number

  validation {
    condition     = var.instance_count > 0 && var.instance_count <= 10
    error_message = "instance_count must be between 1 and 10."
  }
}

condition is any boolean expression referencing the variable itself (and, in modern Terraform versions, potentially other variables); error_message is the custom text shown when the condition evaluates to false. Validation runs at the very start of plan, before any resource evaluation begins.

Why this beats ad-hoc checks

  1. Fails fast, with a clear message. Instead of a cryptic AWS API error three resources deep in the plan, the user immediately sees "environment must be one of: dev, staging, prod." — actionable and specific to the actual mistake.
  2. Centralizes the rule in the module, not the caller. Every consumer of a module automatically gets the same guardrail; nobody has to remember to write their own check before passing a value in.
  3. Multiple validation blocks compose. A single variable can have several validation blocks, each checking a different constraint, each with its own tailored message — clearer than one giant compound condition with a single generic error.
  4. It's documentation, too. A validation block tells the next engineer reading the module exactly what values are acceptable, without needing to trace through how the variable is eventually used deep in some resource's arguments.

A common real-world use

Guarding against common copy-paste mistakes in multi-environment codebases — validating that a CIDR block is actually a valid CIDR (can(cidrhost(var.cidr_block, 0))), that a region string is one of the org's approved regions, or that a naming convention regex is satisfied (can(regex("^[a-z0-9-]+$", var.name))) — catching exactly the kind of typo that would otherwise only surface as a failed apply partway through provisioning.

Related Resources

Variable validation blocks (the previous question) only ever see the raw variable a caller passed in — they can't reason about anything computed later, like another resource's attributes. precondition/postcondition fill that gap.

Precondition — checking an assumption before evaluating a resource

data "aws_ami" "app" {
  most_recent = true
  owners      = ["self"]

  filter {
    name   = "tag:approved"
    values = ["true"]
  }

  lifecycle {
    postcondition {
      condition     = self.tags["approved"] == "true"
      error_message = "Selected AMI is not tagged as approved for use."
    }
  }
}

resource "aws_instance" "web" {
  ami = data.aws_ami.app.id

  lifecycle {
    precondition {
      condition     = data.aws_ami.app.architecture == "x86_64"
      error_message = "Selected AMI must be x86_64; got ${data.aws_ami.app.architecture}."
    }
  }
}

A precondition on aws_instance.web runs before Terraform attempts to create/update it, checking an assumption about something it depends on (here, the AMI's architecture) — catching a mismatch before any API call is made, with a message pointing at exactly what's wrong.

Postcondition — checking the result after

A postcondition (shown above on the data source) runs after the resource/data source is evaluated, asserting something about the actual resulting values — useful for confirming a data source actually returned what you expected (e.g., "the AMI it found really is tagged approved," catching a filter that's too loose).

Key difference from variable validation

Variable validationprecondition/postcondition
ScopeThe variable's own raw value onlyAny expression, including other resources' computed attributes
TimingImmediately, at variable assignmentBefore (precondition) or after (postcondition) the specific resource/data source/output is evaluated
Typical use"Is this input shaped correctly?""Does this cross-resource assumption/invariant actually hold?"

Interview-ready summary

Variable validation guards the inputs to a module; precondition/postcondition guard invariants between resources that can only be checked once real values (potentially only known after planning) are available — both exist to surface a broken assumption as an early, clear error instead of a confusing downstream failure or, worse, a successful apply that silently violates an assumption the configuration depended on.

Related Resources

Simple flat variables (a string here, a number there) don't scale once a module needs to accept genuinely structured input — a list of firewall rules, each with several fields, some of which are optional. Terraform's structural types and the optional() modifier exist precisely for this.

Object type — a fixed-shape record

variable "ingress_rule" {
  type = object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
  })
}

object({...}) describes a record with named, typed attributes — analogous to a struct/interface in a general-purpose language.

Collections of objects

variable "ingress_rules" {
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = []
}

A list(object({...})) is exactly the shape used earlier for dynamic-block-driven security group rules — a variable number of structured entries, each with the same fields.

Optional attributes

variable "ingress_rules" {
  type = list(object({
    port        = number
    protocol    = optional(string, "tcp")
    cidr_blocks = optional(list(string), ["0.0.0.0/0"])
    description = optional(string)
  }))
}

optional(type, default) lets a caller omit protocol/cidr_blocks/description entirely for any given rule, falling back to the specified default (or null if no default is given, for description). Without this, every caller would be forced to specify every single field on every single object, even when the sensible default rarely changes.

Why this matters for module design

This is what lets a well-designed module expose one rich, structured variable (ingress_rules) instead of a dozen flat, individually-optional top-level variables trying to approximate the same structure (ingress_ports, ingress_protocols, ingress_cidrs, all as separate parallel lists that must stay the same length and are easy to accidentally misalign). Structured types with optional attributes keep a module's public interface both expressive and ergonomic — callers write only the fields that actually differ from the sensible default, while the module's implementation can rely on every field being present (with a concrete value) by the time it reaches resource blocks.

Related Resources