Back to Blog

Terraform Functions, Expressions & Dynamic Blocks: Reading, Generating & Modifying Configuration for the Terraform Associate Exam

A hands-on guide to the 'Read, generate, and modify configuration' objective of the HashiCorp Terraform Associate exam — built-in functions, conditional and for expressions, splat syntax, and dynamic blocks — with terraform console examples and copy-paste HCL you can run today.

By Sailor Team , June 27, 2026

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:

SkillWhat you’ll be asked to do
Reference valuesRead resource attributes, variables, locals, and data sources correctly
Use built-in functionsKnow what lookup, coalesce, merge, length, element, join, format and friends return
Write expressionsEvaluate conditional (ternary) and for expressions in your head
Use splat syntaxCollapse a list of objects into a list of attributes with [*]
Generate nested blocksUse 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:

  • element wraps around. element(list, index) uses index % length, so element(["a","b","c"], 3) returns "a", not an error. This is different from list[3], which would error.
  • merge is 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 for key, or default if 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.key gives 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

MistakeWhy it bites you
Returning different types from a ternary’s two branchesplan fails with an “inconsistent conditional result types” error
Using a bare splat ([*]) on a for_each resourcefor_each makes a map, not a list — splat won’t work as expected
Assuming list[3] and element(list, 3) behave the sameelement wraps around; direct indexing errors out of bounds
Expecting merge to be first-winsIt’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 rulesAdds complexity with no benefit

Exam Tips for This Objective

  • Live in terraform console during 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, and keys/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.

Limited Time Offer: Get 80% off all Mock Exam Bundles | Sale ends in 7 days. Start learning today.

Claim Now