/Advanced Terraform: Loops, Functions & Dynamic Blocks
Concept
Hard

Advanced Terraform: Loops, Functions & Dynamic Blocks

12 min read·countfor-eachfor-expressionsdynamic-blocksfunctionssplatconditionalloopstemplatefileflattenterraform-associate

Advanced HCL features — count, for_each, for expressions, dynamic blocks, conditional expressions, splat operators, and built-in functions — are the tools that eliminate repetition and make configurations scale. The Terraform Associate exam tests all of these patterns, especially the count vs for_each tradeoff, for expression syntax, dynamic block iterator naming, and the most commonly used built-in functions.


1. count — Integer-Based Repetition

count creates N identical (or near-identical) copies of a resource, indexed by integer:

hcl
1# Create 3 identical EC2 instances
2resource "aws_instance" "web" {
3  count         = 3
4  ami           = data.aws_ami.ubuntu.id
5  instance_type = "t3.micro"
6
7  tags = {
8    Name = "web-server-${count.index}"   # 0, 1, 2
9  }
10}
11
12# Reference by index
13output "first_instance_ip" {
14  value = aws_instance.web[0].public_ip
15}
16
17# All instance IDs (splat expression)
18output "all_instance_ids" {
19  value = aws_instance.web[*].id
20}
21
22# Conditional resource (0 = don't create, 1 = create)
23resource "aws_cloudwatch_log_group" "app" {
24  count = var.enable_logging ? 1 : 0
25  name  = "/app/${var.environment}"
26}
27
28# Reference a conditional resource safely
29output "log_group_arn" {
30  value = var.enable_logging ? aws_cloudwatch_log_group.app[0].arn : null
31}

count.index is zero-based. Use it for naming, subnet selection, or AZ distribution:

hcl
1resource "aws_subnet" "private" {
2  count             = length(var.availability_zones)
3  vpc_id            = aws_vpc.main.id
4  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
5  availability_zone = var.availability_zones[count.index]
6
7  tags = { Name = "private-${var.availability_zones[count.index]}" }
8}

2. for_each — Key-Based Repetition

for_each creates one resource per element in a map or set, indexed by key:

hcl
1# for_each with a map (key = name, value = instance type)
2variable "servers" {
3  type = map(string)
4  default = {
5    web = "t3.micro"
6    app = "t3.small"
7    db  = "t3.medium"
8  }
9}
10
11resource "aws_instance" "servers" {
12  for_each      = var.servers
13  ami           = data.aws_ami.ubuntu.id
14  instance_type = each.value   # "t3.micro", "t3.small", "t3.medium"
15
16  tags = {
17    Name = "${each.key}-server"   # "web-server", "app-server", "db-server"
18    Role = each.key
19  }
20}
21
22# Reference by key
23output "web_server_ip" {
24  value = aws_instance.servers["web"].public_ip
25}
26
27# All IPs as map
28output "all_ips" {
29  value = { for k, v in aws_instance.servers : k => v.public_ip }
30}
hcl
1# for_each with a set (use toset() to convert list → set)
2variable "environments" {
3  type    = list(string)
4  default = ["dev", "staging", "prod"]
5}
6
7resource "aws_s3_bucket" "env_buckets" {
8  for_each = toset(var.environments)
9  bucket   = "my-app-${each.key}-data"   # each.key == each.value for sets
10
11  tags = { Environment = each.key }
12}
hcl
1# for_each with objects (complex per-resource config)
2variable "security_groups" {
3  type = map(object({
4    description = string
5    ports       = list(number)
6  }))
7  default = {
8    web = { description = "Web tier", ports = [80, 443] }
9    app = { description = "App tier", ports = [8080, 8443] }
10  }
11}
12
13resource "aws_security_group" "tiers" {
14  for_each    = var.security_groups
15  name        = "${each.key}-sg"
16  description = each.value.description
17
18  dynamic "ingress" {
19    for_each = each.value.ports
20    content {
21      from_port   = ingress.value
22      to_port     = ingress.value
23      protocol    = "tcp"
24      cidr_blocks = ["10.0.0.0/8"]
25    }
26  }
27}

