Advanced Patterns
Meta-arguments (count, for_each), dynamic blocks, expressions and functions, lifecycle controls, and workspaces
Advanced Patterns
The features here aren't optional for real-world Terraform — once you're past simple configs, you'll use them daily.
count: Repeat by Number
count creates N instances of a resource:
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = var.availability_zones[count.index]
tags = { Name = "public-${count.index}" }
}
# Reference individual instances or the whole list
output "first_subnet" {
value = aws_subnet.public[0].id
}
output "all_subnet_ids" {
value = aws_subnet.public[*].id # splat expression
}count's Big Caveat
Resources created with count are indexed by position. If you remove an item from the middle of the list, every resource after it shifts and gets recreated:
# Before: count.index 0=us-east-1a, 1=us-east-1b, 2=us-east-1c
# Remove us-east-1b. Now: 0=us-east-1a, 1=us-east-1c
# Terraform: destroy index 1 (1b), recreate index 1 as 1c, destroy index 2Use count only for "give me N identical things" — for anything keyed by name, use for_each.
for_each: Repeat by Key
for_each creates one instance per key in a map or set:
variable "subnets" {
type = map(object({
cidr_block = string
az = string
}))
default = {
"public-1a" = { cidr_block = "10.0.1.0/24", az = "us-east-1a" }
"public-1b" = { cidr_block = "10.0.2.0/24", az = "us-east-1b" }
"private-1a" = { cidr_block = "10.0.3.0/24", az = "us-east-1a" }
}
}
resource "aws_subnet" "this" {
for_each = var.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr_block
availability_zone = each.value.az
tags = { Name = each.key }
}
# Reference one by key
output "public_1a_id" {
value = aws_subnet.this["public-1a"].id
}Removing "private-1a" from the map destroys exactly that subnet — everyone else is untouched. This is the property that makes for_each the default choice for anything with stable identity.
Conditional Resources
Terraform has no if block — express conditionality through count or for_each:
# Create a NAT gateway only in production
resource "aws_nat_gateway" "main" {
count = var.environment == "production" ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
}count = 0 is the idiomatic way to say "don't create this."
Dynamic Blocks
Some resources contain nested blocks (security group rules, IAM policy statements). Use dynamic to generate them from a list:
variable "ingress_rules" {
type = list(object({
description = string
port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{ description = "HTTPS", port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ description = "SSH from bastion", port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/16"] },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}Use sparingly. A dynamic block with three static rules is harder to read than three plain rules.
Expressions & Functions
Terraform has a rich expression language. The ones you'll reach for most:
locals {
# String interpolation
bucket_name = "${var.project}-${var.environment}-logs"
# Conditional
instance_type = var.environment == "production" ? "m5.xlarge" : "t3.medium"
# Try / null handling
region = try(var.region, data.aws_region.current.name, "us-east-1")
# for expression (build a list)
bucket_arns = [for b in aws_s3_bucket.logs : b.arn]
# for expression (build a map)
subnet_by_az = { for s in aws_subnet.public : s.availability_zone => s.id }
# Filtering with for
prod_instances = [for i in aws_instance.app : i.id if i.tags.Environment == "production"]
# Merge maps
tags = merge(
var.default_tags,
{ Name = local.bucket_name },
var.extra_tags,
)
}Functions worth knowing:
| Function | Purpose |
|---|---|
length(list) | Count items |
concat(list1, list2) | Combine lists |
merge(map1, map2) | Combine maps (right wins) |
lookup(map, key, default) | Map access with fallback |
coalesce(a, b, c) | First non-null/non-empty |
format("%s-%d", name, n) | Printf-style |
jsonencode(value) / jsondecode(string) | JSON marshaling |
cidrsubnet(prefix, newbits, netnum) | Carve subnets out of a VPC CIDR |
templatefile("user_data.sh.tpl", { var = val }) | Render a template file |
file("./README.md") | Read a file into a string |
Lifecycle Controls
The lifecycle block changes how Terraform manages a specific resource:
resource "aws_db_instance" "main" {
identifier = "production-db"
instance_class = "db.r6g.xlarge"
allocated_storage = 100
lifecycle {
# Refuse to destroy this resource — even with `terraform destroy`
prevent_destroy = true
# Always create the replacement before destroying the old one
create_before_destroy = true
# Ignore drift on these attributes (don't fight the cloud autoscaler)
ignore_changes = [
allocated_storage, # storage can be scaled up out-of-band
tags["LastBackup"], # set by a Lambda
]
# Cross-attribute validation at plan time
precondition {
condition = var.environment == "production" ? var.allocated_storage >= 100 : true
error_message = "Production DBs require at least 100 GB of storage."
}
}
}When you need each:
| Setting | Use it when |
|---|---|
prevent_destroy | Stateful resources you can never accidentally lose (prod DBs, KMS keys) |
create_before_destroy | Replacing a load balancer / security group; downtime is unacceptable |
ignore_changes | Another system (autoscaler, deploy tool) legitimately mutates the resource |
precondition / postcondition | Cross-cutting invariants you want enforced at plan time |
Workspaces
A workspace is a named state file inside one config directory. Useful for ephemeral environments (preview PRs, dev sandboxes) where you want one codebase to produce many parallel deployments.
terraform workspace new feature-branch-42
terraform workspace list
terraform workspace select feature-branch-42
terraform workspace delete feature-branch-42Reference terraform.workspace in HCL:
resource "aws_s3_bucket" "logs" {
bucket = "myproject-${terraform.workspace}-logs"
}Workspaces are tempting for staging vs. production — don't. A wrong workspace select deploys production code to staging variables (or vice versa). For long-lived environments, use separate directories with separate backends; reserve workspaces for short-lived parallel deployments.
Common Commands Cheat Sheet
# Format and validate
terraform fmt -recursive
terraform validate
# Plan / apply / destroy
terraform plan -out=tfplan
terraform apply tfplan
terraform destroy
# Target a single resource (use sparingly — usually a sign of a state problem)
terraform plan -target=aws_instance.app
# Refresh state from the cloud
terraform refresh
# Show the saved plan or current state
terraform show tfplan
terraform show
# Force a resource to be recreated on next apply
terraform apply -replace=aws_instance.app
# Show / set / inspect outputs
terraform output
terraform output -json | jq
# State surgery
terraform state list
terraform state show aws_instance.app
terraform state mv aws_instance.web aws_instance.app
terraform state rm aws_s3_bucket.logs
terraform import aws_s3_bucket.imported existing-bucket-nameWhat's Next
You can now write Terraform that scales. The last piece is operating it well — project layout, CI/CD, security, testing → Best Practices.