Table of contents
- Creating the Same Resource Multiple Times
- count — Iterate by Number
- for_each — Iterate by Key
- count vs for_each — Selection Criteria
- dynamic Blocks — Iterating Nested Blocks
- Conditional Expressions
- for Expressions — List/Map Transformations
- Conditional Resource Creation Patterns
- A Few Practical Patterns
Creating the Same Resource Multiple Times
Three subnets, five security group rules, ten IAM users. When writing infrastructure code, there are plenty of resources you need to create repeatedly. You could copy-paste, but Terraform provides a more elegant approach.
There are two main meta-arguments for iteration: count and for_each. Knowing which to use in which situation is the core of this part.
flowchart LR
Need["Need iteration"] --> Q{"Is there a\nunique key to\nidentify items?"}
Q -->|"No (just a count)"| Count["Use count"]
Q -->|"Yes (names, IDs, etc.)"| ForEach["Use for_each"]
Count -.-> CountCase["e.g., 3 identical EC2s"]
ForEach -.-> ForEachCase["e.g., per-user IAM,\nper-region S3 buckets"]
count — Iterate by Number
count is the simplest form of iteration. Give it a number, and it creates that many resources.
resource "aws_instance" "worker" {
count = 3
ami = "ami-0c9c942bd7bf113a2"
instance_type = "t3.micro"
tags = {
Name = "worker-${count.index}"
}
}
count.index references the current iteration number (starting from 0). The code above creates three instances: worker-0, worker-1, worker-2.
References also use indices.
resource "aws_eip" "worker_ip" {
count = 3
instance = aws_instance.worker[count.index].id
}
# Reference the first instance from elsewhere
output "first_worker_ip" {
value = aws_instance.worker[0].private_ip
}
# Reference all (splat)
output "all_worker_ips" {
value = aws_instance.worker[*].private_ip
}
[*] is the splat expression. It extracts a specific attribute from all instances as a list.
The Pitfall of count
count is convenient but has one critical pitfall. Removing a middle item causes everything after it to be recreated.
# Initially: 3 subnets
resource "aws_subnet" "app" {
count = 3
cidr_block = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"][count.index]
}
# Want to remove the middle CIDR
# cidr_block = ["10.0.1.0/24", "10.0.3.0/24"]
# count = 2
When you make this change, from Terraform’s perspective, index 1 (previously 10.0.2.0/24) becomes 10.0.3.0/24, and index 2 disappears. The actual intent was “remove the middle one,” but Terraform recreates two resources. If these are production subnets, it’s catastrophic.
count is only safe to use when all instances are completely identical and you’ll never need to remove one from the middle. It’s fine if you’re only ever adding instances, never removing them.
for_each — Iterate by Key
Because of these limitations, most production code uses for_each. Each iteration instance gets a unique key, so items with the same key are recognized as the same instance.
for_each accepts a map or set.
Iterating with a map
resource "aws_subnet" "app" {
for_each = {
a = "10.0.1.0/24"
b = "10.0.2.0/24"
c = "10.0.3.0/24"
}
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = "ap-northeast-2${each.key}"
tags = {
Name = "subnet-${each.key}"
}
}
each.key is the map key (“a”, “b”, “c”), and each.value is the value (“10.0.1.0/24”, etc.).
Even if you remove b from the middle, a and c remain untouched. Terraform manages them as aws_subnet.app["a"], aws_subnet.app["b"], aws_subnet.app["c"], and when b is removed, only that one is deleted.
# Remove b
for_each = {
a = "10.0.1.0/24"
c = "10.0.3.0/24" # c keeps its key
}
# → Only b is deleted, a and c are untouched
Iterating with a set
When values don’t differ individually and you just have a simple list of names, a set is convenient.
variable "user_names" {
default = ["alice", "bob", "carol"]
}
resource "aws_iam_user" "developer" {
for_each = toset(var.user_names)
name = each.value
}
Wrapping a list with toset() turns it into a set. Sets don’t allow duplicates, and each item serves as its own key. each.key and each.value are the same value.
Reference Patterns
count uses index-based references, while for_each uses key-based references.
# Referencing for_each-created resources
resource "aws_route_table_association" "app" {
for_each = aws_subnet.app
subnet_id = each.value.id
route_table_id = aws_route_table.main.id
}
# Access by specific key
output "zone_a_subnet_id" {
value = aws_subnet.app["a"].id
}
# Access as a full map
output "all_subnet_ids" {
value = { for k, v in aws_subnet.app : k => v.id }
}
count vs for_each — Selection Criteria
The choice between these two meta-arguments boils down to “will I need to add or remove items later?”
| Situation | Recommendation |
|---|---|
| Items are completely identical, only the count matters (e.g., N identical workers) | count |
| Items have different configurations (e.g., per-environment, per-user) | for_each |
| Might need to remove items from the middle | for_each |
| Want to conditionally create 0 or 1 instances | count = var.enabled ? 1 : 0 |
Using count for conditional creation is an idiomatic pattern.
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0
ami = "ami-xxx"
instance_type = "t3.nano"
}
output "bastion_ip" {
value = var.create_bastion ? aws_instance.bastion[0].public_ip : null
}
In cases like this, count is more natural than for_each.
dynamic Blocks — Iterating Nested Blocks
Sometimes you need to repeatedly create nested blocks within a resource. Security group ingress rules are a classic example.
# Writing statically
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
}
When there are many rules, it gets repetitive. Using a dynamic block lets you create rules dynamically from data.
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{ from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ from_port = 22, to_port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}
dynamic "ingress" means “dynamically generate nested blocks named ingress.” The contents of a single block go inside content, and ingress.value references the current iteration item.
Avoid Overusing dynamic
dynamic is powerful, but using it more than necessary hurts readability. If there are only about 3 rules, writing them explicitly is more readable. Use it only when rules change dynamically or when there are many.
flowchart TB
Start["Repeat nested blocks?"] --> Few{"Few and\nfixed?"}
Few -->|"Yes (2-3)"| Plain["Just write them out"]
Few -->|"No"| Dyn["Use dynamic"]
Plain -.-> Reason1["Easy to read"]
Dyn -.-> Reason2["Data-driven\nflexible management"]
Conditional Expressions
Terraform’s conditional expressions use ternary operator syntax.
locals {
instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
# Nesting is possible (but readability suffers)
size = var.env == "prod" ? "large" : var.env == "staging" ? "medium" : "small"
}
When nesting gets deep, using lookup() or a map is better.
locals {
instance_sizes = {
dev = "t3.micro"
staging = "t3.medium"
prod = "m5.large"
}
instance_type = local.instance_sizes[var.environment]
}
for Expressions — List/Map Transformations
The most frequently used powerful feature. Transform lists to maps, maps to lists, and filter — all in a single line.
List to List (transformation)
variable "names" {
default = ["alice", "bob", "carol"]
}
locals {
upper_names = [for name in var.names : upper(name)]
# ["ALICE", "BOB", "CAROL"]
}
List to Map
locals {
name_map = { for name in var.names : name => length(name) }
# { alice = 5, bob = 3, carol = 5 }
}
Filtering
locals {
short_names = [for name in var.names : name if length(name) < 5]
# ["bob"]
}
Object List to Map (for for_each conversion)
variable "users" {
default = [
{ name = "alice", role = "admin" },
{ name = "bob", role = "dev" },
]
}
locals {
users_map = { for u in var.users : u.name => u }
}
resource "aws_iam_user" "dev" {
for_each = local.users_map
name = each.value.name
tags = {
Role = each.value.role
}
}
This pattern is useful when the input is in list form but you need to pass it to for_each. Lists have ordering, but for_each requires unique keys, so you convert to a map.
Conditional Resource Creation Patterns
Patterns for enabling backups only in production or toggling optional features.
count for 0 or 1
variable "enable_monitoring" {
type = bool
default = false
}
resource "aws_cloudwatch_metric_alarm" "cpu" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "cpu-high"
metric_name = "CPUUtilization"
# ...
}
for_each with a conditional map
locals {
monitoring_targets = var.enable_monitoring ? {
cpu = "CPUUtilization"
memory = "MemoryUtilization"
} : {}
}
resource "aws_cloudwatch_metric_alarm" "metrics" {
for_each = local.monitoring_targets
alarm_name = "${each.key}-high"
metric_name = each.value
# ...
}
This is cleaner when conditionally creating multiple resources.
A Few Practical Patterns
Pattern 1: Apply different settings per environment
locals {
env_config = {
dev = {
instance_count = 1
instance_type = "t3.micro"
multi_az = false
}
staging = {
instance_count = 2
instance_type = "t3.medium"
multi_az = false
}
prod = {
instance_count = 3
instance_type = "m5.large"
multi_az = true
}
}
current = local.env_config[var.environment]
}
resource "aws_instance" "app" {
count = local.current.instance_count
instance_type = local.current.instance_type
}
Centralize per-environment differences in one place and reference them.
Pattern 2: Create combinations with nested for
variable "regions" {
default = ["us-east-1", "us-west-2"]
}
variable "environments" {
default = ["dev", "prod"]
}
locals {
region_env_pairs = {
for pair in setproduct(var.regions, var.environments) :
"${pair[0]}-${pair[1]}" => {
region = pair[0]
environment = pair[1]
}
}
}
# 4 combinations: us-east-1-dev, us-east-1-prod, us-west-2-dev, us-west-2-prod
setproduct creates all combinations of two lists, and we assign unique keys to form a map. Useful for creating buckets per region per environment.
Loops and conditionals dramatically expand Terraform code’s expressiveness. Just remember the key principles: count when only the quantity of identical items differs, for_each when items have identity, dynamic for nested block iteration. And for expressions are the Swiss Army knife of data structure transformation.
In the next part, we’ll cover workspaces and environment separation. We’ll look at how to split dev/staging/prod and why Terragrunt might be needed.


Loading comments...