Terraform Lesson 3 of 57

HCL, In Depth: Blocks, Arguments, Expressions, Types & Templates

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:

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:

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 !truefalse
- (unary) arithmetic negate number number -5-5
* multiply number, number number 6 * 742
/ divide number, number number 10 / 42.5
% modulo (remainder) number, number number 10 % 31
+ add number, number number 2 + 35
- (binary) subtract number, number number 9 - 45
> greater than number, number bool 3 > 2true
>= greater or equal number, number bool 3 >= 3true
< less than number, number bool 2 < 3true
<= less or equal number, number bool 2 <= 1false
== equal any, any bool "a" == "a"true
!= not equal any, any bool 1 != 2true
&& logical AND bool, bool bool true && falsefalse
|| logical OR bool, bool bool false || truetrue
? : conditional (ternary) bool ? any : any any true ? "yes" : "no""yes"

A few rules that catch people out:

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 <<EOTEOT Content kept verbatim, including leading indentation. The closing word must be at column 0.
Indented heredoc <<-EOTEOT 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
boolstring true"true", false"false". "${true}""true"
numberstring Decimal representation. "${42}""42"
stringnumber If it parses as a number; else error. tonumber("5")5
stringbool "true"/"false" only (and "1"/"0" via tobool); else error. tobool("true")true
tuplelist If all elements convert to a common element type. ["a","b"] as list(string)
tuple/listset Drops order and duplicates. toset(["a","a"]) → one "a"
setlist/tuple Gains order = canonical sort. tolist(toset(["b","a"]))["a","b"]
objectmap If all attribute values convert to a common type. {a=1,b=2} as map(number)
mapobject 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

HCL syntax: blocks, arguments, expressions and the type system

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

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

  1. Which bracket style does a for expression need to produce a map: [ for … ] or { for … }?
  2. True or false: the order in which you write arguments in a block changes the result.
  3. What does <<-EOT do that <<EOT does not?
  4. Convert ["b","a","b"] to a value with no duplicates and predictable order — which function, and what is the result?
  5. What does ~> 5.40 expand to as a pair of comparisons?

Answers

  1. { for … } — curly braces produce an object/map; square brackets produce a tuple/list.
  2. False. HCL is order-independent within a body; Terraform orders work by the dependency graph, not line order.
  3. 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; <<EOT keeps everything verbatim and needs the closing word at column 0.
  4. toset(["b","a","b"]) → a set containing "a" and "b"; converting back with tolist(...) yields ["a","b"] in canonical (sorted) order.
  5. >= 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:

  1. A list of every service name (use a splat).
  2. A map from service name to its replicas.
  3. A filtered list of names where public == true.
  4. A grouped map from tier to the list of names in that tier (use ...).
  5. A single string of the form "<name>:<replicas>" for the first service, built with interpolation.
  6. 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

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.

TerraformHCLExpressionsTypesOpenTofuIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments