Advanced Terraform: Loops, Functions & Dynamic Blocks
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:
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:
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:
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}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}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:
| Aspect | count | for_each |
|---|---|---|
| Indexed by | Integer (0, 1, 2…) | String key |
| Input type | Number | Map or set |
| Reference syntax | resource[0] | resource["key"] |
| Remove middle item | Shifts all indexes → replaces all subsequent resources | Removes only that key → no side effects |
| Best for | N identical resources | Resources with distinct identities |
| Use with sets? | Not directly | Yes — use toset() |
each.key available? | No (use count.index) | Yes |
The danger of count with removal:
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 untouched4. for Expressions — Transforming Collections
for expressions transform lists, maps, and sets inline. They are used in locals, variable defaults, and resource arguments:
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:
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):
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:
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:
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:
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
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") # trueCollection Functions
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") # 1Numeric Functions
1max(3, 1, 4, 1, 5) # 5
2min(3, 1, 4, 1, 5) # 1
3abs(-5) # 5
4ceil(1.2) # 2
5floor(1.8) # 1Encoding & Hashing Functions
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 stringFilesystem Functions
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
1tostring(42) # "42"
2tonumber("42") # 42
3tobool("true") # true
4toset([...]) # convert list to set
5tolist({...}) # convert set to list
6tomap([...]) # convert to mapIP & CIDR Functions
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:
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
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
| Feature | Syntax | Notes |
|---|---|---|
count | count = N | Integer repetition; count.index is 0-based |
for_each map | for_each = map | each.key, each.value |
for_each set | for_each = toset(list) | each.key == each.value |
| Remove with count | Shifts indexes | Can cause unexpected replacements |
| Remove with for_each | Only removes that key | Safe removal |
| Splat | resource[*].attr | All 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 default | Block label name | Override with iterator = name |
dynamic content value | iterator.value or iterator.value.attr | |
| Ternary | cond ? a : b | Conditional value |
lookup(map, key, default) | Retrieve map value safely | Default if key missing |
merge(m1, m2) | Merge maps; later maps win | Used for tag merging |
flatten(list_of_lists) | Single flat list | Use after nested for |
zipmap(keys, vals) | Build map from two lists | |
cidrsubnet(cidr, bits, n) | Compute subnet CIDR | n is subnet index |
templatefile(path, vars) | Render template with substitutions | Preferred for user_data |
filemd5(path) | Hash file for triggers | Force re-run on file change |
Practice Questions19
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.
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.
Q3. What does `terraform.workspace == "prod" ? "t3.large" : "t3.micro"` demonstrate in Terraform?
Select one answer before revealing.
Q4. What does the `lifecycle` block option `create_before_destroy = true` do?
Select one answer before revealing.
Q5. What is the purpose of `ignore_changes` in a Terraform `lifecycle` block?
Select one answer before revealing.
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.
Q7. Which Terraform function converts a list to a set (removing duplicates and losing order)?
Select one answer before revealing.
Q8. What is the `templatefile()` function used for in Terraform?
Select one answer before revealing.
Q9. What does the `for` expression `[for s in var.subnet_ids : upper(s)]` produce?
Select one answer before revealing.
Q10. What is the `merge()` function used for in Terraform?
Select one answer before revealing.
Q11. What does `cidrsubnet("10.0.0.0/16", 8, 1)` return?
Select one answer before revealing.
Q12. What does the `jsonencode()` function do in Terraform?
Select one answer before revealing.
Q13. What does the `file()` function do in Terraform?
Select one answer before revealing.
Q14. What is the `flatten()` function used for in Terraform?
Select one answer before revealing.
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.
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.
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.
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.
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.