Types and Expressions
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Every argument you write in a Terraform configuration is an expression: a literal such as "web" or 8080, a
reference such as var.environment, or a combination of values, operators, function calls and constructs such as
conditionals and for expressions. The syntax of the files themselves is covered in
HCL Syntax and Configuration Files; this page is the
reference for what may appear on the right-hand side of an argument, and for the type system that decides which
values are accepted where. It describes the language as documented for Terraform 1.17 (released 2026-10-21) and
OpenTofu 1.13 (latest stable 1.13.2), and it sits in the middle of the reading path: it builds on
Variables, Outputs and Locals, which declare typed values,
and it is the prerequisite for
Functions and
Meta-Arguments: count, for_each and
depends_on, which use almost every construct below. The examples follow the Bookshelf scenario (catalog-api,
reviews-api, web, PostgreSQL, Redis) and, where possible, run with the built-in terraform_data resource and
terraform console, so they need no cloud account and no credentials.
The official documentation splits this material into the pages listed in the overview: types and values, strings and
templates, references to values, operators, function calls, conditional expressions, for expressions, splat
expressions, dynamic blocks, type constraints and version constraints. This page follows the same order. Whenever an
expression behaves in a surprising way, terraform console (or tofu console) is the fastest way to find out, because
it evaluates any expression against the current configuration and state.
$ terraform console
> 1 + 2 * 3
7
> upper("bookshelf")
"BOOKSHELF"
> [for s in ["web", "catalog-api"] : length(s)]
[
3,
11,
]
> exit
Types and values
Terraform values have a type. The language has three primitive types and several complex types, plus the special
value null.
| Type | Meaning |
|---|---|
|
Text, such as |
|
A whole or fractional value, such as |
|
|
|
An ordered sequence of values that all have type |
|
A collection of unique values of type |
|
Named values, all of type |
|
A fixed-length sequence whose elements can have different types: |
|
A fixed set of named attributes, each with its own type. |
|
Absence of a value. It has no type of its own. |
Strings, numbers and bools are the primitive types. Lists, sets and maps are collection types, whose elements
share one type. Tuples and objects are structural types, whose elements can differ. In most situations lists and
tuples behave identically, as do maps and objects; the difference matters when you write type constraints for module
inputs, and when a provider’s schema restricts an argument to one of them. A literal such as ["a", 15, true] is
written the same way for both, and Terraform decides from context which type it is.
Literals and access syntax
locals {
name = "catalog-api" # string
port = 8080 # number
public = false # bool
zones = ["a", "b", "c"] # list or tuple (a trailing comma is allowed)
labels = { tier = "backend", team = "books" } # map or object
nothing = null # absence
first = local.zones[0] # index into a list
tier = local.labels["tier"] # key lookup in a map
team = local.labels.team # attribute access, for valid identifiers
by_value = { (local.name) = local.port } # a non-literal key goes in parentheses
}
Source: Terraform Docs — Types and Values.
Lists can span several lines and need commas between values. Map keys must be strings, and they can be written without
quotes when they are valid identifiers; a key that is itself an expression is wrapped in parentheses. On the
single-line map form, commas are required; on the multi-line form a newline separates entries. Sets cannot be indexed,
so local.some_set[0] is an error: write tolist(local.some_set)[0], or better, iterate with a for expression.
null
Setting an argument to null makes Terraform behave as if the argument were omitted: the default is used if one
exists, and an error is raised if the argument is required. This is what makes conditional arguments possible
(var.enable_tls ? var.certificate : null), and it is why null appears so often in optional object attributes below.
Unknown and sensitive values
Two extra properties travel with values during evaluation, even though they are not types. A value can be unknown:
its result is only available after apply, as with the ID of a resource that has not been created yet. Plans show such
values as (known after apply), and an expression that depends on one is itself unknown. Because the number of
instances must be known to build the plan, count cannot be unknown, and a for_each collection must be known
well enough to determine its keys; that error and its workarounds are covered in
Meta-Arguments: count, for_each and
depends_on. A value can also be sensitive: Terraform hides it as (sensitive value) in plan and apply output and
propagates the mark to every value derived from it, but it still stores the value in state in cleartext. Sensitive,
ephemeral and write-only data are covered in
Sensitive Data, Ephemeral Values and Write-Only
Arguments.
Automatic type conversion
Terraform converts values automatically where the context expects a different type and a sensible conversion exists; where it cannot, it reports a type mismatch error.
| From → to | Rule |
|---|---|
number or bool → string |
Always possible: |
string → number or bool |
Only when the string holds a valid representation: |
list or tuple → set |
Duplicates and order are lost. |
set → list or tuple |
The order is arbitrary, except that a set of strings comes out sorted lexically. |
list → tuple |
Only when the length matches the tuple type exactly. |
map → object |
Works when the map has at least the required keys; extra keys are silently discarded, so a map → object → map round trip can lose data. |
Element values are converted recursively. One important exception: automatic conversion does not happen with the
equality operator, so 15 == "15" is false (see Operators and precedence).
Conversion functions make the intent explicit. tostring, tonumber, tobool, tolist, toset and tomap have
long been the standard tools. Terraform 1.15 added the convert function, which takes any type constraint as its second
argument and is the precise form of all of them: tostring(v) is convert(v, string), tolist(v) is
convert(v, list(any)), and so on. For collections, convert with a specific element type is preferable, because the
any element type relies on more ambiguous type inference. The existing functions remain for compatibility.
$ terraform console
> convert([], list(string))
tolist([])
> convert({ api = {} }, map(object({ image = optional(string, "nginx:1.27"), note = optional(string) })))
tomap({
"api" = {
"image" = "nginx:1.27"
"note" = tostring(null)
}
})
> tonumber("15") + 1
16
Type constraints
A type constraint describes a type rather than a value. Terraform uses constraints in the type argument of
variable blocks (and, since Terraform 1.15, of output blocks), in the convert function and in provider schemas.
The same syntax applies in OpenTofu.
| Constraint | Meaning |
|---|---|
|
The primitive types. |
|
Collections with element type |
|
All listed keys are required unless wrapped in |
|
Exactly that many elements, each matching the type at its position. |
|
A placeholder, not a type: Terraform infers one concrete type that fits the supplied value. |
The any placeholder
With any, Terraform looks for a single type that fits. ["a", "b", "c"] is inferred as list(string);
["a", 1, "b"] is inferred as list(string) after converting 1 to "1"; ["a", [], "b"] is rejected because no
single element type fits. HashiCorp advises against using any to avoid writing a type: it is appropriate only when
the module passes the value through without inspecting it, for example straight into jsonencode. An any-typed input
gives callers no early error for a wrong shape, and the error then surfaces deeper inside the module.
optional() attributes and defaults
Inside an object type, optional(type) marks an attribute as not required. If the caller omits it, its value is
null. optional(type, default) supplies a default that is used both when the attribute is omitted and when it is
explicitly set to null, so the receiving module never sees null for that attribute. Defaults are applied top-down
in nested types: the outer default is applied first, then the nested ones. Optional attributes were introduced as a
stable feature in Terraform 1.3 (2022-09-21), and the 1.3 release notes show the canonical result: assigning
{ a = "foo" } to an object with a required a, an optional(string) b and an optional(number, 127) c
produces { a = "foo", b = null, c = 127 }.
The example below is a typed registry of the Bookshelf services. replicas, env and the nested health attributes
are optional; port has no default and so is null unless set.
terraform {
required_version = ">= 1.15.0"
}
variable "services" {
description = "Bookshelf services and their settings."
type = map(object({
image = string
port = optional(number)
replicas = optional(number, 1)
env = optional(map(string), {})
health = optional(object({
path = optional(string, "/health")
interval = optional(string, "30s")
}))
}))
}
output "services" {
value = var.services
}
# terraform.tfvars
services = {
"catalog-api" = {
image = "bookshelf/catalog-api:1.4"
port = 8080
health = {} # the object is present, so its defaults apply
}
"reviews-api" = {
image = "bookshelf/reviews-api:2.1"
replicas = 2
env = { LOG_LEVEL = "debug" }
# health is omitted entirely, so it is null: the optional(object(...)) has no default of its own
}
}
$ terraform apply -auto-approve
...
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform output -json services
{"catalog-api":{"env":{},"health":{"interval":"30s","path":"/health"},"image":"bookshelf/catalog-api:1.4","port":8080,"replicas":1},"reviews-api":{"env":{"LOG_LEVEL":"debug"},"health":null,"image":"bookshelf/reviews-api:2.1","port":null,"replicas":2}}
Two details trip people up. First, health = {} and an omitted health are different: an optional(object(...)) with no
default is null when omitted, while an empty object is present and receives the defaults of its own attributes, which
is exactly the behavior shown above. Second, the documentation of defaults only discusses null; an empty string or an
empty list supplied by the caller is a value, not an omission, and the default does not replace it. To leave an
optional argument unset at runtime, a conditional that returns null works, as in var.flag ? "X" : null.
nullable and default
A related pair of variable arguments controls null at the top level. nullable (default true) decides whether a
caller may pass null: when it is false, the variable must have a non-null value, and an explicit null from the
caller falls back to the declared default. This was added in Terraform 1.1 (2021-12-08). When nullable is true and a
default exists, passing null overrides the default. A variable’s default must be a literal value convertible to
the declared type; it cannot reference other objects in the configuration (use a locals block for derived values).
Types on outputs and module boundaries
Terraform 1.15 (2026-04-29) added explicit type constraints on output blocks, so a module can promise the shape of
its outputs just as it does for its inputs. Input variables are converted to their declared type on entry, and in an
object type only the declared attributes are accessible inside the module, which is one more reason to write precise
constraints for anything crossing a module boundary. See
Modules.
References and named values
An expression refers to other objects by name. The names look like attribute access on objects, but they are not
real objects: you cannot use bracket notation in place of the dots, and you cannot iterate over a parent name such as
aws_instance in a for expression. Terraform builds its dependency graph from these references, which is why they
also order the creation of resources (Dependency Graph and
Plan).
| Reference | Meaning |
|---|---|
|
A managed resource, for example |
|
An input variable, converted to the declared type. |
|
A local value. Locals can reference each other as long as they do not form a cycle. |
|
An output of a child module. The module reference is an object, a list ( |
|
A data source, with the same shape rules as resources. |
|
Filesystem paths: the module containing the expression, the root module, and the original working directory (before
any |
|
The name of the selected workspace. OpenTofu also accepts |
|
In a resource or module that uses |
|
In a resource or module that uses |
|
In provisioner and connection blocks only: the resource being provisioned. |
The block-local names (count, each, self) are not input variables and exist only in the blocks that define them;
a dynamic block’s iterator is referenced under its own name (see Dynamic blocks). The filesystem and workspace
values are best used in the root module: a shared module should take a prefix through an input variable instead, with
path.module the one exception. OpenTofu additionally documents terraform.applying and tofu.applying, an
ephemeral value that is false during plan and validate and true during the apply phase.
Attribute, index and key access
Once you have an object, a list or a map, you reach into it with a dot and an attribute name, a bracketed integer for
lists and tuples, or a bracketed string for maps and objects: docker_container.catalog_api.name,
var.zones[0], local.labels["tier"]. Chains are allowed, and an unknown or sensitive value anywhere in the chain
makes the result unknown or sensitive.
The example below shows how count, for_each and plain resources differ in shape, using the built-in
terraform_data resource so that it runs with no provider.
terraform {
required_version = ">= 1.9.0"
}
resource "terraform_data" "database" {
input = "postgres:17"
}
resource "terraform_data" "replica" {
count = 2
input = "postgres-replica-${count.index}"
}
resource "terraform_data" "queue" {
for_each = toset(["covers", "reviews"])
input = "queue-${each.key}"
}
locals {
database = terraform_data.database.output # single object
first_replica = terraform_data.replica[0].output # list element (count)
covers_queue = terraform_data.queue["covers"].output # map element (for_each)
}
output "workspace" {
value = terraform.workspace
}
output "all_replicas" {
value = terraform_data.replica[*].output
}
output "all_queues" {
value = { for k, q in terraform_data.queue : k => q.output }
}
$ terraform apply -auto-approve
...
Outputs:
all_queues = {
"covers" = "queue-covers"
"reviews" = "queue-reviews"
}
all_replicas = [
"postgres-replica-0",
"postgres-replica-1",
]
workspace = "default"
Because terraform_data.queue is a map, a splat expression cannot be applied to it, which is why all_queues uses a
for expression (see Splat expressions). Resources, data sources and module calls are covered in
Resources and Data Sources and
Modules.
Operators and precedence
Terraform has arithmetic, comparison, equality and logical operators. They are evaluated in the order below, from the highest precedence (first) to the lowest, and parentheses override it.
| Level | Operators | Operands |
|---|---|---|
1 |
|
bool, number |
2 |
|
numbers |
3 |
|
numbers |
4 |
|
numbers, producing a bool |
5 |
|
any two values, producing a bool |
6 |
|
bools |
7 |
|
bools |
So 1 + 2 * 3 is 1 + (2 * 3), which is 7, and a || b && c groups as a || (b && c). The comparison and logical
operators expect numbers and bools respectively; Terraform tries an automatic conversion first and raises an error if
that is impossible. Terraform has no exclusive-or operator: for bools, != gives the same result.
Equality deserves care. a == b is true only when both values have the same type and the same value; values of
different types never compare equal, and there is no automatic conversion. HashiCorp recommends using equality mainly
with primitive types or with explicit conversion functions. Structural types behave unexpectedly: var.list == [] is
never true for a list variable, because [] is the empty tuple, not a list; write length(var.list) == 0 instead.
$ terraform console
> 1 + 2 * 3
7
> (1 + 2) * 3
9
> 7 % 4
3
> 5 / 2 == 2.5
true
> 15 == "15"
false
> tostring(15) == "15"
true
> length([]) == 0
true
> 1 < 2 && 3 > 4 || true
true
Source: Terraform Docs — Operators.
Conditional expressions
A conditional expression picks one of two values: condition ? true_val : false_val. The condition is any expression
that returns a bool, built with the operators above, with functions such as contains, length and can, or with
alltrue and anytrue over a for expression. The two result values can have any type, but they must have the same
type, so that Terraform can determine the type of the whole expression without knowing the condition’s value. If they
differ, Terraform tries to find a type both convert to and converts them. Be explicit when the result type is uncertain:
var.enabled ? tostring(12) : "hello" always yields a string.
The three everyday uses are supplying a default for an empty value, leaving an argument unset with null, and
switching a resource on or off with count.
terraform {
required_version = ">= 1.9.0"
}
variable "environment" {
type = string
default = "dev"
}
variable "enable_cache" {
type = bool
default = true
}
variable "domain_override" {
type = string
default = ""
}
locals {
# 1. A default for an empty value.
domain = var.domain_override == "" ? "${var.environment}.bookshelf.example" : var.domain_override
# 2. Different values per environment.
replicas = var.environment == "prod" ? 3 : 1
# 3. null leaves an optional argument unset.
log_level = var.environment == "prod" ? null : "debug"
}
# 4. A resource that exists only when a flag is true.
resource "terraform_data" "redis" {
count = var.enable_cache ? 1 : 0
input = "redis:7 for ${local.domain}"
}
output "redis" {
value = one(terraform_data.redis[*].output)
}
The count = condition ? 1 : 0 idiom works everywhere, with the side effect that the resource becomes a list:
references to it need an index or a splat, and one(terraform_data.redis[*].output) gives the single value or null.
OpenTofu 1.11 added a dedicated alternative: lifecycle { enabled = <bool> } (see Terraform versus OpenTofu). The
related precondition, postcondition and variable validation blocks, which also take conditions, are in
Lifecycle, Validation and Checks.
Function calls
A function call is <NAME>(<ARG1>, <ARG2>). A list or tuple can be expanded into separate arguments by following it
with ... (three ASCII periods, not the Unicode ellipsis), but only in function calls: min([55, 2453, 2]...) is
2. Passing a sensitive value to any function marks the result sensitive; timestamp and uuid return unknown values
during planning; file and templatefile read files during configuration validation, so they cannot read files that
are generated during the run. Since Terraform 1.8 and OpenTofu 1.7, providers can contribute functions called as
provider::<provider_name>::<function_name>(...). The library itself is on
Functions.
Source: Terraform Docs — Function Calls.
Strings and templates
A string is a quoted sequence of characters. Inside quotes, backslash escapes work as follows: \n (newline), \r,
\t, \", \\, \uNNNN and \UNNNNNNNN (Unicode characters). A string becomes a template when it contains
an interpolation sequence or a directive; the same template language is used by templatefile, templatestring
and .tftpl files.
Interpolation
${ ... } evaluates the expression between the markers, converts the result to a string and inserts it:
"catalog-api-${var.environment}". The convention is simple: use interpolation when you are building a string, and write
the bare expression when you are passing a value through (image = var.image, not image = "${var.image}").
Heredocs
A heredoc is a multi-line string. It starts with << or <<- followed by a delimiter identifier (conventionally
EOT) and a line break, and it ends when the identifier appears alone on a line. The <<- form strips the smallest
amount of leading whitespace shared by all lines, so a heredoc can be indented inside a block. Heredocs do not process
backslash escapes: a backslash is literal.
locals {
motd = <<-EOT
Bookshelf
catalog-api, reviews-api and web
EOT
}
The value is "Bookshelf\n catalog-api, reviews-api and web\n": the four common spaces are removed, and the
extra two on the second line remain.
Escaping interpolation and directives
To produce a literal ${, write $${; to produce a literal %{, write %%{. Both forms work in quoted strings
and heredocs, and they are what you need when a template generates a shell script, a Prometheus rule or any text
whose own syntax uses those characters.
Directives
Directives are the template language’s control flow. They start with %{ and a keyword.
| Directive | Meaning |
|---|---|
|
Chooses between two templates. The |
|
Evaluates the enclosed template once per element and concatenates the results. A second symbol gives index or key
and value: |
A ~ placed immediately after the opening { of a directive, or immediately before its closing }, is a strip
marker: it removes the literal whitespace, spaces and newlines, on that side. Without strip markers, every directive
line leaves its own newline in the output, which shows up as blank lines. The ~ also removes the indentation of the
adjacent line, so it is a whitespace tool, not just a newline tool.
terraform {
required_version = ">= 1.9.0"
}
variable "environment" {
type = string
default = "prod"
}
locals {
catalog_backends = ["10.0.1.10", "10.0.1.11"]
# An interpolation with a one-line if directive; the nested quotes are legal inside the directive.
banner = "bookshelf-%{ if var.environment == "prod" }live%{ else }${var.environment}%{ endif }"
hosts_file = <<-EOT
# managed by Terraform; shell uses $${HOME}, templates use %%{ if }
%{ for ip in local.catalog_backends ~}
catalog-api ${ip}
%{ endfor ~}
reviews-api 10.0.2.10
EOT
}
output "banner" {
value = local.banner
}
output "hosts_file" {
value = local.hosts_file
}
$ terraform apply -auto-approve
...
$ terraform output -raw hosts_file
# managed by Terraform; shell uses ${HOME}, templates use %{ if }
catalog-api 10.0.1.10
catalog-api 10.0.1.11
reviews-api 10.0.2.10
$ terraform output -raw banner
bookshelf-live
When not to template
The documentation recommends heredocs for templates that use directives and quoted strings mainly for interpolation.
It also recommends against building JSON or YAML with heredocs and directives: make the data a value and call
jsonencode or yamlencode, which take care of escaping and delimiters. The templatefile function renders a
separate file (conventionally named *.tftpl) from a map of variables, templatestring renders a template held in a
string such as a data-source result, and neither can read a file the run itself generates. The same function names,
and the same template language, exist in OpenTofu. Old tutorials show the template_file data source from the
template provider for this job; templatefile replaced it, and new configurations should not need the provider.
locals {
# Prefer a value that is encoded...
catalog_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = "arn:aws:s3:::bookshelf-covers-example/*"
}]
})
}
# ...rather than a heredoc with hand-written JSON braces, quotes and commas.
for expressions
A for expression transforms a collection (list, set, tuple, map or object) into a new tuple or object. Each input
element produces zero or one output element. The enclosing brackets decide the result: [for ...] makes a tuple,
{for ...} makes an object, which needs the key => value form. Automatic conversion lets you use the result wherever
a list, map or set is expected.
| Form | Result |
|---|---|
|
A tuple of transformed values. With one symbol, the symbol is always the value. |
|
With two symbols on a list, the first is the index and the second the value. |
|
With two symbols on a map or object, the first is the key. |
|
An object. The expression before |
|
A trailing |
|
A trailing |
Ordering. Unordered inputs need an implied order when they become lists or tuples: maps and objects are sorted
lexically by key or attribute name, and sets of strings are sorted by value. A set of any other element type comes
out in an arbitrary order that may change in future versions, so wrap an intentionally unordered result in toset.
Grouped results are sorted as well.
Limits. A for expression produces values, not configuration. It cannot generate nested blocks: that is the job of
dynamic blocks (see Dynamic blocks). The key expression of an object result must be unique unless ... is
used, and a duplicate key is an error.
terraform {
required_version = ">= 1.9.0"
}
locals {
services = {
"catalog-api" = { port = 8080, tier = "backend" }
"reviews-api" = { port = 8081, tier = "backend" }
"web" = { port = 3000, tier = "frontend" }
}
names_upper = [for name, svc in local.services : upper(name)]
ports = { for name, svc in local.services : name => svc.port }
backend_names = [for name, svc in local.services : name if svc.tier == "backend"]
by_tier = { for name, svc in local.services : svc.tier => name... }
urls = [for i, name in keys(local.services) : "${i}: http://${name}.bookshelf.internal:${local.ports[name]}"]
}
output "names_upper" { value = local.names_upper }
output "ports" { value = local.ports }
output "backend_names" { value = local.backend_names }
output "by_tier" { value = local.by_tier }
output "urls" { value = local.urls }
$ terraform apply -auto-approve
...
$ terraform output -json by_tier
{"backend":["catalog-api","reviews-api"],"frontend":["web"]}
$ terraform output -json backend_names
["catalog-api","reviews-api"]
$ terraform output -json ports
{"catalog-api":8080,"reviews-api":8081,"web":3000}
Source: Terraform Docs — for Expressions.
for expressions are the glue of the language: they reshape variables into the maps that for_each needs, build
lists for dynamic blocks, derive outputs, and filter collections in locals. The most frequent pattern is
converting a list of objects into a map keyed by a stable identifier so that for_each creates addresses that do not
shift when an element is removed:
{ for svc in var.service_list : svc.name => svc }. That is the subject of
Meta-Arguments: count, for_each and
depends_on.
Splat expressions
A splat expression is a shorter way to write a common for expression. For a list var.list of objects with an id
attribute, var.list[*].id is equivalent to [for o in var.list : o.id]. The chain can continue to reach nested
values, so var.list[*].interfaces[0].name matches [for o in var.list : o.interfaces[0].name].
Splat has a second, subtler job. A non-null value that is not a list, set or tuple becomes a single-element tuple, and
null becomes an empty tuple. That makes [*] the idiomatic way to turn an optional single value into zero or one
repeated blocks, which pairs perfectly with dynamic blocks (see the next section). HashiCorp advises using this
behavior only where a collection is already implied, such as for_each, because it is not obvious to a reader.
Splat works only on lists, sets and tuples. Resources and module calls that use for_each are maps of objects, so
use a for expression: { for k, q in terraform_data.queue : k => q.output }. There is also a legacy form,
var.list.*.interfaces[0].name, restricted to attribute access: its index is applied after the iteration, so that
expression equals [for o in var.list : o.interfaces][0].name. Both Terraform and OpenTofu still accept it, and
both advise [*] in new code because the behavior is more consistent.
$ terraform console
> [ { id = "a", size = 1 }, { id = "b", size = 2 } ][*].id
[
"a",
"b",
]
> [for o in [ { id = "a" }, { id = "b" } ] : o.id]
[
"a",
"b",
]
> [ "only" ]
[
"only",
]
locals {
certificate = null # the optional TLS certificate of the web service
maybe_tls = local.certificate[*] # [] when null, a one-element tuple otherwise
}
Source: Terraform Docs — Splat Expressions.
Terraform 1.1 fixed one edge case worth knowing about: upgrading an unknown single value to a list with a splat now correctly returns an unknown value and type, where it could previously overpromise a type and fail with an inconsistency error during apply.
Dynamic blocks
Nested blocks such as ports, healthcheck or ingress are not arguments, so you cannot build them with a for
expression. A dynamic block generates repeatable nested blocks from a collection. It is supported inside resource,
data, provider and provisioner blocks.
dynamic "<BLOCK TYPE>" { # the label is the kind of nested block to generate
for_each = <COLLECTION> # required: one element per generated block
iterator = <NAME> # optional: the temporary variable, defaults to the label
labels = [<LABEL>, ...] # optional: the labels of each generated block
content { # the body of each generated block
<ARGUMENT> = <NAME>.value.<ATTRIBUTE>
}
}
Source: Terraform Docs — Dynamic Blocks.
| Part | Meaning |
|---|---|
Label |
The kind of nested block to create. |
|
The collection to iterate over. It must have one element per desired block, and can be produced by a |
|
Renames the temporary variable. Use it when names clash, for example when a nested block has the same type name as its parent. |
|
A list of strings used as the block labels; it can reference the iterator. |
|
The body of each generated block. The iterator is available inside. |
The iterator exposes key (the map key or list index) and value (the current element). For a set, key equals
value, so avoid it there. Blocks can nest: put a dynamic block inside another block’s content and give each level a
distinct iterator name so that outer.value and inner.value are unambiguous.
Limitations. You can only generate arguments and blocks that belong to the resource, data source, provider or
provisioner. It is not possible to generate meta-argument blocks such as lifecycle or provisioner, because
Terraform must process those before it evaluates expressions.
The example generates the port mappings and an optional health check of the catalog-api container. external = null
leaves the host port unset (the provider then picks a free one), and the [*] splat turns the optional healthcheck
object into zero or one blocks. The container runs locally with the kreuzwerker/docker provider.
terraform {
required_version = ">= 1.9.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6.0"
}
}
}
provider "docker" {}
variable "ports" {
description = "Port mappings of catalog-api, keyed by a short name."
type = map(object({
internal = number
external = optional(number)
}))
default = {
http = { internal = 8080, external = 18080 }
metrics = { internal = 9090 }
}
}
variable "healthcheck" {
description = "Optional container health check."
type = object({
test = list(string)
interval = optional(string, "30s")
retries = optional(number, 3)
})
default = null
}
variable "labels" {
type = map(string)
default = { tier = "backend", app = "bookshelf" }
}
resource "docker_image" "catalog_api" {
name = "nginx:1.27" # stands in for bookshelf/catalog-api
keep_locally = true
}
resource "docker_container" "catalog_api" {
name = "catalog-api"
image = docker_image.catalog_api.image_id
dynamic "ports" {
for_each = var.ports
content {
internal = ports.value.internal
external = ports.value.external # null is treated as omitted
}
}
dynamic "labels" {
for_each = var.labels
iterator = tag
content {
label = tag.key
value = tag.value
}
}
dynamic "healthcheck" {
for_each = var.healthcheck[*]
content {
test = healthcheck.value.test
interval = healthcheck.value.interval
retries = healthcheck.value.retries
}
}
}
With -var 'healthcheck={test=["CMD","curl","-f","localhost"]}' the third dynamic block yields one healthcheck
block; with the default null it yields none, and the plan shows no healthcheck section at all.
When not to use dynamic blocks
Overuse of dynamic blocks makes a configuration hard to read, and the documentation says it plainly: always write
nested blocks out literally where possible. Reach for dynamic mainly to hide detail when building a clean interface
for a reusable module, or when the number of blocks is genuinely data (rules from a variable, ports from a service
catalog). Do not use it for a fixed, small set of blocks, for blocks whose attributes differ per instance in ways the
loop cannot express, or when the module simply mirrors its input variables one-to-one: in that last case the calling
module may be a better place to define the resource. Also remember what it cannot do: no lifecycle, no provisioner
blocks, and no way to produce a top-level resource (use for_each for that).
# Prefer the literal form when there are two fixed rules.
resource "docker_container" "web" {
name = "web"
image = docker_image.web.image_id
ports {
internal = 80
external = 8080
}
ports {
internal = 443
external = 8443
}
}
Version constraints
A version constraint is a string of one or more conditions separated by commas, each an operator and a version number. Terraform only uses a version that satisfies every condition.
| Operator | Meaning |
|---|---|
|
Exactly one version. It cannot be combined with other conditions. |
|
Excludes one exact version. |
|
That version or newer ( |
|
Older than ( |
|
The pessimistic operator: only the rightmost version component may increase. |
">= 1.2.0, < 2.0.0" therefore accepts 1.2.0 and newer, but below 2.0.0. Pre-releases carry a dash suffix such as
1.2.0-beta; they are selected only by an exact version (with = or no operator), and the operators >, >=, <,
<= and ~> never match them.
Constraints appear in three places: required_version in the terraform block (the CLI itself), the version of
each entry in required_providers, and the version argument of a module block for registry modules (local paths
do not support it). The root module and child modules can each constrain; Terraform treats all constraints equally and
stops before any plan, apply or state operation if it cannot satisfy them.
terraform {
required_version = ">= 1.15.0, < 2.0.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}
module "network" {
source = "example-org/network/aws" # placeholder registry address
version = "~> 3.2"
}
Best practice. Every module should declare the minimum provider version it is known to work with, using >=. A
reusable module should not set a maximum (do not use ~> or an upper bound in modules meant for reuse): document
known incompatibilities and let the root module manage the maximum. The root module, where you run terraform apply,
should use ~> for each provider to set both a lower and an upper bound. Third-party registry modules are pinned to
an exact version so that updates happen only when you choose; modules your organization maintains and releases with
semantic versioning can use ranges. Always commit .terraform.lock.hcl so that a given configuration installs the same
provider versions everywhere (Providers).
Variables in module sources and versions
A module block’s source and version can reference constant input variables and local values, as long as every
input variable used there declares const = true. A const variable must hold a known, constant value that does not
depend on plan-time results, because Terraform needs it while loading configuration, for example during init. The
source must stay a literal string: template sequences and arbitrary expressions are not supported in it. This arrived
in Terraform 1.15 (2026-04-29); in 1.17 (2026-10-21) variables and locals are also supported in provider requirements.
variable "network_version" {
type = string
default = "~> 3.2"
const = true
}
module "network" {
source = "example-org/network/aws"
version = var.network_version
}
Terraform versus OpenTofu
The expression language is shared, and the OpenTofu documentation pages for types, strings, for expressions, splat,
dynamic blocks, conditionals and version constraints describe the same behavior as the Terraform ones. The differences
are in features added after the 2023 fork, which arrived at different times.
| Feature | Terraform | OpenTofu |
|---|---|---|
|
1.3 |
Supported, documented as above. |
|
1.9 |
1.7 |
Provider-defined functions, |
1.8 |
1.7 |
|
1.15 |
1.13 |
|
Not in the Terraform docs |
1.13 |
|
1.15 (module source and version), 1.17 (provider requirements) |
|
Module output type constraints |
1.15 (output blocks) |
On the 1.14 development line |
|
Not available |
1.11 |
|
Not described in the Terraform docs |
1.9 |
|
Not available |
1.12 |
|
Not part of the Terraform language |
Override same-named |
|
|
Both |
The .tofu mechanism is how a module consumed by both engines keeps a Terraform constraint in providers.tf and an
OpenTofu constraint in providers.tofu, which OpenTofu loads in its place (see
OpenTofu for the extension precedence rules). OpenTofu 1.12 also introduced a
language block as a more general way to define version constraints that separates OpenTofu constraints from other
software; authors should delay adopting it until they are ready to require OpenTofu 1.12 or later.
What changed recently
-
Terraform 1.1 (2021-12-08): the
nullableargument of variables, and a fix for splat on unknown single values. -
Terraform 1.3 (2022-09-21): optional attributes with defaults for object type constraints became stable.
-
Terraform 1.8 (2024-04-10): provider-defined functions (
provider::name::function) and theissensitivefunction. OpenTofu added the same syntax in 1.7. -
Terraform 1.9 (2024-06-26): the
templatestringfunction, and input variable validation rules that can refer to other objects. -
Terraform 1.10 (2024-11-27): ephemeral values and the
ephemeralasnullfunction. -
Terraform 1.15 (2026-04-29): the
convertfunction, explicit type constraints onoutputblocks, variables and locals in modulesourceandversion(withconstvariables) and thedeprecatedargument on variables and outputs. -
Terraform 1.16 (2026-08-26):
terraform consoleaccepts-scope=<module address>to evaluate expressions in the scope of a module or module instance;terraform_datagained astoreblock for ephemeral and sensitive values. -
Terraform 1.17 (2026-10-21): variables and locals in provider requirements; the
-minimal-refreshplanning option; Terraform Policy is generally available. The documentation pages for this page’s topics note no breaking change to the expression syntax itself. -
OpenTofu 1.11: the
enabledmeta-argument insidelifecycle. OpenTofu 1.12:const = true, thelanguageblock, andprevent_destroyexpressions that can use input variables. OpenTofu 1.13:convert,assume...functions, and thebase64gzipresult changing to a different but equivalent encoding, which can make a resource argument that uses it show a diff. -
Older tutorials rely on the
template_filedata source, on the legacy.*splat and on the barelistandmaptype keywords; they still work or parse, but the modern forms aretemplatefile,[*]andlist(T)/map(T). -
The books written for Terraform 1.9 and OpenTofu 1.8 predate
convert, output types,constvariables, variables in module sources, theenabledmeta-argument and thelanguageblock, so the official reference is the authority for those; where they treatanyas a convenient default type or build JSON with heredocs, the official guidance above applies instead.