3. count vs for_each — The Critical Tradeoff

This is one of the most tested topics on the Terraform Associate exam:

Aspectcountfor_each
Indexed byInteger (0, 1, 2…)String key
Input typeNumberMap or set
Reference syntaxresource[0]resource["key"]
Remove middle itemShifts all indexes → replaces all subsequent resourcesRemoves only that key → no side effects
Best forN identical resourcesResources with distinct identities
Use with sets?Not directlyYes — use toset()
each.key available?No (use count.index)Yes

The danger of count with removal:

hcl
1# BEFORE: count = 3 → web[0], web[1], web[2]
2# Remove "staging" from the middle
3# AFTER: count = 2 → web[0] and web[1]
4# Terraform plans: destroy web[2], UPDATE web[1] (was staging config)
5# This causes an unexpected replacement!
6
7# SAFER with for_each:
8# for_each = { dev = ..., prod = ... }
9# Remove "staging" key → only staging is destroyed, dev and prod untouched

4. for Expressions — Transforming Collections

for expressions transform lists, maps, and sets inline. They are used in locals, variable defaults, and resource arguments:

hcl
1# List comprehension — transform each element
2locals {
3  # [for VAR in COLLECTION : EXPRESSION]
4  upper_names   = [for name in var.names : upper(name)]
5  name_lengths  = [for name in var.names : length(name)]
6  prefixed      = [for name in var.names : "app-${name}"]
7}
8
9# Map comprehension — build a map
10locals {
11  # {for VAR in COLLECTION : KEY => VALUE}
12  name_to_ip = { for server in var.servers : server.name => server.ip }
13  id_to_arn  = { for k, v in aws_iam_role.roles : k => v.arn }
14}
15
16# Filtering — add "if" condition
17locals {
18  # [for VAR in COLLECTION : EXPRESSION if CONDITION]
19  active_servers  = [for s in var.servers : s.name if s.enabled]
20  prod_ips        = [for s in var.servers : s.ip if s.environment == "prod"]
21  non_default_sgs = [for sg in aws_security_group.all : sg.id if sg.name != "default"]
22}
23
24# Iterating over a map — use key and value
25locals {
26  # {for KEY, VALUE in MAP : ...}
27  env_urls = { for env, config in var.environments : env => "https://${config.domain}" }
28  tag_list = [for k, v in var.tags : "${k}=${v}"]
29}
30
31# Nested for expression
32locals {
33  all_cidr_pairs = [
34    for vpc in var.vpcs : [
35      for subnet in vpc.subnets : "${vpc.name}/${subnet}"
36    ]
37  ]
38  # Use flatten() to get a single list:
39  all_cidrs_flat = flatten([
40    for vpc in var.vpcs : [for subnet in vpc.subnets : subnet]
41  ])
42}

5. Splat Expressions

Splat expressions are shorthand for extracting a single attribute from all instances of a count resource:

hcl
1# Legacy splat (works with count resources)
2output "all_instance_ids" {
3  value = aws_instance.web[*].id          # [id0, id1, id2]
4}
5
6output "all_public_ips" {
7  value = aws_instance.web[*].public_ip   # [ip0, ip1, ip2]
8}
9
10# Full for expression (works with for_each resources too)
11output "all_instance_ids" {
12  value = [for i in aws_instance.web : i.id]          # list syntax
13}
14
15output "name_to_id_map" {
16  value = { for k, v in aws_instance.servers : k => v.id }  # map syntax
17}
18
19# Splat on a nested attribute
20output "all_subnet_ids" {
21  value = aws_subnet.private[*].id
22}

6. dynamic Blocks — Programmatic Nested Blocks

dynamic blocks generate repeated nested blocks from a collection — the equivalent of a for loop for blocks (not arguments):

