skip to content

When should you avoid a Terraform dynamic block and instead write the nested blocks out literally or declare separate resources?

level: seniorimportance: should knowfreq 45%

answer

  1. read far more often than written
  2. source stops being the answer
  3. nested blocks share one state address
  4. no targeting or lifecycle per block
  5. module interface is the intended use

basics

~20 s

Write blocks literally when the set is small and known; reach for a dynamic block mainly to hide repetition behind a reusable module's interface. When each item needs its own diff, lifecycle or targeted replacement, prefer separate resources instead.

solid answer

~50 s

HashiCorp's own guidance is to use dynamic blocks only when you need to hide details to build a clean interface for a reusable module, and otherwise to write nested blocks out literally. The reasons are practical. Generated configuration is harder to review: you cannot see what will exist without reading a plan, and a one-character change in a variable can move many blocks. Everything a dynamic block emits also lives inside a single resource, sharing one state address — so you cannot target, replace or apply a lifecycle rule to one generated block, and a diff to any of them shows up as a change to the whole resource. Where that granularity matters, separate resources with `for_each` are the better shape: with AWS provider v5 and later, `aws_vpc_security_group_ingress_rule` gives each rule its own address, its own plan line and its own description, which is why many teams moved off generated inline `ingress` blocks entirely.

go deeper

for a junior

Know that dynamic blocks are optional, not an upgrade: with a small fixed set of rules, writing the blocks out literally is the recommended style.

for a middle

Explain the readability cost concretely — the source no longer answers "what will exist", so a reader needs a plan — and name the module-interface case as the intended use.

for a senior

Argue from state structure: generated blocks share one address, so no targeting, no per-item lifecycle and coarse diffs, which is why per-rule resources often win in production.

for a principal

Set the estate-wide convention and own the migration cost, including the state churn when inline blocks become separate resources and the provider-version floor that choice implies.

## The official position, and why it is right Terraform's documentation is unusually direct here: overuse of dynamic blocks makes configuration hard to read and maintain, so they are recommended only when you need to hide details in order to build a clean user interface for a reusable module, and nested blocks should be written out literally where possible. That lands badly with the instinct that repetition is always a defect. But infrastructure code is read far more than it is written, usually under time pressure during an incident, and "three explicit `ingress` blocks" is legible to anyone while a dynamic block plus a variable plus a `for` expression that builds the variable is a small program to be executed in your head. ## Cost one: you cannot read the result With literal blocks, the source *is* the answer. With generation, answering "which ports are open?" means resolving variable precedence, then the expression, then the loop — or running a plan. During an incident that is the difference between ten seconds and ten minutes. Code review suffers the same way. A reviewer sees a diff on a variable's default value; the consequence is somewhere else in the file, possibly multiplied. The blast radius of a small edit stops being visible at the point of edit. ## Cost two: one state address for everything This is the structural argument, and it is the one that matters most in production. Nested blocks are attributes of their resource. They have no addresses of their own, so: - You cannot `-target` or `-replace=` an individual generated block. - You cannot give one of them a `lifecycle` rule; `prevent_destroy` or `ignore_changes` applies to the whole resource. - Any change to any generated block is a change to the resource, and providers sometimes handle a large nested-block edit by replacing more than you expected. - A plan reports "resource will be updated in-place" with a nested diff, rather than one crisp line per thing that changed. Separate resources invert all of that. Each gets an address, a diff line, its own lifecycle and its own failure. Since AWS provider v5, `aws_vpc_security_group_ingress_rule` and `aws_vpc_security_group_egress_rule` exist precisely for this: one resource per rule, each with its own `description`, instead of a set of inline `ingress` blocks that churn as a unit. ```hcl resource "aws_vpc_security_group_ingress_rule" "web" { for_each = var.ingress_rules security_group_id = aws_security_group.web.id cidr_ipv4 = each.value.cidr from_port = each.value.port to_port = each.value.port ip_protocol = "tcp" } ``` The loop has not gone away — it has moved to a level where Terraform can track each element individually. ## When a dynamic block is genuinely the right call 1. **A reusable module's interface.** Callers pass a list of rules; the module hides the repetition. This is the documented use case: the generation is an implementation detail behind a stable, small input surface. 2. **When the provider offers no separate resource.** Plenty of nested blocks have no standalone resource equivalent, and if the count varies with input, generation is the only option. 3. **The zero-or-one conditional block.** `for_each = var.enabled ? [1] : []` is idiomatic and readable, and there is no other clean way to make a whole block optional. 4. **Genuinely large, genuinely uniform sets.** Twenty identical-shaped entries derived from one data structure beat twenty copied blocks that will drift apart. ## The decision, in one pass Ask three questions. Does the number of blocks depend on input? If no, write them literally. Does each item need independent diffs, lifecycle or targeting? If yes, and a standalone resource exists, use separate resources with `for_each`. Is this a module hiding repetition from callers? If yes, a dynamic block is doing exactly its job. What you want to avoid is the middle case that shows up most often in real repositories: a dynamic block introduced to remove three lines of duplication, which now costs every future reader a plan run to answer a question the source used to answer directly.

  • Give a concrete operational consequence of generated blocks sharing a single state address.
    You cannot replace or target one of them. If a single generated rule ends up wrong in the API — drift a refresh cannot reconcile, or a provider bug — the only lever is the whole resource, so `-replace=` on a security group with thirty inline rules churns every one of them. Separate rule resources let you replace exactly the broken one.
  • Does moving from inline dynamic blocks to separate resources require state work?
    Yes. The rules stop being attributes of the security group and become their own resources, so Terraform plans a destroy of the inline configuration and a create of each new resource. Sequence it deliberately — expect a window where rules are removed and re-added, or import the existing rules into the new addresses — and never assume it is a no-op refactor.
  • Is a dynamic block that always produces exactly one block ever justified?
    Yes, in the conditional idiom: `for_each = var.enabled ? [1] : []` produces one block or none, and that is the standard way to make a whole nested block optional when a provider distinguishes absent from empty. A dynamic block that unconditionally produces exactly one block, though, is pure indirection — write it literally.

saying these in an interview costs you the question

  • Treats any repetition in HCL as a defect to remove
  • Thinks a generated block can be targeted or replaced individually
  • Believes dynamic blocks reduce apply-time API calls
  • Assumes swapping inline rules for resources is a no-op refactor
  • Says literal blocks are simply beginner-level Terraform

context