Terraform Lesson 15 of 89

Mastering Terraform Dynamic Blocks, Complex Types, and Variable Validation

A module interface is an API. The difference between a module your platform team trusts and one they fork is almost never the resources inside it; it’s whether the inputs are typed, invalid combinations are rejected at plan time, and optional knobs default sanely instead of forcing every caller to specify everything. Terraform’s type system, dynamic blocks, and the validation/precondition/postcondition family let you build that interface entirely in HCL. They also give you enough rope to write code nobody can read. This is a guide to using the sharp tools well and knowing when to put them down.

I’ll assume Terraform 1.9 or later. optional() defaults landed stable in 1.3, precondition/postcondition in 1.2, and cross-object input validation in 1.9, so version pinning matters for several of these patterns.

In a nutshell

Three Terraform features almost always show up together, so this lesson teaches them as one toolkit: complex types (how you describe the shape of an input), dynamic blocks (how you stamp out repeated nested configuration), and the validation/precondition/postcondition family (how you reject bad input before it does damage). Together they turn a module from a bag of loose settings into a small, well-guarded API.

Start with the one idea that unlocks the rest. A dynamic block is a for-loop for configuration. Imagine a form with room for exactly one “lifecycle rule,” but you actually have five rules in a list. A dynamic block is the rubber stamp that reads your list and stamps the rule sub-block onto the form once per item — five items in, five blocks out; zero items in, zero blocks out. You describe one block template; Terraform repeats it for you.

Complex types are how the list arrives in the first place. Instead of forcing a caller to pass ten separate scalar variables and hoping they line up, you declare one typed object — “a bucket has a name, an optional versioning flag, and a list of lifecycle_rules” — and Terraform checks the shape for you. optional(type, default) (stable since Terraform 1.3) lets attributes be omitted and quietly filled in, so callers write two lines instead of twenty.

Validation is the bouncer at the door. A validation block rejects malformed input the instant a plan starts (“environment must be one of dev/staging/prod”); a precondition checks an invariant against computed values just before Terraform touches a resource; a postcondition checks that the result came out right. Bad input dies with a message you wrote, not a cloud-provider stack trace three minutes into an apply.

From typed variables through dynamic blocks to rendered resources

Read it left to right: a typed variable object flows through input validation, gets reshaped in locals, and a dynamic block stamps out one nested configuration block per list element before Terraform renders the final resource.

Level: Intermediate · Time: ~35 min

Prerequisites. You should be comfortable with basic HCL — resources, variables, and the terraform plan/apply workflow — and have met for_each/count at least once. If any of that is shaky, read Terraform Fundamentals: HCL, Providers, State & Workflow and Resources & Meta-Arguments: count, for_each, lifecycle first. The HCL Syntax Deep Dive and Functions & Expressions for Dynamic Conditionals cover the type and function machinery this lesson leans on.

After this lesson you will be able to:

1. Design module inputs with object types and optional() defaults

The weakest module interface is a pile of scalar variables: bucket_name, bucket_versioning, bucket_lifecycle_days, bucket_logging_target. Callers can’t tell which scalars belong together, and you can’t express “if logging is on, a target is required.” Model the input as a typed object instead, and use optional() to give attributes defaults so callers only specify what they need.

variable "buckets" {
  description = "Map of logical name => bucket configuration."
  type = map(object({
    name           = string
    versioning     = optional(bool, false)
    force_destroy  = optional(bool, false)
    storage_class  = optional(string, "STANDARD")
    lifecycle_rules = optional(list(object({
      id                       = string
      prefix                   = optional(string, "")
      expiration_days          = optional(number)
      transition_to_ia_days    = optional(number)
      noncurrent_expiration    = optional(number)
    })), [])
    tags = optional(map(string), {})
  }))
  default = {}
}

Two properties of optional() matter. First, the second argument is a default that Terraform fills in during type conversion before your code sees the value, so inside the module you write each.value.versioning (guaranteed non-null) instead of lookup(each.value, "versioning", false). Second, defaults apply at the level they’re declared: a caller who omits lifecycle_rules gets [], and a caller who supplies one rule with only id and expiration_days gets prefix = "" filled in automatically.

A caller’s input collapses to the essentials:

module "data_lake" {
  source = "./modules/s3-buckets"

  buckets = {
    raw = {
      name       = "acme-data-lake-raw"
      versioning = true
      lifecycle_rules = [
        { id = "expire-tmp", prefix = "tmp/", expiration_days = 7 },
      ]
    }
    curated = {
      name = "acme-data-lake-curated"
      # everything else defaulted
    }
  }
}

A typed object that rejects unknown attributes is a feature, not a limitation. If a caller misspells versionning, conversion fails with a clear error instead of silently ignoring the key the way an untyped map(any) would.

2. Generate nested blocks with dynamic and the for_each iterator

Once lifecycle_rules is a list, you can’t write a fixed number of nested blocks. dynamic generates them. It takes the block label as its own label, a for_each collection, and a content {} template that runs once per element. Inside content, the iterator object exposes .key and .value.

resource "aws_s3_bucket" "this" {
  for_each      = var.buckets
  bucket        = each.value.name
  force_destroy = each.value.force_destroy
  tags          = each.value.tags
}

resource "aws_s3_bucket_lifecycle_configuration" "this" {
  # Only create the config resource for buckets that actually have rules.
  for_each = { for k, v in var.buckets : k => v if length(v.lifecycle_rules) > 0 }
  bucket   = aws_s3_bucket.this[each.key].id

  dynamic "rule" {
    for_each = each.value.lifecycle_rules
    content {
      id     = rule.value.id
      status = "Enabled"

      filter {
        prefix = rule.value.prefix
      }

      # Nested dynamic: emit expiration only when a day count is set.
      dynamic "expiration" {
        for_each = rule.value.expiration_days != null ? [rule.value.expiration_days] : []
        content {
          days = expiration.value
        }
      }

      dynamic "transition" {
        for_each = rule.value.transition_to_ia_days != null ? [rule.value.transition_to_ia_days] : []
        content {
          days          = transition.value
          storage_class = "STANDARD_IA"
        }
      }
    }
  }
}

The iterator is named after the block by default (rule, expiration, transition). When a nested dynamic label collides with an outer one or the auto-derived name reads badly, rename it explicitly with iterator:

dynamic "setting" {
  for_each = var.app_settings
  iterator = cfg
  content {
    namespace = cfg.value.namespace
    name      = cfg.key
    value     = cfg.value.value
  }
}

The single most important idiom here is the conditional single-element list: for_each = condition ? [value] : []. A dynamic block over an empty list produces zero blocks; over a one-element list, exactly one. That is how you include an optional sub-block only when its data is present, without a separate resource. Reach for dynamic only when block count or content genuinely varies by input; if a block is always present, write it literally.

3. Conditional blocks, null handling, and the merge/coalesce toolkit

Optional inputs mean nulls, and nulls behave differently across HCL functions in ways that bite. Keep three rules straight:

A common mistake is using coalesce to default to an empty string or map; it throws because the empty value is treated as absent. Use a conditional or try for that case.

locals {
  # Merge precedence: caller tags win over module defaults.
  common_tags = {
    ManagedBy   = "terraform"
    Module      = "s3-buckets"
    Environment = var.environment
  }

  # coalesce picks the first real value; never feed it only-empty args.
  resolved_kms_key = coalesce(var.kms_key_arn, var.default_kms_key_arn)
}

resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = each.value.name
  # Later keys override earlier ones in merge().
  tags     = merge(local.common_tags, each.value.tags)
}

merge() is right-biased: later maps win on key collisions, which is the precedence you want when layering caller overrides on top of module defaults. For conditionally including whole attributes, merge an empty map:

locals {
  encryption_block = var.kms_key_arn != null ? {
    sse_algorithm     = "aws:kms"
    kms_master_key_id = var.kms_key_arn
  } : {
    sse_algorithm = "AES256"
  }
}

4. Variable validation with multiple validation blocks and regex

A single variable can carry as many validation blocks as you have rules. Terraform evaluates all of them and surfaces every failure, so write one rule per failure mode with a specific error_message. The condition must reference the variable and must return true when valid.

variable "environment" {
  type        = string
  description = "Deployment environment slug."

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

variable "bucket_prefix" {
  type        = string
  description = "DNS-compatible prefix for bucket names."

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$", var.bucket_prefix))
    error_message = "bucket_prefix must be 3-42 lowercase alphanumerics or hyphens, not starting/ending with a hyphen."
  }

  validation {
    condition     = !strcontains(var.bucket_prefix, "--")
    error_message = "bucket_prefix must not contain consecutive hyphens."
  }
}

can(regex(...)) is the workhorse: regex() throws when there’s no match, and can() converts that throw into false. Two accuracy notes. Use the ^...$ anchors deliberately, because regex matches anywhere in the string otherwise and "ab_CD" would pass a pattern meant to forbid underscores. And remember that as of Terraform 1.9, a validation condition may reference other variables and data sources, not just var.<self>, which lets you express cross-field rules at the input layer:

variable "replica_count" {
  type    = number
  default = 1
}

variable "multi_az" {
  type    = bool
  default = false

  validation {
    # Cross-variable rule: multi-AZ is meaningless with a single replica.
    condition     = var.multi_az == false || var.replica_count >= 2
    error_message = "multi_az requires replica_count >= 2."
  }
}

Keep validation for things knowable from input alone: formats, enums, ranges, mutually exclusive flags. Anything that depends on a computed attribute or a remote lookup belongs in a precondition.

5. Precondition and postcondition lifecycle checks on resources and outputs

validation runs against raw input. precondition and postcondition live in a lifecycle block (or in check blocks and output blocks) and run during plan/apply, so they can reference computed values, other resources, and data sources. A precondition asserts an invariant must hold before Terraform acts on the resource; a postcondition asserts something about the result.

data "aws_caller_identity" "current" {}

resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = each.value.name

  lifecycle {
    # Catch a foot-gun before the destroy is planned, not after.
    precondition {
      condition     = each.value.force_destroy == false || var.environment != "prod"
      error_message = "force_destroy must not be enabled for buckets in prod (${each.value.name})."
    }
  }
}

resource "aws_s3_bucket_public_access_block" "this" {
  for_each                = aws_s3_bucket.this
  bucket                  = each.value.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  lifecycle {
    postcondition {
      condition     = self.block_public_policy && self.restrict_public_buckets
      error_message = "Public access block did not fully apply; refusing to proceed."
    }
  }
}

self is only available inside postcondition and refers to the resource the block is attached to. Postconditions on outputs are an underused guardrail: they let a module assert its own contract before exporting a value, so a downstream module never receives garbage.

output "bucket_arns" {
  description = "ARNs of all managed buckets, keyed by logical name."
  value       = { for k, b in aws_s3_bucket.this : k => b.arn }

  precondition {
    condition     = length(aws_s3_bucket.this) == length(var.buckets)
    error_message = "Bucket count mismatch; some buckets failed to materialize."
  }
}

For invariants that aren’t tied to a single resource’s lifecycle, use a standalone check block. Unlike preconditions, a failing check assertion emits a warning and does not block apply, which is the right severity for advisory drift signals and post-deploy health probes.

check "tls_minimum" {
  data "http" "endpoint" {
    url = "https://${var.public_endpoint}/healthz"
  }

  assert {
    condition     = data.http.endpoint.status_code == 200
    error_message = "Health endpoint returned ${data.http.endpoint.status_code}."
  }
}

6. Transform data with for expressions, flatten, and setproduct

Module internals frequently need to reshape a nested input into a flat collection suitable for for_each. Two functions do most of the heavy lifting: flatten collapses one level of nesting, and setproduct builds the Cartesian product of two or more sets. The canonical pattern is a nested for that emits objects, wrapped in flatten, then re-keyed into a map.

locals {
  # Input: map of bucket => list of CORS origins.
  # Goal: one flat list of {bucket, origin} pairs for a per-rule resource.
  bucket_cors = flatten([
    for bucket_key, cfg in var.buckets : [
      for origin in cfg.cors_origins : {
        bucket = bucket_key
        origin = origin
      }
    ]
  ])

  # Re-key with a STABLE composite key so addressing is deterministic.
  bucket_cors_map = {
    for pair in local.bucket_cors :
    "${pair.bucket}:${pair.origin}" => pair
  }
}

setproduct is the tool when you genuinely want every combination, for example expanding regions x environments:

locals {
  regions      = ["us-east-1", "eu-west-1"]
  environments = ["dev", "prod"]

  # Returns a list of [region, env] tuples.
  deployments = {
    for pair in setproduct(local.regions, local.environments) :
    "${pair[0]}-${pair[1]}" => {
      region      = pair[0]
      environment = pair[1]
    }
  }
}

The non-obvious rule: the value of a composite key must be unique, and the key string you build must be stable across runs. Building keys from list indices ("${idx}") is the classic trap; if the input list reorders, every key shifts and Terraform plans a destroy/create storm. Always key from intrinsic identity (names, IDs), never position.

7. Avoid the count vs for_each addressing trap on collections

This is the single most expensive HCL mistake in production, so it gets its own step. count indexes resources by integer position: aws_s3_bucket.this[0], [1], [2]. for_each indexes by map key: aws_s3_bucket.this["raw"], ["curated"]. When you manage a collection of similar things, the addressing scheme decides what happens when the collection changes.

Consider three buckets managed with count = length(var.bucket_names) over ["raw", "curated", "archive"]. Remove "curated" from the middle. The list becomes ["raw", "archive"], so index [1] flips from curated to archive and index [2] disappears. Terraform reads that as: modify [1] (rename curated -> archive in place, which for an S3 bucket means destroy and recreate) and destroy [2]. You lost the archive bucket to delete the curated one.

# FRAGILE: positional addressing on a mutable collection.
resource "aws_s3_bucket" "bad" {
  count  = length(var.bucket_names)
  bucket = var.bucket_names[count.index]
}

# CORRECT: stable, key-based addressing.
resource "aws_s3_bucket" "good" {
  for_each = toset(var.bucket_names)
  bucket   = each.value
}

With for_each, the address is aws_s3_bucket.good["curated"]. Remove that key and Terraform destroys exactly that one resource and leaves the others untouched. The rule is mechanical: use count only for “create N copies” or a boolean on/off toggle (count = var.enabled ? 1 : 0); use for_each for every collection of distinct things. If you’ve already shipped count over a collection and need to migrate, do it with moved blocks mapping each index to its new key so the change is config-driven and reviewable rather than a state mv by hand.

moved {
  from = aws_s3_bucket.bad[0]
  to   = aws_s3_bucket.good["raw"]
}

8. Readability tradeoffs: when to push logic out of HCL

Every technique above puts computation in configuration, and that has a ceiling. A triple-nested for inside a flatten inside a merge, gated by a ternary, is technically correct and operationally hostile: the next engineer can’t predict the plan, and a typo produces a 400-line diff. Some honest limits:

Logic lives best in… When
Static HCL Fixed structure, no variation by input
dynamic + for Block count/content varies by typed input, shallow nesting
validation / preconditions Asserting invariants and rejecting bad input early
External data / templates Generation, lookups, anything you’d want to unit-test
A real language (CDKTF, a generator) Combinatorial expansion, complex transforms, reuse across stacks

When you find yourself reaching for the fourth nested loop, that’s the signal to generate the configuration upstream, model the data with an external data source, or move to a programmatic tool. The goal of advanced HCL is a simple interface backed by just enough cleverness, not a demonstration of how much the language can do.

Going deeper

The eight steps above are the working patterns. This section is the layer underneath them — the type system’s exact rules, the moments Terraform silently rewrites your values, and the timing model that decides when each guardrail fires. None of it is required to ship a working module, but all of it is what separates “works on my inputs” from “hard to misuse.”

The full type system: primitives, collections, and structural types

Terraform has three families of type. Primitives are string, number, and bool. Collection typeslist(T), set(T), map(T) — hold any number of elements that all share one type T. Structural typesobject({...}) and tuple([...]) — hold a fixed set of elements whose types can differ. The distinction that trips people up is homogeneous vs heterogeneous: a list is “N of the same thing,” a tuple is “these specific things in this order,” a map is “named values all of one type,” and an object is “named attributes each with its own type.”

Type Shape Indexed by Homogeneous? Typical use
string / number / bool single value scalars
list(T) ordered, duplicates ok integer position yes ordered sequences
set(T) unordered, unique not indexable yes membership, for_each
map(T) key → value string key yes (values) keyed lookups
tuple([...]) fixed positions, mixed types integer position no fixed heterogeneous rows
object({...}) named attributes, mixed types attribute name no module input schemas

Two practical consequences. A set has no positions, so you cannot write var.my_set[0]; convert with tolist() first, or iterate it directly (which is exactly why resource for_each loves sets). And object is the only type that lets attributes carry optional(), which is why every non-trivial module input ends up an object or a map(object(...)) rather than a map(any).

Type conversion and coercion: where Terraform quietly rewrites your values

Terraform converts values automatically whenever the target type is unambiguous, and most of the time that is a convenience. The failure mode is when the conversion succeeds but not the way you expected.

The defensive habit: type your variables precisely (object, not any; list(string), not bare list) and let conversion errors surface at plan time instead of discovering the coercion later in a rendered resource.

optional() internals, null, and nullable

optional() only appears inside an object type, and it does exactly two things. optional(string) marks an attribute as omittable and defaults it to null when absent. optional(string, "STANDARD") marks it omittable and defaults it to the supplied value, injected during type conversion — before any expression in your module reads it. That injection timing is the whole point: downstream code sees a fully-populated object and never has to write lookup(each.value, "x", default).

Defaults propagate through nesting from the inside out. If lifecycle_rules is optional(list(object({ prefix = optional(string, "") })), []), then a caller who omits the list gets [], and a caller who supplies a rule with no prefix gets "" — both defaults are applied in the same conversion pass.

nullable is a different knob and operates at the variable level, not the attribute level:

variable "replica_count" {
  type     = number
  default  = 3
  nullable = false # a caller may not pass null; null coalesces to the default
}

With nullable = false (the default is true), if a caller explicitly passes null Terraform substitutes the variable’s default instead of letting null flow into the module. Use it on any variable whose downstream code would break on null. Note the layering: nullable governs the variable; optional() governs attributes inside an object type. They compose but solve different problems.

dynamic block internals: scope, plan-time knowability, and what you can’t generate

A dynamic block is a rendering instruction, not a runtime loop. Three internals are worth knowing:

  1. The iterator is a scoped temporary. Inside content {}, the symbol (default: the block label, or whatever you name via iterator) exists only within that content body and shadows nothing outside it. Unlike a resource-level for_each, a dynamic block’s for_each accepts a list, set, or map — when you iterate a list, .key is the integer index and .value is the element; when you iterate a map, .key is the map key.
  2. The block set should be knowable at plan time. Terraform must decide how many blocks to render while building the plan. The individual .value fields can be unknown (computed) values, but deriving the collection itself from an attribute that won’t exist until apply can produce an “Invalid for_each argument” style error. Keep the driving collection rooted in variables/locals, not in a not-yet-created resource’s output.
  3. You can’t generate everything. dynamic produces repeatable nested configuration blocks only. You cannot use it to generate meta-argument blocks such as lifecycle, nor to produce a resource’s own count/for_each/depends_on/provider. Those are structural and must be written literally.

The readability tax is real: because the rendered block never appears in your source, a wrong content field shows up only in the plan diff. The mitigation is discipline — shallow nesting, an explicit iterator name when the default reads badly, and a hard stop at two levels of nested dynamic.

sensitive (and ephemeral) values on complex types

Marking a variable sensitive = true taints its value and everything derived from it: use a sensitive string to build a map, and the whole map is redacted in plan output. That propagation is the feature — a secret can’t leak by being embedded in a larger structure — but it is also coarse:

variable "db" {
  type = object({
    host     = string
    username = string
    password = string
  })
  sensitive = true # redacts the WHOLE object, host and username included
}

Two consequences follow from sensitivity being value-level, not field-level. First, marking the object sensitive hides the non-secret host and username too, which can make plans harder to review; if only one field is secret, model that secret as its own variable. Second, a sensitive value — or anything derived from one — cannot be used as a for_each key or a count; Terraform rejects it, because doing so would leak the secret through resource addresses. The sensitive() and nonsensitive() functions let you toggle the mark deliberately when you have proven a value is safe to reveal.

For genuine secrets that should never touch state at all, Terraform 1.10’s ephemeral values and resources are the newer answer: an ephemeral value exists only during a single run and is never persisted to state or plan files, closing the gap that sensitive (which still writes the value to state) leaves open. Flag it as version-gated — it is unavailable before 1.10, and OpenTofu’s equivalent differs.

The evaluation model: when validation, preconditions, and checks actually run

The four assertion mechanisms look similar but fire at different phases and with different consequences. Choosing correctly is mostly about when the information you need exists and how loud a failure should be.

Mechanism Lives in Fires May reference On failure
validation variable block input parsing, before the plan graph var.self, and (1.9+) other variables & data sources error — halts
precondition lifecycle of a resource/data/output during plan/apply, before acting on the object computed values, other resources/data — not self error — halts
postcondition lifecycle of a resource/data/output during plan/apply, after the object is known self plus computed values error — halts
check assert standalone check block end of plan, and again on apply/refresh its own scoped data sources, resources warning — does not halt

The mechanical decision tree: if the rule is knowable from raw input alone (format, enum, range, mutually-exclusive flags), it is a validation. If it depends on a computed value or another object and must block the run, it is a precondition (guarding the inputs to an action) or a postcondition (guarding the result). If it is advisory — drift you want surfaced but not enforced, or a post-deploy health probe — it is a check block, whose failures are warnings so a flaky endpoint never blocks an unrelated apply. Note that check blocks re-run on every plan and refresh, which is what makes them suitable as continuous assertions rather than one-shot gates.

Common beginner mistakes

These are misconceptions, not error messages — the wrong mental model that produces working-but-wrong code.

“A dynamic block is just a cleaner way to write any block.” It isn’t cleaner; it’s indirect. A dynamic block whose for_each is a fixed one-element list, or that wraps a block you always want, has added a layer you can’t see in the source for zero benefit. Right model: write blocks literally by default, and reach for dynamic only when the number of blocks varies with input.

optional(string) defaults to an empty string.” It defaults to null. Only optional(string, "") gives you "". If your module code runs length(each.value.prefix) on an attribute you declared optional(string) and the caller omitted it, you get a null error, not zero. Right model: supply the second argument whenever downstream code can’t tolerate null.

validation can check that the resource turned out correctly.” A validation block runs before the plan graph even exists; it can only see raw input values. Anything about a computed attribute, another resource, or the real world is invisible to it. Right model: input shape → validation; computed invariant → precondition/postcondition; advisory → check.

coalesce() is how I default to an empty value.” coalesce() treats empty as absent and errors if every argument is null or empty, so coalesce(var.x, "") throws exactly when var.x is null. Right model: use try(var.x, "") or var.x != null ? var.x : "" for empty defaults; keep coalesce for picking among real values.

“Marking one field sensitive hides just that field.” Sensitivity is a property of the whole value. Mark an object sensitive and every field — including the innocuous name — is redacted, and the value can no longer key a for_each. Right model: isolate the actual secret into its own variable, or use ephemeral values (1.10) for data that shouldn’t reach state.

“I have a list, so I’ll use count.” count addresses by position, so deleting a middle element renames every later resource and triggers destroy/recreate churn (see step 7). Right model: for_each over a map or set keyed by stable identity, so removing one key touches exactly one resource.

Practice challenges

Work these in a scratch directory. None of them need a real cloud account — terraform validate and terraform console are enough to check most, and where a resource type appears it is illustrative. Each solution notes why in one line.

1. Collapse scalars into a typed object (beginner). You have three variables — bucket_name (string), versioning (bool, usually false), and storage_class (string, usually "STANDARD"). Replace them with a single object variable where the last two are optional with those defaults.

<details><summary>Solution</summary>

variable "bucket" {
  type = object({
    name          = string
    versioning    = optional(bool, false)
    storage_class = optional(string, "STANDARD")
  })
}

Why: one object keeps related settings together, and optional(type, default) lets a caller pass just name while the module still reads fully-populated attributes. </details>

2. Generate blocks from a list (beginner–intermediate). Given var.ingress_rules typed list(object({ port = number, cidr = string })), generate one ingress {} block per element on a security-group-style resource.

<details><summary>Solution</summary>

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

resource "aws_security_group" "this" {
  name = "app-sg"

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [ingress.value.cidr]
    }
  }
}

Why: one dynamic "ingress" stamps one ingress {} block per list element, and an empty list yields zero blocks with no special-casing. </details>

