Most people who fail the HashiCorp Terraform Associate exam don’t fail on init, plan, and apply — they fail on the questions that show a block of HCL and ask “what does this evaluate to?” That’s the Read, generate, and modify configuration objective, and it’s where the exam stops testing whether you can run Terraform and starts testing whether you can actually read it. Conditional expressions, for expressions, splat syntax, built-in functions, and dynamic blocks all live here, and they show up far more often than candidates expect.
This guide walks through that objective the way the exam tests it: small, self-contained snippets you can paste into terraform console and verify for yourself. If you want the full exam blueprint and logistics first, start with the Terraform Associate exam guide for 2026 and the 30-day study plan. If you’ve already covered variables, outputs, and locals and count vs. for_each, this is the natural next step.
What “Read, Generate, and Modify Configuration” Covers
The exam objective is deliberately broad. In practice it tests five overlapping skills:
| Skill | What you’ll be asked to do |
|---|---|
| Reference values | Read resource attributes, variables, locals, and data sources correctly |
| Use built-in functions | Know what lookup, coalesce, merge, length, element, join, format and friends return |
| Write expressions | Evaluate conditional (ternary) and for expressions in your head |
| Use splat syntax | Collapse a list of objects into a list of attributes with [*] |
| Generate nested blocks | Use dynamic blocks to produce repeated configuration from a collection |
You don’t need to memorize all ~100 built-in functions. You need to recognize the common ones and be able to trace an expression to its result. The single best tool for that is the Terraform console.
Your Most Important Study Tool: terraform console
terraform console opens an interactive REPL where you can evaluate any expression against your configuration and state. It is the fastest way to build intuition for functions and expressions, and nothing you type in it changes infrastructure.
# Launch the console (works even in an empty directory for pure expressions)
terraform console
> 1 + 2
3
> upper("sailor")
"SAILOR"
> length(["a", "b", "c"])
3
> "Hello, ${"world"}"
"Hello, world"
Throughout this guide, the > prompts are real console sessions — paste them in and confirm the output. If you can predict every result before you hit Enter, you’re ready for this objective.
Expressions and References
An expression produces a value. The simplest expressions are literals and references:
var.region # an input variable
local.common_tags # a local value
aws_instance.web.id # an attribute of a resource
data.aws_ami.ubuntu.id # an attribute of a data source
module.network.vpc_id # an output from a child module
Terraform supports the arithmetic (+ - * / %), comparison (== != < <= > >=), and logical (&& || !) operators you’d expect. String interpolation uses ${ ... }:
locals {
bucket_name = "${var.project}-${var.environment}-assets"
}
A common exam trap: interpolation is only needed when you embed an expression inside a string. Wrapping a bare reference in "${ }" is redundant and the linter will flag it. name = var.project is correct; name = "${var.project}" is legacy style.
Built-in Functions You Must Know
Terraform ships built-in functions grouped by category. You cannot define your own functions — that’s a frequently tested fact. Here are the ones that appear most often, with console output you can verify.
String functions
> upper("cka")
"CKA"
> lower("CKA")
"cka"
> title("hello there")
"Hello There"
> trimspace(" padded ")
"padded"
> replace("v1.2.3", ".", "-")
"v1-2-3"
> substr("kubernetes", 0, 4)
"kube"
> format("web-%03d", 7)
"web-007"
> join(", ", ["a", "b", "c"])
"a, b, c"
> split(",", "a,b,c")
[
"a",
"b",
"c",
]
format and join show up constantly for building names and tag values. Note that split returns a list, while join collapses one back into a string.
Collection functions
> length(["a", "b", "c"])
3
> element(["a", "b", "c"], 1)
"b"
> contains(["a", "b"], "b")
true
> keys({ a = 1, b = 2 })
[
"a",
"b",
]
> values({ a = 1, b = 2 })
[
1,
2,
]
> merge({ a = 1 }, { b = 2 }, { a = 9 })
{
"a" = 9
"b" = 2
}
> concat(["a"], ["b", "c"])
[
"a",
"b",
"c",
]
> distinct(["a", "a", "b"])
[
"a",
"b",
]
Two behaviours the exam loves to test:
elementwraps around.element(list, index)usesindex % length, soelement(["a","b","c"], 3)returns"a", not an error. This is different fromlist[3], which would error.mergeis last-wins. When keys collide, the rightmost map’s value survives —merge({a=1}, {a=9})yields{a=9}.
lookup, coalesce, and try — handling missing values
These three handle “the value might not be there,” and they’re heavily tested because they look similar but behave differently.
> lookup({ env = "prod" }, "env", "default")
"prod"
> lookup({ env = "prod" }, "region", "us-east-1")
"us-east-1"
> coalesce("", null, "first-non-empty")
"first-non-empty"
> coalescelist([], ["fallback"])
[
"fallback",
]
> try(var.maybe_missing, "fallback")
"fallback"
lookup(map, key, default)returns the map value forkey, ordefaultif the key is absent.coalesce(...)returns the first argument that is non-null and non-empty.try(...)returns the first argument that evaluates without error — useful when an attribute path may not exist.
Numeric, encoding, and filesystem helpers
> max(3, 7, 2)
7
> min(3, 7, 2)
2
> ceil(4.1)
5
> floor(4.9)
4
> abs(-12)
12
> jsonencode({ name = "web", port = 80 })
"{\"name\":\"web\",\"port\":80}"
> base64encode("hello")
"aGVsbG8="
jsonencode is worth special attention: it’s the idiomatic way to build IAM policies, container definitions, and any JSON document inside Terraform without fighting heredoc indentation.
resource "aws_iam_role_policy" "s3_read" {
name = "s3-read"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = "${aws_s3_bucket.assets.arn}/*"
}]
})
}
Conditional Expressions (the Ternary)
The conditional expression is condition ? true_value : false_value. Both branches must return the same type (or types Terraform can convert to a common type).
locals {
instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
replica_count = var.high_availability ? 3 : 1
}
You’ll often combine it with a function to provide a fallback:
locals {
# Use the supplied name, or derive one if it's empty
name = var.name != "" ? var.name : "${var.project}-default"
}
A classic exam question shows a ternary where the two branches return different types (a string and a list) and asks why terraform plan errors. The answer is the type mismatch — Terraform cannot reconcile the result type.
for Expressions — Transforming Collections
A for expression transforms one collection into another. It’s the HCL equivalent of a map/filter operation, and it’s one of the most heavily tested constructs in this objective.
Producing a list
Wrap the expression in [ ] to build a list:
> [for s in ["a", "b", "c"] : upper(s)]
[
"A",
"B",
"C",
]
Producing a map
Wrap it in { } and use the key => value form:
> { for name in ["web", "db"] : name => length(name) }
{
"db" = 2
"web" = 3
}
Filtering with if
Add an if clause to keep only matching elements:
> [for n in [1, 2, 3, 4] : n if n % 2 == 0]
[
2,
4,
]
Iterating over a map
When the source is a map, you get both key and value:
> { for k, v in { web = 80, db = 5432 } : k => "port-${v}" }
{
"db" = "port-5432"
"web" = "port-80"
}
A real-world pattern: build a map of normalized tags or transform a list of user objects into a map keyed by username so you can feed it to for_each. This is exactly where for_each and count intersect with for expressions — the for expression shapes the data, and for_each consumes it.
Splat Expressions
A splat expression is shorthand for a for expression that pulls one attribute out of every element in a list. The [*] operator does the work.
# These two are equivalent
aws_instance.web[*].id
[for i in aws_instance.web : i.id]
Splats are most common with resources that use count, since count produces a list:
resource "aws_instance" "web" {
count = 3
ami = var.ami_id
instance_type = "t3.micro"
}
output "instance_ids" {
value = aws_instance.web[*].id # list of all 3 IDs
}
output "private_ips" {
value = aws_instance.web[*].private_ip
}
One subtlety the exam may probe: splat works on lists, so it pairs naturally with count. Resources created with for_each are a map, not a list, so you use values(aws_instance.web)[*].id or a for expression instead of a bare splat.
Dynamic Blocks — Generating Nested Configuration
So far everything has produced values. Dynamic blocks produce nested configuration blocks. They’re the answer to “I have a variable number of ingress rules / settings / volumes and I don’t want to copy-paste blocks.”
Consider a security group where the rules come from a variable:
variable "ingress_rules" {
type = list(object({
port = number
cidr_blocks = list(string)
}))
default = [
{ port = 22, cidr_blocks = ["10.0.0.0/8"] },
{ port = 443, cidr_blocks = ["0.0.0.0/0"] },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
}
The anatomy that the exam tests:
dynamic "ingress"— the label is the name of the nested block you’re generating (here,ingress).for_each— the collection to iterate over (a list or map).content { ... }— the body produced for each element.ingress.value— the current element. The iterator is named after the block label by default;ingress.keygives the index or map key.
You can rename the iterator with iterator = <name> if the default collides, but you rarely need to. The important conceptual point — and a frequent exam question — is that dynamic blocks generate nested blocks, not top-level resources. To create multiple resources, you use count or for_each on the resource itself; to create multiple blocks inside one resource, you use a dynamic block.
When not to reach for dynamic blocks
Dynamic blocks make configuration harder to read. HashiCorp’s own guidance is to use them sparingly — prefer writing blocks out literally when the count is small and fixed. The exam may present an over-engineered dynamic block and ask whether it’s appropriate; the practitioner answer is that a static list of two rules doesn’t need one.
Putting It Together
Here’s a small but realistic snippet that combines a local, a for expression, a conditional, and a function — the kind of layered configuration the exam expects you to read:
variable "teams" {
type = map(string) # team name => environment
default = { payments = "prod", search = "dev" }
}
locals {
# Build a tag map per team, sizing instances by environment
team_config = {
for name, env in var.teams : name => {
instance_type = env == "prod" ? "m5.large" : "t3.micro"
tags = merge(
{ Team = title(name), Environment = upper(env) },
env == "prod" ? { Backup = "true" } : {}
)
}
}
}
output "search_instance_type" {
value = local.team_config["search"].instance_type # "t3.micro"
}
Trace it: for name, env in var.teams iterates the map; the ternary picks an instance type; merge combines a base tag map with a conditionally-added Backup tag. If you can read this without running it, you’ve got the objective.
Common Mistakes to Avoid
| Mistake | Why it bites you |
|---|---|
| Returning different types from a ternary’s two branches | plan fails with an “inconsistent conditional result types” error |
Using a bare splat ([*]) on a for_each resource | for_each makes a map, not a list — splat won’t work as expected |
Assuming list[3] and element(list, 3) behave the same | element wraps around; direct indexing errors out of bounds |
Expecting merge to be first-wins | It’s last-wins on key collisions |
Wrapping every reference in "${ }" | Redundant and flagged by terraform fmt/linters |
| Reaching for a dynamic block for two static rules | Adds complexity with no benefit |
Exam Tips for This Objective
- Live in
terraform consoleduring study. Re-derive every function result rather than memorizing tables. - Know the function categories, not all 100 functions. If you can place
merge,lookup,coalesce,length,element,join,format,jsonencode,try, andkeys/values, you’ll handle most questions. - Read expressions inside-out. Evaluate the innermost function first, then work outward — exactly how Terraform does.
- Watch for type mismatches. Many “why does this fail?” questions are really type questions in disguise.
- Remember you can’t write custom functions. If an answer choice claims you can define one, it’s wrong.
Once you can read any snippet the exam throws at you, the best next move is volume — drilling enough question variations that the patterns become automatic.
Practice Until Reading HCL Is Automatic
Reading configuration fluently is a skill you build by repetition, not by re-reading documentation. The most efficient path is to alternate between two activities: write small experiments in terraform console, and answer realistic multiple-choice questions that force you to evaluate expressions under time pressure.
Sailor.sh’s Terraform Associate practice tests are built for exactly this objective — including “what does this evaluate to?” items on functions, for expressions, and dynamic blocks, each with an explanation of why the answer is what it is. You can also warm up with the free Terraform Associate practice questions to gauge where you stand before committing to a full study cycle. Pair that with the hands-on reps from the Terraform commands cheat sheet and a solid grasp of state management, and the configuration objective becomes one of your strongest, not your weakest.
Frequently Asked Questions
Can I define my own functions in Terraform?
No. Terraform only provides built-in functions; you cannot author custom ones in HCL. You compose the built-ins (often inside locals) to get the behaviour you need. This is a commonly tested fact.
What’s the difference between lookup, coalesce, and try?
lookup(map, key, default) returns a value from a map by key, falling back to a default if the key is missing. coalesce(a, b, ...) returns the first non-null, non-empty argument. try(a, b, ...) returns the first argument that evaluates without raising an error — handy when an attribute path itself may not exist.
When should I use a dynamic block versus for_each on a resource?
Use for_each (or count) on a resource to create multiple resources. Use a dynamic block to generate repeated nested blocks inside a single resource (like multiple ingress rules in one security group). They solve different problems and often appear together.
Does a splat expression work with for_each resources?
Not directly. for_each produces a map of resource instances, and the splat operator ([*]) is designed for lists. Use values(resource.name)[*].attribute or an explicit for expression to extract attributes from a for_each resource.
How are conditional expressions evaluated if both branches have side effects?
There are no side effects in HCL expressions — they’re pure. Terraform evaluates the condition, then returns one branch’s value. Both branches must produce a compatible result type, or plan fails with a type error.
Is terraform console safe to use against production?
Yes. The console only reads your configuration and state to evaluate expressions; it never creates, modifies, or destroys infrastructure. It’s the safest and fastest way to test functions and expressions while you study.
Ready to make the configuration objective a strength? Drill realistic, explained questions with the Sailor.sh Terraform Associate mock exams, then map your full prep with the Terraform Associate exam guide.