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) returns 10
  • lower("HELLO") returns "hello"
  • length(["a", "b", "c"]) returns 3

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 dynamic blocks 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 console is read-only and safe to use for testing functions
  • TF_LOG enables logging, TF_LOG_PATH saves logs to a file
  • TRACE is the default and recommended log level
  • Crash logs go to crash.log automatically

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.

About

About BookGrill.net

BookGrill.net is a technology book review site for developers, engineers, and anyone who builds things with code. We cover books on software engineering, AI and machine learning, cybersecurity, systems design, and the culture of technology.

Know More