Terraform Expressions, Functions, Dependencies, and Sensitive Data
Dynamic Terraform configuration is configuration whose values are computed rather than typed out: expressions (references, conditionals, for expressions, splat and string templates), built-in functions such as lookup, try, merge, jsonencode and cidrsubnet, and the count, for_each and dynamic constructs that stamp out many instances or nested blocks from one definition. The Associate (004) exam tests all of it, plus the two ways to declare dependencies (implicit references versus an explicit depends_on), the custom conditions that catch bad assumptions (variable validation, preconditions, postconditions and non-blocking check blocks), and sensitive-data hygiene: the sensitive flag only redacts output, ordinary values land in state in plaintext, HashiCorp Vault is the recommended secrets manager, and ephemeral values plus write-only arguments are how modern Terraform keeps a secret out of state altogether. This lesson works through each area with short HCL you can check in terraform console, and it states the precise behaviours that decide questions: what errors and what silently falls back, what reindexes and what stays stable, and what is persisted and what is not. One fact candidates forget: you cannot write your own functions in Terraform configuration.
On this page10 sections
- Expressions: references, conditionals, null, and string templates
- for expressions and splat expressions transform collections
- Built-in functions you must recognize
- Reading values that might be missing: lookup, try, can, coalesce
- count and for_each create multiple resource instances
- dynamic blocks generate repeated nested blocks
- Implicit versus explicit dependencies
- Custom conditions: validation, precondition, postcondition
- Managing sensitive data and secrets with Vault
- Ephemeral values and write-only arguments keep secrets out of state
- Write conditional expressions, for expressions, splat expressions and string templates, and use null to make an argument optional
- Apply built-in functions: lookup, try, can, coalesce, merge, contains, element, flatten, toset, jsonencode, format, templatefile and cidrsubnet
- Choose between count and for_each, predict the plan when a collection changes, and generate nested blocks with dynamic blocks
- Distinguish implicit dependencies from explicit depends_on, including its effect on data sources
- Validate configuration with variable validation, preconditions, postconditions and check blocks, and know how each fails
- Handle sensitive data: what sensitive does and does not do, Vault static versus dynamic secrets, ephemeral values and write-only arguments
Expressions: references, conditionals, null, and string templates
Expressions are how values are computed anywhere an argument accepts one. The simplest expressions are literals and references: var.environment, aws_subnet.app.id, data.aws_ami.ubuntu.id, local.common_tags, module.network.vpc_id, each.value, count.index. On top of those, Terraform supports arithmetic, comparison and logical operators, plus a ternary conditional expression:
instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
The syntax is exactly condition ? true_val : false_val, and both result values must be convertible to a common type.
null means "not set"
Setting an argument to null is the same as leaving it out of the block: the provider applies its default (or, for a required argument, Terraform reports it as missing). Because null converts to any type, it pairs with either branch of a conditional, which is the idiomatic way to make an argument optional:
kms_key_id = var.encrypt ? aws_kms_key.data.arn : null
The same trick does not work for count, which needs a whole number. A boolean toggle must be converted explicitly: count = var.create_bucket ? 1 : 0, never count = var.create_bucket.
String templates
String templates interpolate expressions with ${...} and support directives with %{...}:
name = "web-${var.environment}-${count.index}"
message = "%{ if var.environment == "prod" }Handle with care%{ else }Sandbox%{ endif }"Interpolation-only strings like "${var.name}" are legal but redundant: write var.name directly.
for expressions and splat expressions transform collections
A for expression builds a new collection by transforming another. Square brackets produce a list (tuple), curly braces with => produce a map (object), and an optional if clause filters. Results keep the source order:
locals {
upper_zones = [for z in var.zones : upper(z)]
public_ids = [for s in aws_subnet.all : s.id if s.map_public_ip_on_launch]
name_by_env = {for e in var.environments : e => "app-${e}"}
}When iterating a map, bind both sides: {for k, v in var.tags : k => lower(v)}. A map-producing for expression is also how you turn a list of objects into something for_each accepts (see the count and for_each section).
A splat expression is shorthand for extracting one attribute from every element of a list:
output "instance_ids" {
value = aws_instance.web[*].id
}aws_instance.web[*].id is equivalent to [for o in aws_instance.web : o.id]. Splat works on lists and sets, including resources created with count, which form a list. Resources created with for_each form a map, so splat does not apply directly; use values(aws_instance.web)[*].id or a for expression instead.
Built-in functions you must recognize
Terraform ships a large library of built-in functions, and you cannot define your own functions in Terraform configuration. (Providers may publish extra functions called with a provider:: prefix, but the language itself has no user-defined functions.) Explore any of them in terraform console. The ones worth knowing by behaviour:
| Function | Returns | Example |
|---|---|---|
merge(m1, m2, ...) | One map combining all arguments; on a key collision the later argument wins | merge({env = "dev", team = "a"}, {env = "prod"}) gives {env = "prod", team = "a"} |
contains(list, value) | true if the value is in the list (list first, value second) | contains(["dev", "prod"], var.env) |
element(list, index) | The element at that index, wrapping around past the end (index modulo length) | element(["a", "b", "c"], 4) gives "b"; ["a", "b", "c"][4] is an error |
length(value) | Elements in a list, map or set, or characters in a string | length(var.zones) |
join(sep, list) | One string from a list of strings | join(",", var.zones) |
flatten(list) | Nested lists collapsed into one flat list | flatten([["a"], ["b", "c"]]) gives ["a", "b", "c"] |
toset(list) | A set: duplicates removed, no order; the conversion for_each needs for a list of strings | toset(var.bucket_names) |
keys(map) / values(map) | A list of the map's keys / values, in key order | values(aws_instance.web) |
Encoding, strings and files
jsonencode(value)turns an HCL value into a JSON string, so a policy can be written as native HCL (and checked by Terraform) instead of a heredoc:policy = jsonencode({ Version = "2012-10-17", Statement = [ ... ] }).jsondecode(string)does the reverse, parsing JSON into an HCL value you can index.format(spec, values...)uses printf-style verbs:format("web-%s-%03d", var.env, 7)gives"web-dev-007".templatefile(path, vars)renders a file using template syntax,${...}interpolation and%{...}directives, with only the variables you pass in scope; it is the standard way to build cloud-init user data.file(path)returns a file's raw contents with no rendering.
user_data = templatefile("${path.module}/init.tftpl", {
app_port = var.app_port
})cidrsubnet arithmetic
cidrsubnet(prefix, newbits, netnum) adds newbits to the prefix length and returns subnet number netnum of that new size. The new prefix length is the old one plus newbits, and subnet n starts at n times the subnet's block size. Two worked cases:
cidrsubnet("10.0.0.0/16", 8, 2): /16 + 8 = /24, each /24 spans 1 in the third octet, so subnet 2 is10.0.2.0/24.cidrsubnet("10.0.0.0/16", 4, 5): /16 + 4 = /20, each /20 spans 16 in the third octet (4,096 addresses), so subnet 5 starts at 5 x 16 = 80:10.0.80.0/20.
Reading values that might be missing: lookup, try, can, coalesce
Indexing a map with a key it does not have, var.amis["eu-north-1"], is an error, not null; the same is true of an out-of-range list index or a missing object attribute. Terraform gives you four tools for values that may be absent, and they react to different things:
| Function | Falls back when | Typical use |
|---|---|---|
lookup(map, key, default) | The key is not in the map | lookup(var.amis, var.region, "ami-default") |
try(expr1, expr2, ...) | An expression errors; returns the first one that evaluates without error | try(var.settings.tier, "standard") |
can(expr) | Returns true/false for whether the expression evaluates without error | can(regex("^t3\\.", var.instance_type)) in a validation |
coalesce(a, b, ...) | An argument is null or an empty string; returns the first that is neither | coalesce(var.override_name, local.default_name) |
The distinction that trips people up: try and can only react to errors. An attribute that exists but holds null is a valid value, so try(var.settings.tier, "standard") returns null in that case. To replace nulls, use coalesce (or a conditional).
count and for_each create multiple resource instances
The count and for_each meta-arguments turn one resource (or module) block into several instances, and a block can use one or the other, never both.
resource "aws_instance" "worker" {
count = var.environment == "prod" ? 3 : 1
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = { Name = "worker-${count.index}" }
}
resource "aws_instance" "service" {
for_each = {
api = "t3.small"
batch = "t3.large"
}
ami = data.aws_ami.ubuntu.id
instance_type = each.value
tags = { Name = each.key }
}| count | for_each | |
|---|---|---|
| Input | A whole number (not a bool) | A map, or a set of strings (not a list, not a set of objects) |
| Per-instance reference | count.index (0, 1, 2...) | each.key and each.value |
| Instance address | aws_instance.worker[0] | aws_instance.service["api"] |
| Result type | List of instances | Map of instances |
| Removing a middle item | Later items shift to new indexes and change | Only the removed key's instance is destroyed |
| Best for | Identical copies, on/off toggles (count = 0 or 1) | Distinct named instances that change over time |
What the plan does when a count list shrinks
Suppose count = length(var.names), name = var.names[count.index], and the list goes from ["a", "b", "c"] to ["a", "c"]. Index 0 is unchanged; index 1 now receives "c" instead of "b", so it is updated in place, or replaced if that argument forces replacement; index 2 no longer exists and is destroyed. The real object for "c" is torn down while the old "b" object is rewritten to look like it. Under for_each = toset(var.names), only ["b"] is destroyed.
Feeding for_each
- A list of strings must be converted:
for_each = toset(var.names)(tolistorsortstill return lists, which for_each rejects). - A list of objects needs a map with a unique key per element:
for_each = {for s in var.subnets : s.name => s}. If the objects are nested (for example subnets inside networks),flattenthem into one list first, then build the map with a composite key such as"${s.network}.${s.name}". - The keys must be known at plan time; the values may be unknown until apply. Never key on an attribute that is only known after apply, such as a generated ID.
- A for_each resource is itself a map, so it can drive another:
for_each = aws_vpc.main, thenvpc_id = each.value.idgives one instance per VPC, with matching keys.
Where count.index and each exist
count.index exists only inside a block that sets count, and each.key/each.value only inside a block that sets for_each. To pair instances across two counted resources, give both the same count and index the other one from inside: instance = aws_instance.app[count.index].id in an aws_eip with count = length(aws_instance.app) attaches EIP 0 to instance 0, and so on.
dynamic blocks generate repeated nested blocks
A dynamic block repeats a nested block inside one resource, driven by a collection, whereas count and for_each repeat whole resources. The classic case is security group rules:
variable "ingress_rules" {
type = list(object({
port = number
cidrs = list(string)
}))
}
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.cidrs
}
}
}- The label (
"ingress") names the nested block type to generate. for_eachaccepts any collection: a list, a set or a map.- Inside
content, the iterator object is named after the label unless you setiterator = rule. It has two attributes:.key(the map key, the list index for a list, or the element itself for a set, where key equals value) and.value(the current element). - Dynamic blocks can generate only nested blocks defined by the resource type's schema. They cannot generate meta-argument blocks such as
lifecycleorprovisioner, which Terraform processes before it evaluates expressions.
Use dynamic blocks sparingly: they make configuration harder to read, and HashiCorp's guidance is to prefer literal nested blocks unless the repetition genuinely needs to be data-driven.
Implicit versus explicit dependencies
Terraform orders operations from a dependency graph, and there are exactly two ways a dependency gets into that graph. An implicit dependency is created automatically whenever one block's expression references another: subnet_id = aws_subnet.app.id means the subnet is created first. This is the preferred mechanism because it is self-maintaining.
An explicit dependency uses the depends_on meta-argument for relationships Terraform cannot see in any expression:
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name
depends_on = [aws_iam_role_policy.app]
}The instance references the instance profile, but nothing in its arguments mentions the role policy, yet the software on the instance fails at boot if the policy is not attached first.
The rules of depends_on
- It takes a static list of whole resource, data source or module references:
[aws_iam_role_policy.app],[module.network]. Attribute references (aws_iam_role_policy.app.id), arbitrary expressions and quoted strings (["aws_iam_role_policy.app"]) are not accepted. - It adds to the implicit dependencies; it never replaces or removes them.
- It works on resources, data sources and module blocks.
- On a data source it has a side effect: if anything it depends on has planned changes, Terraform defers the read until apply. The data source's attributes become "known after apply" in the plan, and that unknown ripples into every resource that uses them, which can turn a quiet plan into a noisy one.
Implicit dependencies are preferred; depends_on is the last resort for dependencies invisible to Terraform, and overusing it creates ordering constraints that make plans more conservative and hide the real relationships.
Custom conditions: validation, precondition, postcondition
Custom conditions let a configuration check its own assumptions and fail with a clear message instead of a confusing provider error. Every one pairs a boolean condition with an error_message, and a failure stops the run.
Variable validation guards input values as early as its references allow: a rule that checks only the variable runs before any resource is evaluated. Since Terraform 1.9 a condition can reference other variables and objects in the module, not only the variable itself, so cross-variable rules are possible (a rule that references another object runs once that object's value is known):
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "instance_count" {
type = number
validation {
condition = var.environment != "prod" || var.instance_count >= 2
error_message = "prod needs at least two instances."
}
}Preconditions and postconditions go inside the lifecycle block of a resource or data source. A precondition checks an assumption before Terraform acts on the object and cannot reference self. A postcondition checks a guarantee afterwards and reads the object's own result through self:
data "aws_ami" "app" {
most_recent = true
owners = ["self"]
lifecycle {
postcondition {
condition = self.architecture == "x86_64"
error_message = "The AMI must be x86_64."
}
}
}The same assumption written as a precondition on one consumer looks like this; it reads the data source, not self:
resource "aws_instance" "app" {
ami = data.aws_ami.app.id
instance_type = var.instance_type
lifecycle {
precondition {
condition = data.aws_ami.app.architecture == "x86_64"
error_message = "The AMI must be x86_64."
}
}
}A postcondition on a data source protects every consumer of it at once, rather than one precondition per resource. When a condition fails, Terraform halts and does not continue to downstream objects, but it does not roll back changes already applied earlier in the run.
Outputs accept precondition blocks (directly in the output block, with no lifecycle wrapper) to check a value before it is exposed; outputs have no postconditions.
check blocks: assertions that warn
A check block is a top-level block (a sibling of resources, not nested in one) holding one or more assert blocks, each with a condition and error_message. It may contain its own scoped data source, visible only inside the check. Checks run at the end of plan and apply, and a failed assertion, or an error from the scoped data source, is reported as a warning that does not stop the run:
check "site_up" {
data "http" "home" {
url = "https://${aws_lb.web.dns_name}"
}
assert {
condition = data.http.home.status_code == 200
error_message = "The site did not return 200."
}
}| Mechanism | Where | On failure |
|---|---|---|
| validation | variable block | Error, run stops |
| precondition | resource/data lifecycle, or output | Error, run stops |
| postcondition | resource/data lifecycle | Error, downstream work stops (no rollback) |
| check / assert | top level | Warning, run continues |
Managing sensitive data and secrets with Vault
The ground truth of Terraform secrets handling is that state stores ordinary values in plaintext, including variables and outputs marked sensitive and attributes a provider marks sensitive, such as a database password. sensitive = true redacts values from plan and apply output so they do not leak into logs; it encrypts nothing. The real controls are operational: never hardcode credentials in .tf files, never commit .tfvars files containing secrets, pass secrets via TF_VAR_ environment variables or a secrets manager, and keep state in an access-controlled, encrypted remote backend.
Sensitivity propagates: an expression built from a sensitive value is sensitive too. A root module output whose value contains a sensitive value must itself declare sensitive = true, or Terraform raises an error. nonsensitive(value) is the deliberate override when you know a derived value is safe to show.
Vault: static versus dynamic secrets
HashiCorp's recommended secrets manager is Vault, consumed through the vault provider. Secrets live centrally with access policies and audit logging instead of being scattered across configurations:
data "vault_kv_secret_v2" "db" {
mount = "secret"
name = "database"
}A value read this way through a regular data source is still written to plan and state. What limits the damage is the kind of secret:
| Static secret (KV) | Dynamic secret (e.g. AWS secrets engine) | |
|---|---|---|
| What it is | A stored value, such as a long-lived AWS access key you put in Vault | Credentials Vault generates on request, with a lease (TTL) |
| Copy left in state | Valid until someone rotates it at the source | Stops working when the lease ends and Vault revokes the credential at the provider |
| Deleting or versioning in Vault | Housekeeping inside Vault (such as KV v2 version expiry, which soft-deletes old versions) changes only what Vault holds; the credential at the cloud provider stays valid until it is rotated there | Not needed: Vault itself revokes the credential at the provider when the lease ends |
So when a secret might land in state, short-lived dynamic credentials (for example from vault_aws_access_credentials) are the stronger choice: any copy that leaks expires on its own.
Ephemeral values and write-only arguments keep secrets out of state
Ephemeral values and write-only arguments are how Terraform uses a secret during a run without persisting it to plan or state. They are the only mechanisms that do; sensitive does not.
- Ephemeral resources (
ephemeralblocks, Terraform 1.10+) fetch a value for the duration of a run and are never stored, unlike a regular data source, whose result is persisted in state. They are referenced with theephemeral.prefix. - Ephemeral variables (
ephemeral = trueon a variable, 1.10+) can be referenced only in ephemeral contexts: provider blocks, write-only arguments, ephemeral resources, other ephemeral variables or locals, ephemeral outputs of child modules, and provisioner and connection blocks. A root module output cannot be ephemeral. - Write-only arguments (1.11+, provider-supported, usually suffixed
_wo) accept a value, including an ephemeral one, and send it to the provider without storing it.
ephemeral "vault_kv_secret_v2" "db" {
mount = "secret"
name = "database"
}
resource "aws_db_instance" "app" {
engine = "postgres"
username = "app"
password_wo = ephemeral.vault_kv_secret_v2.db.data["password"]
password_wo_version = 1
}Because a write-only value is never stored, Terraform has nothing to compare it with and cannot detect that it changed. Each write-only argument therefore comes with a stored version companion (password_wo_version): to push a new password, change the version value, and the plan shows an in-place update that sends the new secret. The version is the only signal Terraform has that the secret is new, so a rotation always pairs the new value with a new version.
Tip. Expect to evaluate expressions on sight: a conditional, a for expression, a splat, or a function call such as element with an out-of-range index, merge with a duplicate key, coalesce versus try on a null, or cidrsubnet with non-octet newbits. Count-versus-for_each scenarios are common: the plan after removing a middle list item, why for_each rejects a list or a list of objects, and where count.index and each.key are valid. You will also see which depends_on syntax is legal and how it affects a data source, which custom condition fails the run versus only warns, and sensitive-data facts: sensitive values still sit in state, dynamic Vault secrets expire, and write-only arguments rotate by bumping their version argument.
- The conditional is condition ? true_val : false_val; null means "argument not set", so cond ? value : null makes an argument optional, but count needs a number (cond ? 1 : 0).
- Splat (resource[*].attribute) works on count-created lists; for_each-created resources form a map, so use values() or a for expression.
- You cannot write custom functions in Terraform; know merge (later wins), contains(list, value), element (wraps), flatten, toset, jsonencode/jsondecode, format vs templatefile, and cidrsubnet arithmetic.
- Indexing a missing map key errors; lookup and try fall back on absence/errors, can tests for errors, and coalesce skips null and empty strings. try does not replace a null value.
- count takes a number and reindexes when a middle item is removed (shifted indexes change, the last is destroyed); for_each takes a map or set of strings and keeps stable keys that must be known at plan time.
- depends_on takes whole resource, data source or module references only, adds to implicit dependencies, and on a data source defers the read to apply when its dependency changes.
- validation, precondition and postcondition fail the run (postconditions can read self; nothing is rolled back); top-level check blocks run at the end of plan/apply and only warn.
- sensitive redacts output only; state keeps sensitive values in plaintext. Vault dynamic secrets expire and are revoked; ephemeral resources and write-only arguments (bump the _wo_version to rotate) keep secrets out of state.
Frequently asked questions
When should I use for_each instead of count?
Use for_each whenever the instances are distinct named things that may be added or removed over time, because instances are addressed by stable keys and removing one never disturbs the others. Use count for genuinely identical replicas or as an on/off toggle with count = 0 or 1. The deciding failure mode: removing a middle element under count shifts every later index, so those instances are updated or replaced with their neighbours' arguments and the last index is destroyed.
How do I use for_each with a list?
for_each accepts only a map or a set of strings, so a plain list is rejected. Convert a list of strings with toset(list). For a list of objects, build a map with a unique key per element using a for expression, such as {for s in var.subnets : s.name => s}; flatten nested lists into one list first. The keys must be known at plan time, although the values can be unknown until apply.
What is the difference between try, lookup and coalesce in Terraform?
lookup(map, key, default) returns the default when a key is absent from a map. try(a, b) returns the first expression that evaluates without an error, so it covers missing keys, attributes and indexes, but a value that exists and is null is returned as null. coalesce(a, b) returns the first argument that is neither null nor an empty string. Plain map[key] indexing with a missing key is an error, which is why these functions exist.
Can I write my own functions in Terraform?
No. The Terraform language supports only its built-in functions, plus functions that providers publish under a provider:: prefix. There is no way to define a custom function inside your configuration. Use terraform console to explore what the built-in functions return.
When do I actually need depends_on?
Only when one object relies on another's behaviour but no expression references it, so Terraform cannot infer the order, such as an instance whose software needs an IAM role policy attached before boot. depends_on takes a list of whole resource, data source or module references (no attributes or quoted strings) and adds to the implicit dependencies. Prefer an attribute reference where one exists, and be careful on data sources: depends_on can defer their read to apply time.
What is the difference between a postcondition and a check block?
A postcondition sits in a resource or data source lifecycle block, reads the object through self, and fails the run with an error, stopping downstream work without rolling back what was already applied. A check block is a top-level block of assert conditions, optionally with its own scoped data source, that runs at the end of plan and apply and reports failures as warnings, so the run continues. Use postconditions to enforce, checks to monitor.
If I read secrets from Vault, are they kept out of the Terraform state file?
Not when you read them with a regular data source: the value is persisted in plan and state in plaintext, even if marked sensitive. Vault still helps, and dynamic secrets such as AWS credentials from the AWS secrets engine carry a lease, so a leaked copy is revoked when the lease ends. To keep a secret out of state entirely, read it with an ephemeral resource (Terraform 1.10+) and pass it to a write-only argument (1.11+).
How do I rotate a password set through a write-only argument?
Supply the new value and change the companion version argument, for example password_wo_version on aws_db_instance. Terraform never stores a write-only value, so it cannot compare old and new; the stored version number is the only thing it can diff, and a changed version is what makes the plan send the new secret to the provider as an in-place update.
Source
This lesson covers the "Terraform configuration" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.
- HashiCorp Terraform Associate certification page — HashiCorp
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.