Terraform Basics #4 Data Sources and Expressions: Reading Instead of Creating, Conditionals, and for

5 min read

Everything in our code so far was created from scratch. Real-world Terraform code, however, operates among things that already exist. You need to launch servers inside a VPC another team built, fetch the latest AMI ID that AWS maintains, and know which account you are currently running in. This post covers the tool for reading instead of creating — data sources — and the expressions that transform what you read. It builds on the block syntax from #2 and the variables from #3.

The data block: read it, don’t create it #

If a resource block is a declaration that something “must exist,” a data block is a query: “find me something that exists.” The most common example is an AMI lookup. Creating an EC2 instance requires an AMI ID, but that ID differs per region and changes every time a new version ships. Hardcode it and you are soon running a stale image.

main.tf
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023*-x86_64"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"
}

The reference syntax is almost identical to resources; the only difference is the data. prefix. Every time you run plan, Terraform looks up the latest AMI matching the conditions, so the ID never has to appear in your code. Each provider ships a variety of data sources — aws_caller_identity for the account you are running in, aws_region for the current region, aws_vpc for finding an existing VPC — and you can browse them in the docs under the Data Sources list next to Resources. When you only want to reference a resource that another team or the console created, reading it without taking over management is exactly what data sources are for.

Conditionals: changing values by environment #

Time to put the env variable from #3 to work. If you want a bigger instance only in production, use a conditional expression.

main.tf
resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = var.env == "prod" ? "m7i.large" : "t3.micro"
}

The form is condition ? value_if_true : value_if_false, the same as the ternary operator in many programming languages. As we will see in #6, combining a conditional with count also enables branches like “create this resource only in production.” But once conditionals start nesting three or four levels deep, the code becomes unreadable — at that point it is better to break them up with named locals.

for expressions and splat: transforming collections #

To transform a list or a map, use a for expression. Wrap it in square brackets and you get a list; wrap it in curly braces and you get a map.

main.tf
locals {
  team = ["alice", "bob", "carol"]

  # list → list: prefix each name
  usernames = [for name in local.team : "dev-${name}"]

  # list → map: build a tag map keyed by name
  member_tags = { for name in local.team : name => "member" }

  # conditional filter: add if to filter items out
  admins = [for name in local.team : name if name != "carol"]
}

When you want to pull the same attribute from multiple resources, the splat syntax [*] is the shortcut. For example, if you created several subnets, aws_subnet.public[*].id becomes a list of just the IDs. A for expression can do the same job, but splat exists as a short form for the common case of “this one attribute from all of them.”

Built-in functions: you can’t define your own, so pick from the shelf #

Terraform has no syntax for defining your own functions. Instead, it ships with a generous set of built-ins. Narrowed down to the ones you will meet most often:

FunctionWhat it does
length(list)Length of a collection
merge(map1, map2)Merge maps (the common-tags + per-resource-tags pattern)
format("app-%s", var.env)String formatting
file("policy.json")Read a file’s contents as a string
jsonencode({...})HCL value to JSON string (the standard way to write IAM policies)
toset(list)List to set (needed for for_each in #6)

Of these, merge is one you will use daily in practice. The pattern is to keep the common tags that go on every resource in locals, then merge them with per-resource tags.

main.tf
tags = merge(local.common_tags, { Name = "web-server" })

There is no need to memorize the full list — keep the functions documentation open and look things up as needed.

terraform console: a playground for expressions #

Sometimes you want to see what value an expression produces without running apply. Run terraform console and an interactive prompt opens.

Run result
$ terraform console
> [for name in ["alice", "bob"] : "dev-${name}"]
[
  "dev-alice",
  "dev-bob",
]
> format("app-%s", "prod")
"app-prod"

It can also read the variables and resources in the current directory, so typing data.aws_ami.al2023.id shows the actual lookup result. When you are writing your first for expressions, spinning them a few times here before moving them into code cuts down dramatically on the trial-and-error of repeated plans.

Recap #

What we covered in this post:

  • A data block queries existing resources Terraform did not create, referenced as data.type.name.attribute. The latest-AMI lookup is the classic example
  • Conditionals condition ? a : b build per-environment branches; when nesting gets deep, split them into locals
  • for expressions produce lists with square brackets and maps with curly braces, filtering with if. When you only need one attribute from all items, splat [*] is shorter
  • You cannot define functions; you use built-ins. The merge pattern for common tags and jsonencode are everyday staples
  • terraform console is the tool for experimenting with expressions without apply

In the next post (#5 State Explained) we answer the question we have been postponing since #1: how does Terraform remember what it created? We open up the terraform.tfstate file and look into the very center of how Terraform works.

X