Terraform basics · Lesson 6 of 9 · about 30 minutes

Repeating and choosing: count, for_each and conditionals

How Terraform makes several of something from one block with count and for_each, why removing an item from a count list causes trouble, and how to choose values, or whether something exists at all, with conditional expressions.

By the end you'll know

  • How count makes several copies of a resource, and the trap that comes with it
  • How for_each makes copies with names instead of numbers, and why that's safer
  • How to choose a value with a conditional expression
  • How to make a resource exist only sometimes
  • How a for expression builds a list for an output

Where you are

At the end of Lesson 5, your web server takes its settings from variables. But every resource is still a single thing: one container, one page. Real configurations often need several of something, like a DNS record for each subdomain or a page for each section of a site, and some things only some of the time, like a test server that only exists while you're testing.

Terraform handles both, but not the way most languages do. There's no for statement and no if statement. Instead, repeating and choosing are done with special arguments and expressions, and that's why people find them confusing at first. This lesson takes them one at a time, with a page for each section of your site.

If you did the Python track, here's the map from what you already know:

The ideaIn PythonIn Terraform
Do it once per itema for loopfor_each on a resource
Do it a number of timesa loop with a countercount on a resource
Pick one of two valuesan if and else blockcondition ? this : that
Only sometimesan if with no elsecount = condition ? 1 : 0

If you didn't, the everyday examples below are all you need.

count: several numbered copies

Raffle tickets come in a numbered strip: ticket 0, ticket 1, ticket 2 (programmers count from zero). count does that for a resource: put count = 3 on it, and Terraform makes three copies, each with a number.

Step 1. Add a list of page names to variables.tf:

HCL
variable "pages" {
  description = "Extra pages for the site"
  type        = list(string)
  default     = ["home", "about", "contact"]
}

list(string) means "a list, where every item is text", like a Python list. Then add this resource to web.tf:

HCL
resource "local_file" "pages" {
  count    = length(var.pages)
  filename = "site/${var.pages[count.index]}.html"
  content  = "<h1>${var.pages[count.index]}</h1>"
}
  • count = length(var.pages) makes one copy per item. length() counts the items, so that's 3.
  • count.index is the copy's number, 0, 1 or 2, different for each copy.
  • var.pages[count.index] picks the matching item from the list, like sites[0] in Python Lesson 3. So copy 0 is home.html, copy 1 is about.html, and copy 2 is contact.html.

Run terraform apply and type yes. Three pages are created in your site folder, and because the container shares that folder, they're live straight away: open http://localhost:8081/about.html. Run terraform state list, and you'll see their addresses, numbered in square brackets:

OUTPUT
local_file.pages[0]
local_file.pages[1]
local_file.pages[2]

The count trap

Now remove "about" from the middle of the list in variables.tf, so it's ["home", "contact"], and run terraform plan. You might expect one file to be removed. Instead:

OUTPUT
  # local_file.pages[1] must be replaced