hcl
1# Without dynamic (repetitive):
2resource "aws_security_group" "web" {
3  ingress { from_port = 80;  to_port = 80;  protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
4  ingress { from_port = 443; to_port = 443; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
5  ingress { from_port = 22;  to_port = 22;  protocol = "tcp"; cidr_blocks = ["10.0.0.0/8"] }
6}
7
8# With dynamic (DRY):
9variable "ingress_rules" {
10  type = list(object({
11    port        = number
12    cidr_blocks = list(string)
13  }))
14  default = [
15    { port = 80,  cidr_blocks = ["0.0.0.0/0"] },
16    { port = 443, cidr_blocks = ["0.0.0.0/0"] },
17    { port = 22,  cidr_blocks = ["10.0.0.0/8"] },
18  ]
19}
20
21resource "aws_security_group" "web" {
22  name = "web-sg"
23
24  dynamic "ingress" {
25    for_each = var.ingress_rules      # iterate over the list
26    iterator = rule                   # optional: rename iterator (default = block label)
27
28    content {
29      from_port   = rule.value.port
30      to_port     = rule.value.port
31      protocol    = "tcp"
32      cidr_blocks = rule.value.cidr_blocks
33    }
34  }
35
36  egress {
37    from_port   = 0
38    to_port     = 0
39    protocol    = "-1"
40    cidr_blocks = ["0.0.0.0/0"]
41  }
42}

dynamic block anatomy:

hcl
1dynamic "BLOCK_TYPE" {
2  for_each = COLLECTION        # required — map, set, or list
3  iterator = CUSTOM_NAME       # optional — default is BLOCK_TYPE
4
5  content {
6    # Use CUSTOM_NAME.key   (for maps)
7    # Use CUSTOM_NAME.value (for all)
8    # Use CUSTOM_NAME.value.attr (for objects)
9  }
10}

Real-world pattern — EBS volumes:

hcl
1resource "aws_instance" "data" {
2  ami           = data.aws_ami.ubuntu.id
3  instance_type = "t3.large"
4
5  dynamic "ebs_block_device" {
6    for_each = var.ebs_volumes   # list of objects
7    content {
8      device_name = ebs_block_device.value.device_name
9      volume_size = ebs_block_device.value.size_gb
10      volume_type = ebs_block_device.value.type
11      encrypted   = true
12    }
13  }
14}

7. Conditional Expressions

The ternary operator condition ? true_val : false_val is used everywhere:

hcl
1# Resource argument
2instance_type = var.environment == "prod" ? "t3.medium" : "t3.micro"
3
4# Resource count (enable/disable)
5count = var.create_bastion ? 1 : 0
6
7# Null coalescing pattern
8subnet_id = var.subnet_id != null ? var.subnet_id : aws_subnet.default.id
9
10# Nested ternary (use locals for readability)
11locals {
12  db_class = (
13    var.environment == "prod"    ? "db.r5.large"  :
14    var.environment == "staging" ? "db.t3.medium" :
15    "db.t3.micro"
16  )
17}
18
19# Conditional in for expression
20locals {
21  enabled_features = [for f in var.features : f.name if f.enabled]
22}

8. Built-in Functions — Complete Reference

Terraform has 100+ built-in functions. These are the exam-relevant ones:

String Functions

hcl
1upper("hello")              # "HELLO"
2lower("HELLO")              # "hello"
3title("hello world")        # "Hello World"
4trimspace("  hello  ")      # "hello"
5trim("xxhelloxx", "x")      # "hello"
6replace("hello", "l", "r")  # "herro"
7substr("hello", 1, 3)       # "ell" (offset, length)
8format("%-10s %s", "hello", "world")  # formatted string
9formatlist("item-%d", [1,2,3])        # ["item-1","item-2","item-3"]
10split(",", "a,b,c")         # ["a","b","c"]
11join(",", ["a","b","c"])     # "a,b,c"
12startswith("hello", "he")   # true
13endswith("hello", "lo")     # true
14contains(["a","b"], "a")    # true

Collection Functions

hcl
1length(["a","b","c"])                    # 3
2length({a=1, b=2})                       # 2
3merge({a=1}, {b=2}, {c=3})              # {a=1, b=2, c=3}
4lookup({a="x", b="y"}, "a", "default")  # "x"
5lookup({a="x"}, "missing", "default")   # "default"
6keys({a=1, b=2})                        # ["a", "b"]
7values({a=1, b=2})                      # [1, 2]
8flatten([[1,2],[3,[4,5]]])              # [1,2,3,4,5]
9toset(["a","b","a"])                    # {"a","b"} (dedup)
10tolist(toset(["b","a"]))               # ["a","b"] (sorted)
11tomap({a=1, b=2})                      # {a=1, b=2}
12zipmap(["a","b"], [1,2])               # {a=1, b=2}
13slice(["a","b","c","d"], 1, 3)         # ["b","c"]
14concat(["a","b"], ["c","d"])           # ["a","b","c","d"]
15distinct(["a","b","a","c"])            # ["a","b","c"]
16sort(["c","a","b"])                    # ["a","b","c"]
17reverse(["a","b","c"])                 # ["c","b","a"]
18element(["a","b","c"], 1)              # "b"
19index(["a","b","c"], "b")             # 1

Numeric Functions

hcl
1max(3, 1, 4, 1, 5)    # 5
2min(3, 1, 4, 1, 5)    # 1
3abs(-5)               # 5
4ceil(1.2)             # 2
5floor(1.8)            # 1

Encoding & Hashing Functions

hcl
1base64encode("hello")                   # "aGVsbG8="
2base64decode("aGVsbG8=")               # "hello"
3jsonencode({name = "web", port = 80})   # '{"name":"web","port":80}'
4jsondecode('{"a":1}')                  # {a = 1}
5yamlencode({key = "val"})              # "key: val
6"
7yamldecode("key: val")                 # {key = "val"}
8md5("hello")                           # hash string
9sha256("hello")                        # hash string

Filesystem Functions

hcl
1file("${path.module}/scripts/init.sh")              # read file as string
2filebase64("${path.module}/certs/ca.crt")           # read as base64
3filemd5("${path.module}/scripts/init.sh")           # MD5 hash of file
4filesha256("${path.module}/scripts/init.sh")        # SHA256 hash of file
5templatefile("${path.module}/templates/user.tpl", { # render template
6  name = var.username
7  env  = var.environment
8})

Type Conversion Functions

hcl
1tostring(42)       # "42"
2tonumber("42")     # 42
3tobool("true")     # true
4toset([...])       # convert list to set
5tolist({...})      # convert set to list
6tomap([...])       # convert to map

IP & CIDR Functions

hcl
1cidrsubnet("10.0.0.0/16", 8, 0)   # "10.0.0.0/24" (newbits=8, netnum=0)
2cidrsubnet("10.0.0.0/16", 8, 1)   # "10.0.1.0/24"
3cidrhost("10.0.0.0/24", 5)        # "10.0.0.5"
4cidrnetmask("10.0.0.0/24")        # "255.255.255.0"

9. templatefile() — Dynamic File Generation

templatefile() renders a template file with variable substitutions — the preferred way to generate user_data, config files, and scripts:

hcl
1# Template file: templates/user_data.sh.tpl
2# #!/bin/bash
3# export APP_ENV="${environment}"
4# export DB_HOST="${db_host}"
5# export DB_PORT="${db_port}"
6# apt-get update -y
7# %{ for pkg in packages ~}
8# apt-get install -y ${pkg}
9# %{ endfor ~}
10
11resource "aws_instance" "app" {
12  ami       = data.aws_ami.ubuntu.id
13  user_data = templatefile("${path.module}/templates/user_data.sh.tpl", {
14    environment = var.environment
15    db_host     = aws_db_instance.main.endpoint
16    db_port     = 5432
17    packages    = ["nginx", "curl", "jq"]
18  })
19}

Template directives:

${variable} interpolation %{ if condition }...%{ endif } conditional %{ for item in list }...%{ endfor } loop ~ strip whitespace/newline

10. Practical Combination Patterns

hcl
1# Pattern 1: Dynamic security group from variable
2variable "sg_rules" {
3  type = list(object({
4    description = string
5    port        = number
6    protocol    = string
7    cidr        = string
8  }))
9}
10
11resource "aws_security_group" "app" {
12  name = "${var.environment}-app-sg"
13
14  dynamic "ingress" {
15    for_each = var.sg_rules
16    content {
17      description = ingress.value.description
18      from_port   = ingress.value.port
19      to_port     = ingress.value.port
20      protocol    = ingress.value.protocol
21      cidr_blocks = [ingress.value.cidr]
22    }
23  }
24}
25
26# Pattern 2: Multi-AZ subnet creation
27locals {
28  azs     = ["us-east-1a", "us-east-1b", "us-east-1c"]
29  # Generate CIDR blocks for each AZ automatically
30  private_cidrs = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, i)]
31  public_cidrs  = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, i + 100)]
32}
33
34resource "aws_subnet" "private" {
35  count             = length(local.azs)
36  vpc_id            = aws_vpc.main.id
37  cidr_block        = local.private_cidrs[count.index]
38  availability_zone = local.azs[count.index]
39  tags              = { Name = "private-${local.azs[count.index]}" }
40}
41
42# Pattern 3: Tag normalization with merge + for expression
43locals {
44  base_tags = {
45    ManagedBy   = "terraform"
46    Environment = var.environment
47    Project     = var.project
48  }
49  # Convert all tag values to strings (required by AWS tagging)
50  normalized_tags = { for k, v in merge(local.base_tags, var.extra_tags) : k => tostring(v) }
51}
52
53# Pattern 4: Flatten nested lists for resource creation
54locals {
55  # Input: list of environments, each with list of services
56  env_services = [
57    { env = "dev",  services = ["api", "worker"] },
58    { env = "prod", services = ["api", "worker", "scheduler"] },
59  ]
60
61  # Flatten to [{env="dev",service="api"}, {env="dev",service="worker"}, ...]
62  all_services = flatten([
63    for env_cfg in local.env_services : [
64      for svc in env_cfg.services : {
65        env     = env_cfg.env
66        service = svc
67        key     = "${env_cfg.env}-${svc}"
68      }
69    ]
70  ])
71
72  # Convert to a map for for_each
73  services_map = { for s in local.all_services : s.key => s }
74}
75
76resource "aws_ecs_service" "services" {
77  for_each = local.services_map
78  name     = each.value.service
79  tags     = { Environment = each.value.env }
80}

11. Quick Reference

FeatureSyntaxNotes
countcount = NInteger repetition; count.index is 0-based
for_each mapfor_each = mapeach.key, each.value
for_each setfor_each = toset(list)each.key == each.value
Remove with countShifts indexesCan cause unexpected replacements
Remove with for_eachOnly removes that keySafe removal
Splatresource[*].attrAll attributes from count resource
List for expression[for x in c : expr]Transform list
Map for expression{for x in c : k => v}Build map
Filtered for[for x in c : x if cond]Filter collection
dynamic iterator defaultBlock label nameOverride with iterator = name
dynamic content valueiterator.value or iterator.value.attr
Ternarycond ? a : bConditional value
lookup(map, key, default)Retrieve map value safelyDefault if key missing
merge(m1, m2)Merge maps; later maps winUsed for tag merging
flatten(list_of_lists)Single flat listUse after nested for
zipmap(keys, vals)Build map from two lists
cidrsubnet(cidr, bits, n)Compute subnet CIDRn is subnet index
templatefile(path, vars)Render template with substitutionsPreferred for user_data
filemd5(path)Hash file for triggersForce re-run on file change

Practice Questions19

hard

Q1. Consider this Terraform code. What will it create? ```hcl resource "aws_security_group" "web" { name = "web-sg" dynamic "ingress" { for_each = [80, 443] content { from_port = ingress.value to_port = ingress.value protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } } } ```


