Skip to content
Infrastructure as Code iac hcl 5 min read

for Expressions

A for expression takes one collection and produces another, applying a transformation to every element along the way. It is HCL’s equivalent of map/filter/comprehension from general-purpose languages, and it is the tool you reach for whenever the shape of your data does not quite match what a resource argument or a for_each meta-argument expects. Unlike the for_each and count meta-arguments, a for expression does not create resources — it builds a value. Everything here behaves identically in Terraform 1.5+ and OpenTofu, since both share the HCL2 grammar.

Building a list

Wrapping a for expression in square brackets produces a tuple (a list). You name a temporary variable for each element, then write the expression after the colon that computes the output element.

variable "names" {
  type    = list(string)
  default = ["web", "api", "worker"]
}

locals {
  upper_names = [for name in var.names : upper(name)]
  prefixed    = [for name in var.names : "svc-${name}"]
}

output "prefixed" {
  value = local.prefixed
}

Output:

Changes to Outputs:
  + prefixed = [
      + "svc-web",
      + "svc-api",
      + "svc-worker",
    ]

When iterating a list, the single loop variable is the element value. When iterating a map or object, you may declare two variables — the key and the value.

variable "ports" {
  type = map(number)
  default = {
    http  = 80
    https = 443
  }
}

locals {
  port_labels = [for name, port in var.ports : "${name}:${port}"]
}

This yields ["http:80", "https:443"]. The result of a list for expression is always a tuple, even when the source is a map.

Building a map

Wrapping a for expression in curly braces produces an object (a map). You must supply two result expressions separated by the => arrow: the key on the left and the value on the right.

variable "users" {
  type = list(object({
    name = string
    role = string
  }))
  default = [
    { name = "alice", role = "admin" },
    { name = "bob",   role = "viewer" },
  ]
}

locals {
  roles_by_user = { for u in var.users : u.name => u.role }
}

output "roles_by_user" {
  value = local.roles_by_user
}

Output:

Changes to Outputs:
  + roles_by_user = {
      + alice = "admin"
      + bob   = "viewer"
    }

Map keys must be unique. If two iterations produce the same key Terraform raises an error, unless you group the values — append ... after the value expression to collect colliding entries into a list.

locals {
  users_by_role = { for u in var.users : u.role => u.name... }
}

This produces { admin = ["alice"], viewer = ["bob"] }, grouping every user under their role.

Filtering with if

Append an if clause to keep only the elements for which a condition is true. The condition is evaluated against the current loop variables, so filtering composes naturally with transformation.

variable "instances" {
  type = list(object({
    name    = string
    enabled = bool
  }))
  default = [
    { name = "blue",  enabled = true },
    { name = "green", enabled = false },
    { name = "red",   enabled = true },
  ]
}

locals {
  active = [for i in var.instances : i.name if i.enabled]
}

The result is ["blue", "red"]. Combine if with a map projection to drop disabled records entirely while reshaping what remains.

Feeding for_each and count

The most common production use of a for expression is preparing data for for_each. Because for_each requires a map (or a set of strings) with stable, known keys, you typically convert a list of objects into a keyed map first.

variable "buckets" {
  type = list(object({
    name       = string
    versioning = bool
  }))
  default = [
    { name = "logs-prod", versioning = true },
    { name = "assets",    versioning = false },
  ]
}

resource "aws_s3_bucket" "this" {
  for_each = { for b in var.buckets : b.name => b }
  bucket   = each.value.name
}

resource "aws_s3_bucket_versioning" "this" {
  for_each = { for b in var.buckets : b.name => b if b.versioning }
  bucket   = aws_s3_bucket.this[each.key].id

  versioning_configuration {
    status = "Enabled"
  }
}

Output:

  # aws_s3_bucket.this["assets"] will be created
  # aws_s3_bucket.this["logs-prod"] will be created
  # aws_s3_bucket_versioning.this["logs-prod"] will be created

Plan: 3 to add, 0 to change, 0 to destroy.

Note how the if b.versioning filter means only logs-prod gets a versioning resource, while both buckets are created.

Warning: Use a stable, meaningful attribute as the for_each key, not count.index or list position. If you key by index and later remove an element, every subsequent resource shifts and Terraform plans a destroy/recreate cascade.

Ordering and the result type

A list for expression over a map iterates keys in lexical order, giving deterministic output. A list for produces a tuple; a map for produces an object. If a resource argument requires a set, wrap the result with the toset function.

FormResult typeUse when
[for x in c : expr]tuple/listTransforming or filtering into a list
[for k, v in c : expr]tuple/listProjecting map entries into a list
{for x in c : k => v}object/mapKeying records for for_each
{for x in c : k => v...}object/mapGrouping colliding keys into lists

Best Practices

  • Convert lists of objects into maps with a for expression before passing them to for_each, keying on a stable, unique attribute.
  • Apply if filters inside the for expression rather than building the full collection and pruning it afterward.
  • Keep complex for expressions in locals so the transformation is named, reviewable, and reused.
  • Use the grouping form (...) instead of manual merge logic when several elements legitimately share a key.
  • Wrap results with toset when an argument expects a set, and remember a map for already deduplicates by key.
  • Avoid keying for_each maps by list index — use real identifiers so removing one element does not reshuffle the rest.
Last updated June 14, 2026
Was this helpful?