HCL Metaprogramming — count and for_each, dynamic Blocks, and the Expression Language That Makes a Module an API
Engineers who would never copy a function body will paste a resource block three times, because the file ends in .tf.
§ IFrame
HCL gets dismissed as a config format, and the dismissal costs real money. Engineers who would never copy a function body three times will happily paste a resource block three times, because the file ends in .tf and their programmer instincts have been told to stand down. Today's Ops lesson closed with a question that only a language can answer: if one module serves ten callers with ten shapes of need, how does one body avoid becoming ten copies inside? The answer is that HCL is a language, with loops, conditionals, comprehensions, and generation, and the module author who writes it as one produces a body that bends where copies would multiply.
This is the first HCL lesson on the Polyglot-Dev shelf, opening the sub-rotation the sprint correction ratified this week. The material splits into two halves. The iteration constructs, count and for_each, decide how many of a resource exist. The expression constructs, for expressions and dynamic blocks, decide what shape each one takes. Between them they cover nearly everything a production module body does that a beginner's flat file does not.
§ IILanguage Idiom
count is an index; for_each is a name. Hold that sentence, because it decides real incidents. count is the older construct: set count = 3 on a resource and Terraform stamps three copies, addressed web[0], web[1], web[2]. The addresses are positions in a list. Now delete the middle entry from the list that fed the count. Every element after it shifts down one, Terraform sees web[1] become what web[2] was, and the plan proposes to destroy and recreate infrastructure that you never touched. Brikman's loops chapter grounds this lesson, and it walks that exact wreck in slow motion.
for_each iterates a map or a set instead, and it addresses each copy by key: web["alpha"], web["bravo"], web["charlie"]. Delete bravo and exactly bravo is destroyed. The others hold, because their names did not move when the collection changed. Position is a fact about a list; a name is a fact about the thing. Wire identity to names and change stays local, which is the same claim the Ops lesson made about contracts, working one level further down.
count keeps one honest job in a for_each world: the conditional resource. count = var.enable_nat ? 1 : 0 is the idiom that turns a boolean input into the presence or absence of a resource, and its partner one(aws_nat_gateway.this[*].id) collapses the zero-or-one list back to a single value for outputs.
for expressions reshape; dynamic blocks generate. A for expression is HCL's comprehension: {for name, s in var.subnets : name => s.cidr} builds a new map from an old one, filtering with an if clause where needed. It is the tool that converts what the caller sent into what the provider wants, without a single copied line. The dynamic block answers a different question. Some provider arguments are nested blocks, and you cannot loop a block with an expression. dynamic "ingress" { for_each = var.rules ... } stamps one nested ingress block per element, with a content block describing each stamp. A dynamic block is a loop that writes configuration, and it is the last piece needed before a module body can absorb a caller's structure whole.
§ IIICode Worked Example
The network module from today's Ops lesson promised its callers subnets from a typed map. Here is the body keeping that promise. The caller passes structure; the signature types it:
variable "subnets" {
type = map(object({
cidr = string
az = string
public = bool
}))
validation {
condition = alltrue([for s in values(var.subnets) : can(cidrnetmask(s.cidr))])
error_message = "Every subnet cidr must be a valid CIDR block."
}
}
The validation line is a for expression working at the border checkpoint: it sweeps every entry, can() converts a would-be error into a boolean, and alltrue() folds the sweep into one verdict at plan time. A malformed caller is refused before anything exists.
The body stamps one subnet per entry, keyed by name:
resource "aws_subnet" "this" {
for_each = var.subnets
vpc_id = aws_vpc.this.id
cidr_block = each.value.cidr
availability_zone = each.value.az
map_public_ip_on_launch = each.value.public
tags = { Name = "${var.env}-${each.key}" }
}
output "subnet_ids" {
value = { for name, s in aws_subnet.this : name => s.id }
}
Inside the loop, each.key is the caller's chosen name and each.value is that entry's object. The output below runs the comprehension in the other direction, folding the stamped resources back into a name-to-ID map, which is exactly the shape the Ops lesson's composition discipline needs when these IDs feed the next module's input. Caller structure in, caller-shaped results out. The body in between never mentions any particular subnet.
The security group absorbs a variable number of rules the same way, through generation:
resource "aws_security_group" "edge" {
name = "${var.env}-edge"
vpc_id = aws_vpc.this.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidrs
}
}
}
One caller sends two rules and gets two ingress blocks; another sends nine and gets nine. The module carries no opinion about the number. Read the three blocks together and the division of labor is clean: the variable types and validates, for_each decides existence, the for expression reshapes, and dynamic generates the nesting the provider demands.
§ IVConnection to Today's Ops Lesson
The Ops lesson drew the module's outside: signature, return, address, pin. Every construct here is the inside face of one of those outside promises. The typed variable is the signature made enforceable. The for_each body is how one implementation serves every caller without copies, which is what made the version tag meaningful in the first place, since a tag on a body that callers must fork is a tag on nothing. The output comprehension is the return clause actually being computed. And the cert lesson closes the triangle: the Associate exam's objectives 5 and 8 test precisely this pairing, modules from the outside and the configuration language from the inside.
§ VPrior-Lesson Reach
Two earlier lessons in this track already touched today's machinery from their own angles. Wednesday's Dev lesson built pytest contract tests over terraform show -json, and its test_nothing_is_destroyed assertion is the mechanical tripwire for exactly the count-index shift described above: reorder a counted list and the plan JSON sprouts delete-and-create pairs the test refuses. The rename hazard is invisible in the diff of your .tf files and loud in the plan, and the plan is where that lesson taught you to look. Wednesday's Ops lesson supplies the other half of the reason for_each wins: state records resources by address, so an address scheme built on positions writes churn into the shared record every time the collection breathes, while an address scheme built on names keeps state describing intent.
§ VIClosing
Write module bodies as programs, because that is what they are. Give every collection a name-keyed for_each and reserve count for the boolean existence idiom. Let for expressions do the reshaping at the edges, so the caller's vocabulary and the provider's vocabulary both stay natural and the translation lives in one visible line. Reach for dynamic only when the provider's nesting forces it, and keep the content block thin. A body written this way has no reason to be copied, and a module nobody needs to copy is the whole point of the exercise.
Take one resource block you have pasted more than once, in any configuration you own. Lift it into a for_each over a named map, plan, and read the addresses. If the plan destroys nothing and the names read like intent, you did it right.
Paired lessons → Ops 01-Earth-DevOps/Synthesis-Lessons/2026-07-25-terraform-modules-... · Cert Cert-Prep/HashiCorp/2026-07-25-terraform-associate-003-modules-and-configuration-...
Filed 2026-07-25 Saturday Fajr · Dev lesson · HCL depth, sub-rotation counter 0 · first lesson on the HCL shelf
Backward-Synergy-Reach → Testing Terraform with Python (Wed 07-22 Dev) · Terraform state + the lock (Wed 07-22 Ops)
Grounded in Brikman, Terraform: Up and Running 3ed Ch 5 · HTML shipped in-cycle per HARD DISCIPLINE · earth-accent