Select one answer before revealing.

hard

Q2. What does the following Terraform expression evaluate to? ```hcl locals { env_sizes = { dev = "t3.micro" staging = "t3.small" prod = "t3.large" } instance_type = lookup(local.env_sizes, var.environment, "t3.micro") } ``` If `var.environment = "prod"`, what is `local.instance_type`?


Select one answer before revealing.

medium

Q3. What does `terraform.workspace == "prod" ? "t3.large" : "t3.micro"` demonstrate in Terraform?


Select one answer before revealing.

hard

Q4. What does the `lifecycle` block option `create_before_destroy = true` do?


Select one answer before revealing.

hard

Q5. What is the purpose of `ignore_changes` in a Terraform `lifecycle` block?


Select one answer before revealing.

hard

Q6. You have a `for_each` over a map of server configurations. How do you reference the current key and value inside the resource block? ```hcl resource "aws_instance" "servers" { for_each = var.servers # map of { name => instance_type } instance_type = ________ tags = { Name = ________ } } ```


Select one answer before revealing.

medium

Q7. Which Terraform function converts a list to a set (removing duplicates and losing order)?


Select one answer before revealing.

hard

Q8. What is the `templatefile()` function used for in Terraform?


Select one answer before revealing.

hard

Q9. What does the `for` expression `[for s in var.subnet_ids : upper(s)]` produce?