-/+ resource "local_file" "pages" {
      ~ filename             = "site/about.html" -> "site/contact.html" # forces replacement

then, further down:

OUTPUT
  # local_file.pages[2] will be destroyed
  # (because index [2] is out of range for count)

and Plan: 1 to add, 0 to change, 2 to destroy.

Here's what happened. The copies are known by their numbers, not by what they are. Take about out, and contact moves up from number 2 to number 1. So Terraform sees copy 1 change from about to contact, which means replacing it, and copy 2 disappear. One removal, and a resource that had nothing to do with it gets destroyed and recreated. With pages, that's harmless. With a list of servers, removing one from the middle would rebuild the ones after it, and everything on them.

Don't apply. Put "about" back, so the list is ["home", "about", "contact"] again, and run terraform plan: No changes.

for_each: copies with names

A coat rack with name tags, instead of a numbered strip: when Sam leaves, Sam's peg is empty, and nobody else's coat moves. for_each gives each copy a name, its key, instead of a number.

Step 2. Change the local_file "pages" resource to use for_each:

HCL
resource "local_file" "pages" {
  for_each = toset(var.pages)
  filename = "site/${each.key}.html"
  content  = "<h1>${each.key}</h1>"
}
  • for_each = toset(var.pages) makes one copy per item. toset() turns the list into a set: like a list, but with no order and no duplicates, which is what for_each needs, because every name has to be unique.
  • each.key is this copy's name: home, about or contact.

Each copy's address now uses its name, like local_file.pages["about"]. That means every address has changed, from numbers to names, so on its own, this change would make Terraform destroy all three pages and create them again. With pages, you could let it. But it's a good chance to use Lesson 3's moved block, the safe way you'd do it with real servers. Add these to web.tf:

HCL
moved {
  from = local_file.pages[0]
  to   = local_file.pages["home"]
}

moved {
  from = local_file.pages[1]
  to   = local_file.pages["about"]
}

moved {
  from = local_file.pages[2]
  to   = local_file.pages["contact"]
}

Run terraform plan:

OUTPUT
  # local_file.pages[0] has moved to local_file.pages["home"]
  # local_file.pages[1] has moved to local_file.pages["about"]
  # local_file.pages[2] has moved to local_file.pages["contact"]

They may appear in a different order. The summary says Plan: 0 to add, 0 to change, 0 to destroy.: same pages, new addresses. Run terraform apply and type yes.

Now try the removal again. Take "about" out of the list, and run terraform plan:

OUTPUT
  # local_file.pages["about"] will be destroyed
  # (because key ["about"] is not in for_each map)

with Plan: 0 to add, 0 to change, 1 to destroy. Exactly the one you removed, and nothing else touched. That's why, for anything you might add to or remove from, for_each is the safer choice. count is fine when you just want a number of identical things, like "3 workers", where it doesn't matter which one goes. Put "about" back, and run terraform plan: No changes.

A map for more than a name. When each copy needs more than one value, for_each also takes a map, Terraform's version of a Python dictionary. Then each.key is the key, and each.value is its value:

HCL
variable "page_titles" {
  type = map(string)
  default = {
    home  = "Welcome"
    about = "About me"
  }
}

With for_each = var.page_titles, the copy for home would have each.key as "home" and each.value as "Welcome". You don't need to add this one; just know it's there when you need it.

Choosing a value

A traffic light picks red or green depending on one thing. A conditional expression picks one of two values depending on a condition:

HCL
condition ? value_if_true : value_if_false

Read the ? as "if so, use", and the : as "otherwise, use". It's the whole of Terraform's if and else, in one line.

Step 3. Add a switch to variables.tf:

HCL
variable "maintenance_mode" {
  description = "Show a 'back soon' page instead of the usual one"
  type        = bool
  default     = false
}

bool means true or false. Then, in web.tf, change the content of local_file "page", your main index.html page from Lesson 4, to:

HCL
  content  = var.maintenance_mode ? "<h1>Back soon</h1>" : "<h1>${var.page_text}</h1>"

If maintenance_mode is true, the page says "Back soon"; otherwise, it shows your usual text.

Only sometimes

Some things should only exist some of the time, like a notice that only appears during maintenance. Terraform has no "only if", but count can do it: zero copies means it doesn't exist, and one copy means it does. Add this to web.tf:

HCL
resource "local_file" "notice" {
  count    = var.maintenance_mode ? 1 : 0
  filename = "site/notice.html"
  content  = "<p>Planned maintenance tonight. Back by morning.</p>"
}

The conditional picks 1 or 0, and count makes that many. This one line is the pattern people find strangest in Terraform, because nothing in it says "if". Now that you've seen the parts, read it again: "how many copies? If maintenance mode is on, 1; otherwise, 0."

Turn maintenance mode on for one run:

BASH
terraform apply -var maintenance_mode=true

The plan replaces index.html, because its content changed, and adds local_file.notice[0]. Type yes, then refresh http://localhost:8081: "Back soon". Now run plain terraform apply, without the -var, and type yes: the usual page comes back, and the notice is destroyed. One switch, and two changes follow it.

A for expression in an output

One more kind of repeating, this time for values rather than resources. A for expression builds a list from another list. Step 4. Add this to outputs.tf:

HCL
output "page_urls" {
  description = "The address of every extra page"
  value       = [for name in var.pages : "http://localhost:${var.external_port}/${name}.html"]
}

Read it as "for each name in the list of pages, make this address". The square brackets around it mean the result is a list. Run terraform apply and type yes:

OUTPUT
page_urls = [
  "http://localhost:8081/home.html",
  "http://localhost:8081/about.html",
  "http://localhost:8081/contact.html",
]

Add a page to the list, and its address appears here too, without touching the output.

Removing about: count versus for_eachOn the left, count. Before: pages 0 home, 1 about, 2 contact. After removing about: 0 home, 1 contact. Copy 1 changes from about to contact, so it's replaced, and copy 2 is destroyed. On the right, for_each. Before: home, about, contact, each named by its key. After removing about: only the about copy is destroyed; home and contact are untouched.count: copies have numbers[0] home[1] about[2] contact[0] home[1] contact: replaced[2] destroyedone removal, two files rebuilt or removedfor_each: copies have names["home"]["about"]["contact"]["home"]["about"] destroyed["contact"]one removal, one file removedNumbers shift when the list changes. Names don't.
Before and after removing "about". With count, everything after the gap gets a new number, so Terraform rebuilds it. With for_each, each copy keeps its name, so only the one you removed goes.

In my real setup

CivicGrid's Terraform doesn't use any of these yet: it has exactly one of everything, one cluster, one DNS record, one database secret, and repeating code for a single item only makes it harder to read. Where they'd earn their place is the next step. More subdomains, each with its own DNS record, would be a for_each over a map of names to addresses, and a staging cluster that only exists while I'm testing a big change would be count = var.create_staging ? 1 : 0. Use them when you have several of something, not just in case.

Break it on purpose

Try each one, then put it back.

Give for_each a list. In local_file "pages", change toset(var.pages) to just var.pages, and run terraform plan:

OUTPUT
Error: Invalid for_each argument

followed by an explanation that ends: the for_each argument must be a map, or set of strings, and you've given it a list of strings. A list can have duplicates and an order, and for_each needs unique names, so Terraform insists you say "treat this as a set". Put toset() back.

Use each where there's no for_each. In local_file "notice", change the filename to "site/${each.key}.html", and run terraform plan:

OUTPUT
Error: Reference to "each" in context without for_each

each only exists inside a resource that has for_each; inside a count resource, the equivalent is count.index. Mixing them up is one of the most common Terraform errors. Put the filename back to "site/notice.html".

A duplicate name. Change the list to ["home", "about", "home"], and run terraform plan. No error this time: toset() quietly drops the second "home", because a set can't hold duplicates. So the plan just has two pages, and wants to remove contact, which is no longer in the list. That's usually what you want, but it can surprise you, so it's worth knowing. Put the list back to ["home", "about", "contact"].

Where you are now

You can make several of something with count or for_each, and you know why for_each is safer when items come and go. You can pick values with condition ? this : that, make things exist only sometimes with count = condition ? 1 : 0, and build lists with for expressions. That's the end of the lessons on your own computer. Lesson 7 takes everything you've learned to a real cloud.

Check yourself

  1. What does count = 3 on a resource do?

  2. With count, you remove the middle item of the list. Why is another copy replaced too?

  3. Why does for_each avoid the count trap?

  4. for_each = var.pages fails because var.pages is a list. What's the fix?

  5. What does count = var.maintenance_mode ? 1 : 0 do?

  6. Which line is Terraform's version of if and else?

Key terms

  • count: makes a number of copies of a resource, each with a number, count.index, from 0.
  • for_each: makes one copy per item in a set or map, each named by its key, each.key.
  • Set: like a list, but with no order and no duplicates. toset() makes one from a list.
  • Map: names paired with values, like a Python dictionary. With for_each, each.value is the value.
  • Conditional expression: condition ? this : that, which picks one of two values.
  • Optional resource: count = condition ? 1 : 0, so the resource exists only when the condition is true.
  • for expression: [for item in list : something], which builds a new list from another one.

Go deeper