Terraform Cert Guide Chapter 4 Part 2: Loops, Functions, and Debugging
The second half of Chapter 4 in Ravi Mishra’s Terraform certification guide covers three things that will make your Terraform code way less repetitive and way more debuggable: loops, built-in functions, and debugging tools.
If Part 1 was about where your state lives and how provisioners work, Part 2 is about writing smarter code. Let’s get into it.
Terraform Loops: Stop Copy-Pasting Resources
Imagine your coworker Mark says “we need 10 subnets with security groups.” You could write the same resource block 10 times, or you could use loops. Terraform gives you three ways to iterate:
- count for looping over resources
- for_each for looping over resources and inline blocks
- for for transforming lists and maps
The count Expression
The simplest loop. You slap count = 3 on a resource block and Terraform creates three of them.
resource "azurerm_resource_group" "example" {
count = 3
name = "Terraform-rg${count.index}"
location = "West Europe"
}
This gives you Terraform-rg0, Terraform-rg1, Terraform-rg2. Not the prettiest names, so you can feed it a list instead:
variable "rg_names" {
type = list(string)
default = ["Azure-rg", "AWS-rg", "Google-rg"]
}
resource "azurerm_resource_group" "example" {
count = length(var.rg_names)
name = var.rg_names[count.index]
location = "West Europe"
}
Now length() figures out how many items are in your list and count.index grabs the right name. Clean.
One thing to know: once you use count, your resource becomes a list. To reference a specific one, you use the index: azurerm_resource_group.example[0].id. Want all IDs? Use the splat expression: azurerm_resource_group.example[*].id.
But count has a nasty gotcha. Terraform identifies count-based resources by their position in the array. So if you have three resource groups and you remove the middle one (“AWS-rg”), Terraform doesn’t just delete that one. It sees that index 1 changed from “AWS-rg” to “Google-rg” and index 2 disappeared. So it destroys and recreates “Google-rg” and deletes the old index 2. That is almost certainly not what you wanted.
The book explains this really well: when you remove an item from the middle of a list, everything after it shifts down by one index. Terraform thinks the items changed, not that one was removed. This is the main reason count usage is declining.
The other limitation: you can’t use count to loop over inline blocks inside a resource. For example, you can’t loop subnets inside a virtual network using count.
The for_each Expression
This is the fix for count’s problems. Instead of tracking resources by index, for_each tracks them by key. It works with sets of strings or maps.
resource "azurerm_resource_group" "example" {
for_each = toset(var.rg_names)
name = each.value
location = "West Europe"
}
Notice the toset() function. for_each needs a set or map, not a list, so you convert it. Then each.value gives you the current item’s value and each.key gives you the key (for maps with key-value pairs).
The big difference: your resource is now a map, not a list. Terraform tracks azurerm_resource_group.example["AWS-rg"] instead of azurerm_resource_group.example[1]. So when you remove “AWS-rg” from your variable list, Terraform only destroys that one resource group. It leaves “Azure-rg” and “Google-rg” completely alone. Problem solved.
Dynamic blocks for inline loops. This is something count simply can’t do. Say you want multiple subnets inside a virtual network:
resource "azurerm_virtual_network" "vnet" {
name = var.vnet_name
location = azurerm_resource_group.example.location
resource_group_name = azurerm_resource_group.example.name
address_space = var.address_space
dynamic "subnet" {
for_each = var.subnet_names
content {
name = subnet.value.name
address_prefix = subnet.value.address_prefix
}
}
}
The dynamic block tells Terraform “generate a subnet block for each item in my variable.” The label after dynamic (in this case “subnet”) becomes the iterator name you use inside content. Feed it a map of subnet configs and you get multiple subnets without repeating yourself.
The for Expression
This one is for transforming data, not creating resources. Think of it like map() in JavaScript or list comprehensions in Python.
For lists:
variable "cloud" {
type = list(string)
default = ["azure", "aws", "gcp"]
}
output "cloud_names" {
value = [for cloud_name in var.cloud : upper(cloud_name)]
}
# Output: ["AZURE", "AWS", "GCP"]
For maps:
variable "cloud_map" {
type = map(string)
default = {
Azure = "Microsoft"
AWS = "Amazon"
GCP = "Google"
}
}
output "cloud_mapping" {
value = [for cloud_name, company in var.cloud_map : "${cloud_name} cloud is founded by ${company}"]
}
# Output: ["AWS cloud is founded by Amazon", "Azure cloud is founded by Microsoft", "GCP cloud is founded by Google"]
The syntax is [for ITEM in LIST : OUTPUT] for lists and [for KEY, VALUE in MAP : OUTPUT] for maps. Simple and readable once you get used to it.
Terraform Built-in Functions
Terraform has a bunch of built-in functions organized into categories: numeric, string, collection, encoding, filesystem, date/time, hash/crypto, IP network, and type conversion. You won’t memorize them all, but you should know how they work.
The format is straightforward: function(arguments). Examples:
max(5, 10, 3)returns10lower("HELLO")returns"hello"length(["a", "b", "c"])returns3
You can also chain them: abs(max(-1, -10, -5)) first finds the max (-1) then takes the absolute value (1).
The book highlights the cidrsubnet() function as a practical example. If your VNet uses 10.0.0.0/16, you can calculate subnet addresses automatically:
subnet {
name = "subnet1"
address_prefix = cidrsubnet(var.address_space[0], 8, 1)
# Result: 10.0.1.0/24
}
No manual CIDR math. The function handles it.
Testing Functions with terraform console
This is one of those tools that people don’t use enough. Run terraform console in your project directory and you get an interactive shell where you can test any function or expression.
$ terraform console
> var.address_space[0]
10.0.0.0/16
> cidrsubnet(var.address_space[0], 8, 1)
10.0.1.0/24
It loads your current state and configuration so you can reference real variables and resources. And it never modifies your state file. It’s purely read-only.
Before you put a complex function in your config, test it in the console first. Saves a lot of plan/apply cycles.
Debugging Terraform
Things break. When they do, Terraform gives you logging via the TF_LOG environment variable. Set it to any of these levels: TRACE, DEBUG, INFO, WARN, or ERROR.
export TF_LOG=TRACE
TRACE is the most verbose and the one HashiCorp recommends for troubleshooting. Want to save logs to a file? Set TF_LOG_PATH:
export TF_LOG_PATH=./terraform.log
Both variables need to be set for file logging to work. TF_LOG enables the logging, TF_LOG_PATH tells it where to write.
When Terraform Crashes
If Terraform crashes hard (a real panic, not just an error), it writes a crash.log file with debug logs, the panic message, and a stack trace. The book shows an example that looks like typical Go panic output:
panic: runtime error: invalid memory address or nil pointer dereference
As an end user, this log is mostly useful for filing a GitHub issue at github.com/hashicorp/terraform/issues. But if you’re curious, the first couple of lines in the stack trace usually tell you which provider method failed and which line of code caused it.
One important note the book mentions: when you upload crash logs to GitHub, make sure they don’t contain secrets or credentials. Terraform logs can include sensitive values if you’re not careful.
Key Takeaways for the Cert Exam
- count uses index-based tracking, which causes destroy/recreate issues when removing middle items from a list
- for_each uses key-based tracking, solving count’s reordering problem
- for_each with
dynamicblocks lets you loop inline blocks (like subnets inside a VNet), which count cannot do - for expressions transform lists and maps, similar to comprehensions in other languages
terraform consoleis read-only and safe to use for testing functionsTF_LOGenables logging,TF_LOG_PATHsaves logs to a fileTRACEis the default and recommended log level- Crash logs go to
crash.logautomatically
The loops section alone is worth spending time on. Understanding when to use count vs for_each is one of those things that separates people who write Terraform from people who write good Terraform.
Previous: Chapter 4 Part 1: Backends and Provisioners
Next: Chapter 5: Terraform CLI
This is part of a series retelling “HashiCorp Infrastructure Automation Certification Guide” by Ravi Mishra (Packt, 2021). For the full text, grab the book - ISBN: 978-1-80056-597-5.