Every .tf file you will ever write is made of exactly two things: blocks and arguments. That is the entire shape of the language. Once you can see any Terraform file as a tree of blocks containing arguments containing expressions, the apparent complexity collapses — resource, variable, module, provider, and the rest are all the same grammar wearing different labels. The hard part of Terraform is never the syntax; it is the expressions you put on the right-hand side of an argument: the types those values can take, the operators that combine them, the for comprehensions that reshape them, and the template strings that render them. This lesson is the complete tour of that language — the HashiCorp Configuration Language, or HCL — from the punctuation up.
This is a reference-grade lesson: the goal is that after reading it you can look at any expression in any Terraform codebase and know exactly what it evaluates to and why. We cover the block-and-argument grammar; the full set of value types and how Terraform converts between them; every operator (arithmetic, comparison, logical) and the conditional ? :; string templates including interpolation, the %{if} and %{for} directives, and heredocs; for expressions for both lists and maps with if filtering; the splat operator [*]; comments; and the terraform { } settings block including required_version. Where it matters I note OpenTofu parity — OpenTofu is the open-source fork and shares the identical HCL grammar and CLI, so everything here applies to both unless stated. Functions get their own dedicated lesson, so here we use only a handful in passing; the focus is the language itself.
Learning objectives
After working through this lesson you will be able to:
- Read any Terraform file as a tree of blocks, labels, and arguments, and explain the difference between an argument and a nested block.
- Name and use every HCL value type — the primitives (string, number, bool), the collection types (list, set, map), the structural types (tuple, object), and
null— and predict how Terraform converts between them. - Write and read expressions: literals, references, every operator (arithmetic, comparison, logical), index/attribute access, and the conditional
a ? b : c. - Build string templates: interpolation with
${ }, the%{ if }and%{ for }directives, heredocs (<<EOT), and the indentation-stripping<<-EOTform. - Transform collections with
forexpressions — producing lists and maps, filtering withif, and grouping — and collapse nested data with the splat operator[*]. - Configure the
terraform { }settings block, in particularrequired_versionand the~>constraint, and write correct comments.
Prerequisites
You need only a text editor and the terraform (or tofu) CLI installed; everything is demonstrated with terraform console, which evaluates expressions without touching any cloud, so no account and no money are required. No programming background is assumed — every term is defined where it appears — though if you have met JSON, YAML, or any scripting language a few ideas will feel familiar. This lesson sits early in the Terraform Zero-to-Hero ladder, immediately after The Terraform CLI, In Depth (which gives you the console command we lean on here) and before the providers and resources lessons; it is the language bedrock those later lessons stand on. If you have read the Terraform Fundamentals overview you have seen these constructs at a glance — this lesson is where we make them complete.
The grammar: blocks, labels, and arguments
HCL has a single, regular structure. A block is a container introduced by a block type keyword, followed by zero or more labels (quoted strings that name or qualify the block), followed by a body wrapped in braces { }. Inside the body are arguments (a name, an = sign, and a value expression) and, optionally, nested blocks (the same grammar recursing). That is all there is.
resource "aws_instance" "web" { # block type = resource; labels = "aws_instance","web"
ami = "ami-0abcd1234" # argument: name = expression
instance_type = "t3.micro" # argument
root_block_device { # nested block (no labels here)
volume_size = 20 # argument inside the nested block
}
}
Read that aloud as: “a resource block of type aws_instance named web, whose body sets the arguments ami and instance_type and contains a nested root_block_device block.” Every top-level construct in Terraform is just a block type with its own label rules. The table below is the complete set of top-level block types you will meet — the whole vocabulary of a .tf file at the outermost level.
| Block type | Labels | Purpose | Example header |
|---|---|---|---|
terraform |
none | Settings for Terraform itself (version, backend, providers). | terraform { |
provider |
one (provider name) | Configure a provider instance (region, auth). | provider "aws" { |
resource |
two (type, name) | Declare infrastructure Terraform creates/manages. | resource "aws_s3_bucket" "logs" { |
data |
two (type, name) | Read-only lookup of existing infrastructure. | data "aws_ami" "ubuntu" { |
variable |
one (variable name) | Declare an input variable. | variable "region" { |
output |
one (output name) | Declare a value to export. | output "url" { |
locals |
none | Declare one or more named local values. | locals { |
module |
one (module name) | Call a child module. | module "vpc" { |
moved |
none | Record a refactor so state follows a renamed resource. | moved { |
import |
none | Declare an existing object to import into state (1.5+). | import { |
removed |
none | Record that a resource left the config without destroying it (1.7+). | removed { |
check |
one (check name) | Define a standalone assertion / health check (1.5+). | check "health" { |
The distinction that trips up beginners is argument versus nested block, because both live inside a body and both configure the parent. The rule: an argument uses name = value (an equals sign and a single expression), while a nested block uses name { ... } (a label-less or labelled header and braces). Some schema fields accept either form for historical reasons, but the meaning differs — and for repeated nested blocks you generate them with dynamic blocks (covered in the functions and the dynamic-blocks lessons), never by copy-paste.
| Form | Syntax | When the schema wants it | Mental model |
|---|---|---|---|
| Argument | name = expr |
A single value (string, number, list, map, object…). | “Set this field to this value.” |
| Nested block | name { … } |
A structural sub-object, often repeatable. | “Add one of these child things.” |
| Meta-argument | count, for_each, provider, depends_on, lifecycle |
Special arguments Terraform itself interprets on resource/module/data. |
“Tell Terraform how to manage this block.” |
A subtle but important point: HCL is declarative and order-independent within a body. The order you write arguments and blocks in does not affect the result — Terraform builds a dependency graph from references, not from line order. You can define a local after the resource that uses it; Terraform sorts it out. (The one place order matters is multiple validation blocks and the evaluation of a for expression, both of which we meet later.)
The JSON equivalent
HCL has an exact, lossless JSON representation (files ending .tf.json) intended for machine generation, not humans. A block becomes a nested object keyed by block type then labels; arguments become object keys. You will rarely write it, but you should recognise it.
{
"resource": {
"aws_instance": {
"web": {
"ami": "ami-0abcd1234",
"instance_type": "t3.micro"
}
}
}
}
Value types: the complete set
Every expression in HCL evaluates to a value, and every value has a type. Terraform’s type system has three families — primitive, collection, and structural — plus the special value null and the special type any. Knowing the exact type of a value is what lets you predict whether an operator, a function, or a for_each will accept it. Here is the entire system in one table.
| Type | Family | What it holds | Literal example | Notes |
|---|---|---|---|---|
string |
primitive | Unicode text (UTF-8). | "hello", "t3.micro" |
Double-quoted; supports ${} interpolation and escapes. |
number |
primitive | Integer or fractional, arbitrary precision. | 42, 3.14, 1e6 |
One numeric type — no separate int/float. |
bool |
primitive | Truth value. | true, false |
Bare keywords, not strings. |
list(T) |
collection | Ordered sequence of same-type T, integer-indexed from 0. |
["a","b","c"] |
Duplicates allowed; order preserved. |
set(T) |
collection | Unordered collection of unique T. |
toset(["a","b"]) |
No indexes, no duplicates; iteration order is the canonical sort. |
map(T) |
collection | Unordered key→value where keys are strings and all values share type T. |
{ env = "prod", tier = "web" } |
Accessed by key; keys are always strings. |
tuple([T1,T2,…]) |
structural | Ordered, fixed-length sequence where each position has its own type. | ["a", 1, true] |
A list whose elements may differ in type. |
object({k=T,…}) |
structural | A collection of named attributes, each with its own type. | { name = "x", port = 8080 } |
A map whose values may differ in type; keys are fixed attribute names. |
null |
(special value) | Absence of a value. | null |
Means “unset”; an argument set to null behaves as if omitted (uses its default). |
any |
(special type) | A placeholder meaning “any type”; resolved when a concrete value is supplied. | — | Used in variable type constraints; not a value you create. |
Three pairs of these are easy to confuse, and interviewers love the distinction, so commit them to memory:
- list vs set vs tuple. A
listis ordered and may repeat and is homogeneous (list(string)). Asetis unordered and unique and homogeneous. Atupleis ordered, fixed-length, and heterogeneous — each slot has its own type. Literals written with[ ]start life as tuples; Terraform converts them to alistorsetwhen the context (a variable type, a function) demands one. - map vs object. A
map(T)requires every value to be the same typeTand treats keys as arbitrary string data you can iterate. Anobject({...})has a fixed schema — named attributes each with their own type — and is what you reach for when modelling a structured input. Literals written with{ }start life as objects; Terraform converts them to amapwhen context demands. nullvs""vs[].nullis absence (use the default / omit the argument). The empty string""and empty list[]are present but empty values. Setting an optional resource argument tonullis the supported way to say “leave this unset”; setting it to""may send an empty string to the API, which is often a different thing.
Collection element types and nesting
Collection and structural types nest arbitrarily, and you will read these compound type expressions constantly in variable blocks. Read them inside-out:
| Type expression | In words |
|---|---|
list(string) |
A list of strings. |
map(number) |
A map whose values are numbers. |
set(string) |
A set of unique strings. |
list(object({ name = string, port = number })) |
A list of objects, each with a string name and a number port. |
map(list(string)) |
A map whose values are each a list of strings. |
object({ cidr = string, public = bool, tags = map(string) }) |
One object with a string, a bool, and a map-of-string attribute. |
tuple([string, number, bool]) |
A 3-element tuple: string, then number, then bool. |
Expressions: literals, references, and access
An expression is anything that produces a value. The simplest are literals — the type examples above. Beyond literals, the everyday expressions are references (naming another object’s value) and access operators (reaching into a collection or object).
A reference addresses a value elsewhere in the configuration. The table is the complete set of reference prefixes you can write on the right-hand side of an argument.
| Reference syntax | Refers to | Example |
|---|---|---|
var.<name> |
An input variable. | var.region |
local.<name> |
A local value. | local.common_tags |
<resource_type>.<name> |
A managed resource’s attributes. | aws_instance.web.id |
<resource_type>.<name>[<idx>] |
One instance of a counted/for_each resource. |
aws_instance.web[0], aws_instance.web["eu"] |
data.<type>.<name> |
A data source’s attributes. | data.aws_ami.ubuntu.id |
module.<name>.<output> |
An output of a called module. | module.vpc.vpc_id |
each.key / each.value |
The current key/value inside a for_each block. |
each.value.cidr |
count.index |
The current index (0-based) inside a count block. |
count.index |
path.module / path.root / path.cwd |
Filesystem paths (this module / root module / working dir). | "${path.module}/files/x.sh" |
terraform.workspace |
The current CLI workspace name. | terraform.workspace |
self.<attr> |
The current resource’s own attribute (only inside provisioners/connection). | self.private_ip |
Access operators reach inside a value. There are two notations, and both ultimately do index/key lookup:
| Operator | Form | Works on | Example | Result |
|---|---|---|---|---|
| Index | expr[N] |
list/tuple (integer N), map/object (string N) | ["a","b"][1] |
"b" |
| Attribute (dot) | expr.name |
object/map (and resource attributes) | {a=1,b=2}.a |
1 |
| Map key (bracket) | expr["key"] |
map/object | {env="prod"}["env"] |
"prod" |
Use bracket syntax ["..."] when a map key contains characters that are not valid identifiers (dots, dashes, spaces) — local.tags["my-team:owner"]. Use dot syntax for object attributes and resource attributes. They are interchangeable for simple identifier keys.
A safety note: indexing a list out of range, or accessing a map key that does not exist, is an error that fails the plan — not a silent null. To access “maybe missing” data safely you use the lookup() function with a default, or try(), both covered in the functions lesson. The optional() modifier on object types (also a later topic) is how you make object attributes themselves omittable.
Operators: the complete table
HCL has a fixed, small set of operators — there is no operator overloading and no way to define your own. The table below is every operator in the language, grouped by kind, in roughly descending precedence. Parentheses ( ) override precedence as you would expect.
| Operator | Kind | Operand types | Result | Example → value |
|---|---|---|---|---|
! |
logical NOT | bool | bool | !true → false |
- (unary) |
arithmetic negate | number | number | -5 → -5 |
* |
multiply | number, number | number | 6 * 7 → 42 |
/ |
divide | number, number | number | 10 / 4 → 2.5 |
% |
modulo (remainder) | number, number | number | 10 % 3 → 1 |
+ |
add | number, number | number | 2 + 3 → 5 |
- (binary) |
subtract | number, number | number | 9 - 4 → 5 |
> |
greater than | number, number | bool | 3 > 2 → true |
>= |
greater or equal | number, number | bool | 3 >= 3 → true |
< |
less than | number, number | bool | 2 < 3 → true |
<= |
less or equal | number, number | bool | 2 <= 1 → false |
== |
equal | any, any | bool | "a" == "a" → true |
!= |
not equal | any, any | bool | 1 != 2 → true |
&& |
logical AND | bool, bool | bool | true && false → false |
|| |
logical OR | bool, bool | bool | false || true → true |
? : |
conditional (ternary) | bool ? any : any | any | true ? "yes" : "no" → "yes" |
A few rules that catch people out:
- There is no string concatenation operator.
+is numeric only —"a" + "b"is an error. To join strings you use interpolation ("${a}${b}") or thejoin()/format()functions. - Division of two integers can yield a fraction.
10 / 4is2.5, because there is only one number type. Usefloor()/ceil()if you need integer division behaviour. ==and!=compare by value and type.1 == "1"isfalse— a number is not equal to a string even if they look alike. Comparisons of collections compare element-by-element.&&and||short-circuit, but be careful: in a conditional, both the true and false result expressions must be valid (type-checkable) even though only one is evaluated. The classic guard islength(var.list) > 0 ? var.list[0] : null.- Operands are converted where unambiguous. Comparison and arithmetic will convert a string like
"5"to a number if the other operand forces it, following the conversion rules below — but relying on that is poor style.
The conditional expression in depth
The ternary condition ? true_val : false_val is the only branching construct in an expression, and you will use it everywhere — to toggle a count, choose a SKU, or supply a default. The condition must be a bool; the two result expressions should be of a compatible type (Terraform will find a common type, converting if needed). It is the idiomatic replacement for the “if/else” you might want but HCL does not have at expression level.
locals {
instance_type = var.is_production ? "m5.large" : "t3.micro"
replica_count = var.high_availability ? 3 : 1
# Common "default if empty" guard — note both branches type-check:
name = var.name != "" ? var.name : "default-${terraform.workspace}"
}
Because both branches are always type-checked, a conditional whose branches disagree on type (one returns a string, the other a list) is an error. When you genuinely need different shapes, push the choice into the data, not the conditional.
String templates: interpolation, directives, heredocs
A string template is a string literal that contains embedded expressions or control directives, evaluated to produce the final text. This is HCL’s only “templating” mechanism inside the language (file-based templating uses templatefile(), a later topic). There are two embedded constructs — interpolation with ${ … } and directives with %{ … } — plus the heredoc forms for multi-line strings.
Interpolation embeds the value of any expression into a string. The expression inside ${ } is evaluated and converted to a string.
"Hello, ${var.name}!" # → "Hello, alice!"
"${var.env}-${var.app}-bucket" # → "prod-web-bucket"
"Subnet ${count.index} in ${var.az}" # → "Subnet 0 in eu-west-1a"
To output a literal ${ or %{ (so Terraform does not treat it as a directive), double the first symbol: $${not_interpolated} renders as ${not_interpolated}, and %%{literal} renders %{literal}. Inside double-quoted strings the usual escape sequences apply, summarised here:
| Escape | Renders |
|---|---|
\n |
newline |
\r |
carriage return |
\t |
tab |
\" |
literal double quote |
\\ |
literal backslash |
\uNNNN |
Unicode code point (4 hex digits) |
\u{NNNNNN} |
Unicode code point (1–6 hex digits) |
$${ |
literal ${ (escapes interpolation) |
%%{ |
literal %{ (escapes a directive) |
Directives add control flow inside a string: a conditional %{ if }/%{ else }/%{ endif } and a loop %{ for }/%{ endfor }. They are how you conditionally include or repeat text within a single string value.
| Directive | Form | Effect |
|---|---|---|
if |
%{ if cond }A%{ else }B%{ endif } |
Emit A if cond is true, else B (the else is optional). |
for |
%{ for x in coll }…${x}…%{ endfor } |
Repeat the body once per element. |
| Strip markers | %{~ … ~} |
A ~ next to the marker trims adjacent whitespace/newline — essential for clean multi-line output. |
# Conditional inside a string:
"Welcome${var.vip ? ", valued customer" : ""}"
# Loop inside a string (build an /etc/hosts fragment) with whitespace stripping:
<<-EOT
%{ for ip, name in var.hosts ~}
${ip} ${name}
%{ endfor ~}
EOT
The ~ strip markers matter: without them the %{ for } and %{ endfor } lines would each leave a blank line in the output. %{ for ~} strips the newline before the marker; %{~ endfor } strips the newline after. Getting these right is the difference between a clean generated config and one littered with blank lines.
Heredocs let you write multi-line strings without escaping newlines. You open with << followed by a delimiter word, put your content on the following lines, and close with the same delimiter alone on its own line. The indented heredoc <<- additionally strips the leading whitespace common to all lines, so you can indent the heredoc to match its surroundings.
| Form | Syntax | Behaviour |
|---|---|---|
| Standard heredoc | <<EOT … EOT |
Content kept verbatim, including leading indentation. The closing word must be at column 0. |
| Indented heredoc | <<-EOT … EOT |
Strips the smallest common leading indentation from every line; closing word may be indented. |
user_data = <<-EOT
#!/bin/bash
echo "host=${var.hostname}" >> /etc/myapp.conf
systemctl restart myapp
EOT
The delimiter word is conventionally EOT (“end of text”) or EOF, but any identifier works. Interpolation and directives are active inside heredocs exactly as in normal strings.
for expressions: reshaping collections
The for expression is the workhorse of HCL — a comprehension that transforms one collection into another. It is not the same as the for_each meta-argument (which creates resource instances); a for expression is a value-producing expression you can put anywhere a value is expected. It comes in two flavours determined by the surrounding brackets: [ for … ] produces a tuple/list, and { for … } produces an object/map.
The anatomy: for <vars> in <input> : <result> with an optional if <condition> filter at the end. When iterating a list/set/tuple you get one variable (the element); when iterating a map/object you get two (key, value) — or use one to get just the index/key.
| Goal | Form | Example | Result |
|---|---|---|---|
| Transform a list → list | [for x in list : expr] |
[for s in ["a","b"] : upper(s)] |
["A","B"] |
| List → list with index | [for i, x in list : "${i}:${x}"] |
[for i, s in ["a","b"] : "${i}=${s}"] |
["0=a","1=b"] |
| List → map | {for x in list : key => val} |
{for s in ["a","bb"] : s => length(s)} |
{a=1, bb=2} |
| Map → map | {for k, v in map : k => expr} |
{for k, v in {a=1} : k => v*10} |
{a=10} |
| Map → list (values) | [for k, v in map : v] |
[for k, v in {a=1,b=2} : v] |
[1,2] |
| Filter | [for x in list : x if cond] |
[for n in [1,2,3,4] : n if n % 2 == 0] |
[2,4] |
| Group (many → list per key) | {for x in list : key => x... } |
see below | groups duplicates |
The if filter keeps only elements where the condition is true — the comprehension equivalent of WHERE. The grouping mode, signalled by ... after the value expression in a { for }, collects all elements that map to the same key into a list, instead of erroring on duplicate keys:
# Without ... duplicate keys are an error. With ... they group into lists:
variable "instances" {
default = [
{ name = "web1", tier = "web" },
{ name = "web2", tier = "web" },
{ name = "db1", tier = "db" },
]
}
locals {
by_tier = { for inst in var.instances : inst.tier => inst.name... }
# → { web = ["web1","web2"], db = ["db1"] }
}
A critical rule for { for } producing a map: the keys must be unique (unless you use the grouping ...), and keys are always converted to strings. The result of a { for } is an object, which is interchangeable with a map in most contexts. The result of a [ for ] is a tuple, interchangeable with a list.
You can also iterate to produce nested structures and combine for with the splat and functions like flatten() to handle “list of objects each containing a list” shapes — the canonical pattern for, say, expanding subnets across availability zones. That advanced reshaping (flatten/setproduct) is drilled in the functions and dynamic-blocks lessons; here, internalise the four core shapes in the table.
The splat operator [*]
The splat operator is a concise shorthand for a common for expression: pulling one attribute out of every element of a list. list[*].attr means “the attr of each element” — exactly equivalent to [for x in list : x.attr], but shorter and very common when working with counted resources.
| Expression | Equivalent for |
Meaning |
|---|---|---|
aws_instance.web[*].id |
[for x in aws_instance.web : x.id] |
All instance IDs of a counted resource. |
var.users[*].name |
[for u in var.users : u.name] |
The name of every user object. |
aws_instance.web[*] |
[for x in aws_instance.web : x] |
Every instance (the whole list). |
There are two splat variants. The modern [*] (the “splat operator”) works on lists, sets, and tuples. There is also a legacy attribute-only splat .* (e.g. aws_instance.web.*.id) retained for backward compatibility — prefer [*]. A handy quirk: applied to a value that is not a list, [*] wraps it in a single-element list (and applied to null yields []), which makes it useful for normalising “scalar or list” inputs. The splat does not work on maps/objects — for those you use a for expression to pull values.
Type conversion rules
Terraform automatically converts values between types when the context makes the target type unambiguous — for example, when you pass a tuple literal ["a","b"] to a variable declared list(string), or compare a number to a string. Knowing these rules removes most “Inappropriate value for attribute” surprises. The table is the complete set of automatic conversions.
| From → To | Rule | Example |
|---|---|---|
bool → string |
true→"true", false→"false". |
"${true}" → "true" |
number → string |
Decimal representation. | "${42}" → "42" |
string → number |
If it parses as a number; else error. | tonumber("5") → 5 |
string → bool |
"true"/"false" only (and "1"/"0" via tobool); else error. |
tobool("true") → true |
tuple → list |
If all elements convert to a common element type. | ["a","b"] as list(string) |
tuple/list → set |
Drops order and duplicates. | toset(["a","a"]) → one "a" |
set → list/tuple |
Gains order = canonical sort. | tolist(toset(["b","a"])) → ["a","b"] |
object → map |
If all attribute values convert to a common type. | {a=1,b=2} as map(number) |
map → object |
When a fixed schema is required and keys match attributes. | passing a map to an object(...) variable |
any → any |
Identity; no conversion. | — |
anything ↔ null |
null is assignable to any optional position; converting null to a non-optional type is an error. |
argument = null |
Two practical consequences. First, literals are flexible: a [ ] literal is a tuple and a { } literal is an object, and Terraform converts them to list/set/map on demand — which is why you can write ["a","b"] for a list(string) variable without calling tolist(). Second, explicit conversion functions exist for when context is not enough or you want to be deliberate: tostring, tonumber, tobool, tolist, toset, tomap. These are the functions to reach for when you need to force a type, and try()/can() are how you handle conversions that might fail. The full function catalogue is the next-but-one lesson.
Comments and formatting
HCL supports three comment styles. Use them; future-you and your reviewers will thank you.
| Style | Syntax | Scope |
|---|---|---|
| Hash line | # comment to end of line |
Single line (the idiomatic, preferred style). |
| Double-slash line | // comment to end of line |
Single line (equivalent to #). |
| Block | /* … */ |
Multiple lines (cannot nest). |
The canonical style is #; terraform fmt will rewrite // single-line comments to # automatically (it leaves /* */ blocks alone). On formatting generally: terraform fmt is the official formatter — it normalises indentation (two spaces), aligns the = in consecutive arguments, and orders meta-arguments. Run it before every commit; the alignment the tool produces is the de-facto house style and most CI pipelines enforce it.
locals {
# Hash comment — the preferred style.
region = "eu-west-1" // also valid; fmt converts this to '#'
/* A block comment can
span several lines. */
enabled = true
}
The terraform { } settings block and required_version
The one block that configures Terraform itself — rather than any infrastructure — is the top-level terraform { } block. It takes no labels and may appear once per module (its contents are merged if split, but keep it in one place). Inside it live the settings that govern how this configuration is run: which Terraform versions may run it, which providers it needs, where state lives, and which experimental features are on.
Setting (inside terraform {}) |
Form | Purpose |
|---|---|---|
required_version |
required_version = ">= 1.9.0" |
Constrain which Terraform CLI versions may run this config. |
required_providers |
nested map block | Declare each provider’s source and version constraint. |
backend |
nested block, e.g. backend "s3" {} |
Configure remote state storage and locking. |
cloud |
nested block | Configure Terraform Cloud / HCP Terraform (alternative to backend). |
experiments |
experiments = [ … ] |
Opt into experimental language features (rare; version-specific). |
provider_meta |
nested block per provider | Pass module-level metadata to a provider (advanced/rare). |
The setting most relevant to this lesson is required_version, which gates the CLI version, and the version constraint syntax it shares with required_providers. A constraint is a comma-separated list of conditions, all of which must hold. The operators are below — memorise the pessimistic ~>, because it is both the most useful and the most misread.
| Operator | Meaning | ~> example expands to |
|---|---|---|
= (or bare) |
Exactly this version. | = 1.9.0 |
!= |
Any version except this one. | != 1.8.0 |
>, >=, <, <= |
Comparison — newer/older than. | >= 1.5.0 |
~> |
Pessimistic: allow the rightmost component to increase, lock the rest. | ~> 1.9 ⇒ >= 1.9.0, < 2.0.0; ~> 1.9.2 ⇒ >= 1.9.2, < 1.10.0 |
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws" # registry address: registry.terraform.io/hashicorp/aws
version = "~> 5.40" # >= 5.40.0 and < 6.0.0
}
}
}
The pessimistic constraint ~> is the rule to internalise: it pins everything to the left of the last component and lets the last component float upward. ~> 1.9 permits any 1.x from 1.9 up but not 2.0; ~> 1.9.2 permits any 1.9.x from 1.9.2 up but not 1.10. Use ~> on the minor (~> 5.40) for libraries and providers so you get bug fixes without surprise majors. required_version accepts the same operators but, unlike providers, has no lock file — only the live constraint protects you, so be explicit. OpenTofu honours required_version too; if you support both, keep the lower bound conservative.
Diagram
The diagram traces a single .tf file from the outside in: the block (type + labels + body), the arguments inside it, and the expression on each argument’s right-hand side — which is in turn built from literals of the various types, references, operators, the conditional, string templates, and for/splat comprehensions, with the type-conversion rules sitting between literal forms and their declared types.
Hands-on lab
This lab uses terraform console — an interactive expression evaluator that touches no cloud, costs nothing, and is the best way to build intuition for HCL. Everything runs locally.
1. Create a tiny configuration so console has variables and locals to read. In an empty directory create main.tf:
terraform {
required_version = ">= 1.9.0"
}
variable "regions" {
type = list(string)
default = ["eu-west-1", "us-east-1", "ap-south-1"]
}
variable "instances" {
type = list(object({
name = string
tier = string
port = number
}))
default = [
{ name = "web1", tier = "web", port = 80 },
{ name = "web2", tier = "web", port = 80 },
{ name = "db1", tier = "db", port = 5432 },
]
}
locals {
env = "prod"
is_prod = local.env == "prod"
bucket_name = "${local.env}-app-logs"
web_ports = [for i in var.instances : i.port if i.tier == "web"]
names_by_tier = { for i in var.instances : i.tier => i.name... }
}
2. Initialise and open the console. init here just prepares the working directory; there are no providers to download.
terraform init
terraform console
3. Evaluate expressions at the > prompt — try each and predict the answer first:
> 6 * 7
42
> 10 / 4
2.5
> 10 % 3
1
> "a" == "a"
true
> 1 == "1"
false
> local.is_prod ? "m5.large" : "t3.micro"
"m5.large"
> "${local.env}-web-bucket"
"prod-web-bucket"
> upper("hello")
"HELLO"
> length(var.regions)
3
> var.regions[0]
"eu-west-1"
> [for r in var.regions : upper(r)]
[
"EU-WEST-1",
"US-EAST-1",
"AP-SOUTH-1",
]
> { for i in var.instances : i.name => i.port }
{
"db1" = 5432
"web1" = 80
"web2" = 80
}
> var.instances[*].name
[
"web1",
"web2",
"db1",
]
> local.web_ports
[
80,
80,
]
> local.names_by_tier
{
"db" = [
"db1",
]
"web" = [
"web1",
"web2",
]
}
> tolist(toset(["b","a","b"]))
[
"a",
"b",
]
> type(var.instances)
list(
object({
name: string,
port: number,
tier: string,
}),
)
4. Test type conversion and the null/empty distinction:
> tostring(42)
"42"
> tonumber("5") + 1
6
> coalesce(null, "", "fallback")
""
> coalesce(null, null, "fallback")
"fallback"
The type() and coalesce() lines show two lessons at once: type() reports the full inferred type (note the object element type), and coalesce() returns its first non-null argument — so "" (present-but-empty) wins over the later "fallback", illustrating that null and "" are different.
5. Validation (no cloud): outside the console, prove the file is well-formed:
terraform fmt -check # exits non-zero if formatting is off; run `terraform fmt` to fix
terraform validate # "Success! The configuration is valid."
Expected output. Each console line prints the value shown. terraform validate prints Success! The configuration is valid.
Cleanup. There is nothing in any cloud to destroy. Type exit (or Ctrl-D) to leave the console, then rm -rf .terraform .terraform.lock.hcl terraform.tfstate* if you want a pristine directory. Delete main.tf to finish.
Cost note. ₹0 / $0. terraform console, fmt, and validate are entirely local — no provider is configured and nothing is provisioned. This is the cheapest possible way to learn the language, which is exactly why it is the recommended sandbox.
Common mistakes & troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Invalid template interpolation value / string is wrong |
Using + to join strings, or forgetting ${ }. |
There is no string +; use interpolation "${a}${b}" or join/format. |
${...} appears literally in output |
You escaped it ($${) or are inside a context that double-renders. |
Use a single ${ }; only double $$ when you want a literal. |
Duplicate object key from a { for } |
Two iterations produced the same key. | Make keys unique, or use the grouping form => value.... |
Inappropriate value for attribute "x": list of string required |
Passed a tuple of mixed types where list(string) expected. |
Make elements one type, or fix the variable’s type. |
Invalid index / call to ... index out of range |
Indexed past the end of a list or a missing map key. | Guard with length()/lookup()/try(); remember access errors, not nulls. |
Blank lines in heredoc/%{for} output |
Missing ~ strip markers on the directives. |
Add ~: %{ for x in c ~} … %{ endfor ~}. |
Unsupported argument though the field exists |
Wrote a nested block as an argument (or vice versa). | Match the schema: name = … for arguments, name { … } for blocks. |
| Conditional fails to type-check | The two branches of ? : return different types. |
Make both branches the same type, or restructure. |
Error: Unsupported Terraform Core version |
required_version excludes your installed CLI. |
Install a matching version (tfenv/tfswitch) or relax the constraint. |
Best practices
- Run
terraform fmton every save and enforce it in CI. The aligned, two-space style it produces is the universal house style and keeps diffs about logic, not whitespace. - Prefer interpolation and functions over clever operators. Readable
"${var.env}-${var.app}"beats anything exotic; remember there is no string+. - Use the right collection type.
setfor “unique, order doesn’t matter”,listfor “ordered, may repeat”,map/objectfor keyed data. Choosing correctly makesfor_eachandforexpressions natural later. - Reach for
nullto mean “unset”. Set optional arguments tonull(often via a conditional) rather than""or0, so the provider applies its own default. - Keep
forexpressions shallow and named. If a comprehension grows hairy, compute it once in alocalwith a descriptive name instead of inlining it three times. - Comment the why, not the what. Use
#; explain non-obvious constraints and trade-offs, not what the line literally does. - Pin versions with
~>on the minor.required_versionand provider constraints with~> X.Ygive you fixes without surprise majors.
Security notes
The language itself has a few security-relevant edges. Interpolating untrusted input into a heredoc that becomes a shell script (user_data, local-exec) is a command-injection vector — validate and quote anything that originates outside your config. Sensitive values flow through expressions: marking a variable sensitive = true causes Terraform to redact it in plan/apply output, but the value still lands in state in plaintext, and naively interpolating a secret into a non-sensitive string or output can leak it past the redaction — keep secrets in dedicated, sensitive-marked paths and never build resource names or tags out of them. Finally, heredocs and templates make it easy to embed credentials inline (“just this once”); don’t — reference a secrets manager data source instead, a pattern the secrets-in-IaC lesson covers.
Interview & exam questions
1. What are the two fundamental building blocks of HCL, and how do they differ?
Blocks (a type keyword, optional labels, and a { } body) and arguments (name = expression). Blocks are containers — including nested blocks — while arguments assign a single value to a field. Everything in a .tf file is one or the other.
2. List Terraform’s value types by family.
Primitives: string, number, bool. Collections (homogeneous): list, set, map. Structural (heterogeneous): tuple, object. Plus the special value null and the special type any.
3. What is the difference between a list, a set, and a tuple?
A list is ordered, allows duplicates, and is homogeneous. A set is unordered and unique and homogeneous. A tuple is ordered and fixed-length but heterogeneous — each position has its own type. [ ] literals are tuples until converted.
4. Map versus object — when do you use each?
A map(T) has arbitrary string keys all mapping to the same type T and is iterable like a dictionary. An object({...}) has a fixed schema of named attributes each with its own type — used to model structured inputs. { } literals are objects until converted to a map.
5. Why is "a" + "b" an error, and how do you concatenate strings?
+ is strictly numeric — HCL has no string-concatenation operator. Use interpolation "${a}${b}" or the join()/format()/concat() functions.
6. Explain the conditional expression and the “both branches type-check” rule.
cond ? t : f returns t if cond is true else f. Although only one branch is evaluated, both must be valid and of a compatible type at plan time, which is why guards like length(x) > 0 ? x[0] : null are written carefully.
7. What does the pessimistic constraint ~> 1.9.2 permit?
>= 1.9.2, < 1.10.0 — it locks all components except the last and lets the last float up. ~> 1.9 would permit >= 1.9.0, < 2.0.0.
8. What is the difference between ${ } and %{ } in a template, and how do you escape them?
${ } is interpolation (embed an expression’s value); %{ } is a directive (if/for control flow inside a string). Escape them by doubling the first symbol: $${ and %%{.
9. Write a for expression that turns a list of objects into a map keyed by name.
{ for o in var.objs : o.name => o }. If name could repeat, add grouping: { for o in var.objs : o.tier => o.name... } to collect a list per key.
10. What does aws_instance.web[*].id mean, and what is it shorthand for?
The splat: the id of every instance of the counted/for_each resource aws_instance.web — equivalent to [for x in aws_instance.web : x.id]. Splat works on lists/sets/tuples, not maps.
11. What happens when you index a list out of range or read a missing map key?
It is a plan-time error, not a silent null. Guard with length(), lookup(map, key, default), or try().
12. What is the difference between null, "", and []?
null is absence (use the default / treat as unset); "" is a present empty string; [] is a present empty list. Setting an optional argument to null is the supported “leave unset”; ""/[] send a real empty value.
Quick check
- Which bracket style does a
forexpression need to produce a map:[ for … ]or{ for … }? - True or false: the order in which you write arguments in a block changes the result.
- What does
<<-EOTdo that<<EOTdoes not? - Convert
["b","a","b"]to a value with no duplicates and predictable order — which function, and what is the result? - What does
~> 5.40expand to as a pair of comparisons?
Answers
{ for … }— curly braces produce an object/map; square brackets produce a tuple/list.- False. HCL is order-independent within a body; Terraform orders work by the dependency graph, not line order.
- The indented heredoc
<<-strips the smallest common leading indentation from every line (and lets the closing word be indented), so you can indent the block to match its surroundings;<<EOTkeeps everything verbatim and needs the closing word at column 0. toset(["b","a","b"])→ asetcontaining"a"and"b"; converting back withtolist(...)yields["a","b"]in canonical (sorted) order.>= 5.40.0, < 6.0.0.
Exercise
In a scratch directory, create a variable "services" of type list(object({ name = string, tier = string, replicas = number, public = bool })) with four entries spanning at least two tiers. Then, using only terraform console and locals, produce all of the following and verify each value:
- A list of every service
name(use a splat). - A map from service
nameto itsreplicas. - A filtered list of names where
public == true. - A grouped map from
tierto the list of names in that tier (use...). - A single string of the form
"<name>:<replicas>"for the first service, built with interpolation. - A count toggle:
length([for s in var.services : s if s.public]) > 0 ? "has-public" : "private-only".
Run terraform fmt and terraform validate to confirm the file is well-formed, then clean up with rm -rf .terraform*. The goal is to exercise splat, both for shapes, filtering, grouping, interpolation, and a conditional — the whole expression toolkit — without provisioning anything.
Certification mapping
This lesson maps to the HashiCorp Certified: Terraform Associate (003) exam, principally the objective Read, generate, and modify configuration — the blocks-and-arguments grammar, the value types, expressions and operators, the conditional, string templates and interpolation, for expressions, and the splat. It also supports Understand Terraform basics (the terraform {} settings block, required_version, and the version-constraint syntax including ~>) and Use the Terraform CLI (the terraform console, fmt, and validate commands exercised in the lab). The Associate exam frequently tests the ~> constraint, the difference between collection and structural types, and what a for or splat expression evaluates to — all covered here. The companion Terraform Associate Prep Kit lesson drills these as practice questions.
Glossary
- HCL — HashiCorp Configuration Language; the declarative language of
.tffiles, made of blocks and arguments. Has an exact JSON form (.tf.json). - Block — A container introduced by a block type keyword, optional labels, and a
{ }body (e.g.resource "aws_s3_bucket" "x" { }). - Label — A quoted string after a block type that names/qualifies the block (resources take two: type and name).
- Argument — A
name = expressionassignment inside a block body. - Expression — Anything that produces a value: a literal, reference, operator combination, conditional, template, or
for. - Primitive type —
string,number, orbool. - Collection type —
list(T),set(T), ormap(T); homogeneous (all elements one type). - Structural type —
tuple([...])orobject({...}); heterogeneous (each position/attribute its own type). null— The value meaning absence; an argument set tonullbehaves as if omitted.any— A type placeholder meaning “any type”, used in variabletypeconstraints.- Interpolation — Embedding an expression’s value in a string with
${ }. - Directive — In-string control flow with
%{ if }/%{ for }…%{ endif }/%{ endfor }. - Heredoc — A multi-line string literal opened with
<<DELIM(or<<-DELIMto strip common indentation) and closed byDELIM. forexpression — A comprehension that transforms a collection into a tuple ([for…]) or object ({for…}), optionally filtered withif.- Splat —
list[*].attr, shorthand for pulling one attribute from every element of a list. - Conditional expression — The ternary
cond ? a : b; HCL’s only expression-level branch. - Type conversion — Terraform’s automatic coercion between compatible types where context makes the target unambiguous; forced with
tostring/tolist/tomap/etc. - Pessimistic constraint (
~>) — A version constraint that locks all components except the rightmost, which may increase. required_version— Theterraform {}setting constraining which CLI versions may run the config (no lock file backs it).- OpenTofu — The Linux Foundation open-source fork of Terraform; identical HCL grammar and CLI commands.
Next steps
You can now read and write any HCL expression — the grammar, the full type system, every operator and the conditional, string templates, for and splat, the conversion rules, and the terraform {} settings block. The next thing those expressions need is something to act on: providers. Continue with Terraform Providers, In Depth: required_providers, Versions, Aliases & the Lock File, which takes the required_providers block and ~> constraint you just met and goes deep on provider sources, multiple aliased instances, and the dependency lock file. When you are ready to push HCL further — generating repeated nested blocks, validating inputs, and modelling complex object types — see Dynamic Blocks, Complex Types & Validation.