Select one answer before revealing.

medium

Q10. What is the `merge()` function used for in Terraform?


Select one answer before revealing.

hard

Q11. What does `cidrsubnet("10.0.0.0/16", 8, 1)` return?


Select one answer before revealing.

medium

Q12. What does the `jsonencode()` function do in Terraform?


Select one answer before revealing.

easy

Q13. What does the `file()` function do in Terraform?


Select one answer before revealing.

hard

Q14. What is the `flatten()` function used for in Terraform?


Select one answer before revealing.

hard

Q15. You have the following Terraform code. What problem does it solve? ```hcl resource "aws_instance" "blue" { ami = var.ami_id instance_type = "t3.micro" lifecycle { create_before_destroy = true } } resource "aws_lb_target_group_attachment" "blue" { target_group_arn = aws_lb_target_group.main.arn target_id = aws_instance.blue.id } ```


Select one answer before revealing.

hard

Q16. Hands-On: Your colleague left the following code in a module and `terraform plan` keeps showing a diff on the `tags` attribute every single run, even with no config changes. What is the likely cause? ```hcl resource "aws_instance" "app" { ami = data.aws_ami.ubuntu.id instance_type = "t3.micro" tags = { LastDeployed = timestamp() } } ```


Select one answer before revealing.

hard

Q17. Hands-On: Your Terraform code manages an ECS Fargate service. Every `terraform apply` shows a diff on the `desired_count` attribute even though you have not changed it. What is the likely cause and fix? ```hcl resource "aws_ecs_service" "api" { name = "api-service" cluster = aws_ecs_cluster.main.id task_definition = aws_ecs_task_definition.api.arn desired_count = 2 launch_type = "FARGATE" } ```


Select one answer before revealing.

hard

Q18. Hands-On: You need to create an AWS IAM policy document using Terraform. Which approach correctly creates a policy allowing S3 read access? ```hcl # Approach A resource "aws_iam_policy" "s3_read" { policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["s3:GetObject", "s3:ListBucket"] Resource = "*" }] }) } # Approach B data "aws_iam_policy_document" "s3_read" { statement { effect = "Allow" actions = ["s3:GetObject", "s3:ListBucket"] resources = ["*"] } } resource "aws_iam_policy" "s3_read" { policy = data.aws_iam_policy_document.s3_read.json } ```


Select one answer before revealing.

hard

Q19. Hands-On: You need to output all private subnet IDs from the following `for_each` subnet resource as a list. Fill in the correct output value. ```hcl resource "aws_subnet" "private" { for_each = var.availability_zones # set of AZ names vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, index( tolist(var.availability_zones), each.key )) availability_zone = each.key } output "private_subnet_ids" { value = ________ } ```


Select one answer before revealing.