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[*].idalways includes every instance; there's noifclause equivalent. To get only some instances' IDs, you need a realforexpression. - 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
forexpression 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
- 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.
- 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.
- Multiple validation blocks compose. A single variable can have several
validationblocks, each checking a different constraint, each with its own tailored message — clearer than one giant compound condition with a single generic error. - It's documentation, too. A
validationblock 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 validation | precondition/postcondition | |
|---|---|---|
| Scope | The variable's own raw value only | Any expression, including other resources' computed attributes |
| Timing | Immediately, at variable assignment | Before (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.