3. Emit an optional sub-block only when present (intermediate). Add a logging {} block to a bucket resource that appears only when var.log_bucket is non-null.

<details><summary>Solution</summary>

dynamic "logging" {
  for_each = var.log_bucket != null ? [var.log_bucket] : []
  content {
    target_bucket = logging.value
    target_prefix = "logs/"
  }
}

Why: the conditional single-element list renders exactly one block when the value exists and none when it is null — the canonical “optional sub-block” idiom. </details>

4. Two validation rules, two messages (intermediate). Give a name variable one rule for format (starts with a letter; lowercase letters, digits, hyphens only) and a separate rule for length (3–24 characters), each with its own error_message.

<details><summary>Solution</summary>

variable "name" {
  type = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]*$", var.name))
    error_message = "name must start with a letter and contain only lowercase letters, digits, and hyphens."
  }

  validation {
    condition     = length(var.name) >= 3 && length(var.name) <= 24
    error_message = "name must be between 3 and 24 characters."
  }
}

Why: two rules, two messages — Terraform reports every failing rule, so a caller sees exactly what is wrong rather than one error at a time. </details>

5. Cross-variable validation (advanced, 1.9+). Given var.min_size and var.max_size, reject any input where min_size > max_size, using a validation block (not a precondition).

<details><summary>Solution</summary>

variable "min_size" {
  type = number
}

variable "max_size" {
  type = number

  validation {
    condition     = var.max_size >= var.min_size
    error_message = "max_size must be greater than or equal to min_size."
  }
}

Why: since Terraform 1.9 a validation condition may read other variables, so this ordering invariant is enforced at input parse time, before any resource is planned. </details>

6. Flatten a nested map and guard the keys (advanced). Given var.vpcs typed map(object({ subnets = list(string) })), build a for_each-ready map keyed "vpc:subnet", and add an output precondition that fails if two composite keys collide.

<details><summary>Solution</summary>

variable "vpcs" {
  type = map(object({
    subnets = list(string)
  }))
}

locals {
  subnet_pairs = flatten([
    for vpc_key, cfg in var.vpcs : [
      for subnet in cfg.subnets : {
        vpc    = vpc_key
        subnet = subnet
      }
    ]
  ])

  subnets = {
    for pair in local.subnet_pairs :
    "${pair.vpc}:${pair.subnet}" => pair
  }
}

output "subnet_keys" {
  value = keys(local.subnets)

  precondition {
    condition = length(local.subnets) == length(distinct([
      for p in local.subnet_pairs : "${p.vpc}:${p.subnet}"
    ]))
    error_message = "Duplicate vpc:subnet pair detected; composite keys must be unique."
  }
}

Why: flatten collapses the nested loop into a flat list, the composite string key is stable and identity-based, and the output precondition catches a silent key collision before it becomes a plan-time “duplicate key” error. </details>

Verify

Validate the type system, validation rules, and lifecycle checks before you trust the module. These commands are non-destructive.

# 1. Syntax and internal consistency, including type/validation wiring.
terraform init -backend=false
terraform validate

# 2. Canonical formatting (catches dynamic/for indentation drift).
terraform fmt -recursive -check -diff

# 3. Prove validation/precondition messages fire on bad input.
#    Expect a non-zero exit and your custom error_message.
terraform plan -var 'environment=production'   # not in [dev,staging,prod]

# 4. Inspect the resolved type structure of an input, defaults included.
echo 'var.buckets' | terraform console

Use terraform console to confirm optional() defaults materialize as expected; type an input expression and watch Terraform fill the missing attributes. To prove the count-vs-for_each addressing, run terraform state list after an apply and confirm resources are addressed by key (["raw"]), not index ([0]). For the full module surface, a native test file asserts both happy-path planning and that invalid inputs fail:

# tests/validation.tftest.hcl
run "rejects_bad_environment" {
  command = plan

  variables {
    environment = "production"
    buckets     = {}
  }

  expect_failures = [var.environment]
}

Run it with terraform test. The expect_failures list turns a failing validation into a passing test, which is how you regression-guard your guardrails.

Checklist

Glossary

terraformhcldynamic-blocksvalidationmodule-design
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments