Skip to content
beginner Phase 2 · HCL Syntax and Expression Language Deep Dive

Block Structure and Nesting

Recognizing labeled blocks, body attributes, and how Terraform nests configuration.

45m
0 problems
Topic Progress 0%

One Grammar, Four Parts: Reading Any .tf File as Blocks

Every Terraform configuration file is a body of attributes and blocks, and the whole language is learnable from the shape of those two things. An attribute is name = expression — one equals sign, one expression, terminated by a newline. A block is a type keyword, zero or more labels, and a braced body that is itself another collection of attributes and blocks:

resource "aws_instance" "web" {          # block type + two labels + body
  ami           = "ami-0c55b159cbfafe1f0"  # attribute
  instance_type = "t3.micro"               # attribute

  root_block_device {                      # nested block (no labels here)
    volume_size = 20
  }
}

The block header is resource "aws_instance" "web". Its first token is the block type — the keyword Terraform's parser looks for to decide what this object is. Then come the labels, and the count is not up to you: the parser and Terraform's own schema decide how many a given block type accepts. resource always takes exactly two (you will see why in Resource Block Anatomy); variable takes exactly one; locals and terraform take none. Labels are usually quoted strings, though the grammar also allows bare identifiers — quoting is the style everyone uses, and quoted labels support characters like dashes that a bare identifier would not.

Inside the body, you will always find two kinds of content, and knowing which is which resolves half of all beginner syntax errors. Attributes assign a single expression to a name and can appear at most once per body — repeat ami = "x" twice in one resource and Terraform rejects the file with an argument-redefined error, not a silent last-one-wins. Nested blocks like root_block_device { ... } open their own body, may repeat if the provider schema allows (security groups declare many ingress blocks), and can nest further. The rule of thumb: if the provider's documentation shows it repeating with different values, it is a block; if it takes one value, it is an attribute. A useful edge case: a block may also be written on one line — root_block_device { volume_size = 20 } — which the spec calls a one-line block; handy for tiny blocks, a readability trap for large ones.

Newlines are structural, not cosmetic. Statements are terminated by newlines, which is why you cannot naively wrap a long attribute across lines the way you would in JavaScript — though inside brackets, braces, and parentheses newlines are treated as plain whitespace, so collection literals and function calls may span lines freely. This one fact explains errors like "Missing newline after argument" that appear when a line break lands in the wrong place. One more nesting rule that surprises people coming from other languages: blocks do not take expressions for their labels or headers — you cannot interpolate into a block type name, and while labels are strings, they are static strings. Generating a variable number of blocks from data therefore cannot be done by writing a loop around a block in HCL; it takes the dynamic block mechanism (or count/for_each on whole blocks), which you will meet when provider schemas demand repetition.

Read any .tf file top-down with this grammar — block types, label counts, attributes versus nested blocks — and you are reading Terraform's object model directly, because that is exactly what these files are.

Which Top-Level Blocks May Repeat, and How Many Labels Each Demands

A working directory may contain many .tf files, and Terraform concatenates them all into one logical configuration before doing anything — file boundaries are for humans, not for the engine. What matters is which block types the parser accepts at the top level, how many times, and with how many labels. This table is worth memorizing as a reference, because misreading it produces the two most common structural errors:

Block type Labels May repeat? Creates
terraform none once per directory version/provider/backend settings
provider "aws" none (use alias) many API connection config
variable "name" one once per name an input
locals none many blocks, unique names internal values
output "name" one once per name a published value
module "name" one once per name a child module call
resource "type" "name" two once per type+name pair a managed object
data "type" "name" two once per type+name pair a read-only lookup
moved, check, import varies as needed state moves, checks, imports

Notice what repeats freely and what does not. Two variable "region" blocks in one directory fail validation — inputs are named globally across the concatenated configuration, so their names must be unique; two resource "aws_instance" "web" blocks fail for the same reason at the resource level: the address aws_instance.web must identify exactly one object in state. But you may have fifty resources, each with a unique type+name pair, spread over a dozen files — a layout teams exploit for organization (network.tf, compute.tf, variables.tf), with zero effect on behavior.

Provider blocks deserve their exception. provider "aws" { region = "eu-west-1" } takes no label; additional configurations of the same provider are distinguished by an alias argument instead, and resources select between them with the provider meta-argument:

provider "aws" { region = "eu-west-1" }
provider "aws" { region = "us-east-1", alias = "us" }

resource "aws_route53_zone" "primary" {}                       # default config
resource "aws_s3_bucket" "logs" { provider = aws.us }          # aliased config

Aliases are how one directory manages resources in several regions or accounts at once — an identity question that returns when you study meta-arguments in Attributes, References, and Meta-arguments.

The repetition rules also explain two errors you will meet in shared codebases. "Duplicate variable definition" appears when two files each declare variable "region" — a merge of two module branches copy-pasted without renaming, caught at validate time before anything runs. And an "Argument or block definition required" error at the top level usually means a block header is malformed — a missing brace above, or a stray quoted string where a block type was expected — because the parser can only start a body item with an identifier (a block type or an attribute name). When you hit that error, look upward, not at the reported line: it is almost always an unclosed block two lines earlier that swallowed the real declaration into its body.

Finally, nested blocks behave by the same grammar one level down, with one sharp corner: sometimes a provider stores a set of nested blocks that you need to generate from a list — and blocks cannot take a for expression the way attributes can. That specific mismatch is what the dynamic block exists to solve, and it is the clearest evidence that attributes and nested blocks really are different mechanisms, not just different spellings. Syntax you can now recognize on sight; the next chapter makes the style machine-enforced.

terraform fmt: Letting the Tool Own Your Formatting Arguments

Code review of infrastructure changes should debate security groups and instance sizes, not brace placement and equals-sign alignment. Terraform ships the tool that ends the debate: terraform fmt rewrites .tf files into the canonical style HashiCorp's formatter defines — two-space indentation, aligned = signs in consecutive attributes, normalized spacing inside braces and brackets, and consistent single-line layout where the input was a one-line block:

terraform fmt              # rewrites files in the current directory, prints changed names
terraform fmt -diff        # show what it would change, like git diff
terraform fmt -check       # exit non-zero if any file is unformatted (CI gate)
terraform fmt -recursive   # descend into modules/ subdirectories too

The -check form is the piece that matters operationally. Wire it into CI and formatting stops being a review comment:

- name: Format
  run: terraform fmt -check -recursive

A PR that introduces misformatted files fails before a human looks at them, and local editors with the HashiCorp extension (or fmt on save hooks) make compliance automatic. The style guide HashiCorp publishes — which argument names, which ordering, when to use lists versus maps — is worth a read once you are past the basics, but fmt enforces only syntax-level layout; the rest of the guide is convention your team adopts deliberately.

What fmt will not do, and this trips people up: it does not validate your configuration. terraform fmt proves the files parse; terraform validate — after init — proves the configuration is semantically sound: every reference resolves, every argument exists in the provider schema, every type constraint is satisfiable. The standard local and CI sequence uses both, in this order:

terraform init -backend=false   # fetch providers; skip backend for validation-only runs
terraform fmt -check -recursive
terraform validate

-backend=false lets validate run in CI without touching (or having) the real state backend — a detail that surprises teams building their first pipeline, and the reason terraform validate fails with a backend error in a fresh CI container until you pass it.

Editor integration closes the loop. The official HashiCorp extension for VS Code (and the community plugins for Vim, JetBrains, and Neovim) bundle the language server, which runs fmt and validate against your open directory continuously and surfaces diagnostics before you ever type a command. Teams that skip the extension tend to rediscover the same bug the hard way: a typo'd argument name passes fmt happily — formatting never checks semantics — and only validate (or the language server's live diagnostics, which wrap it) reports "Unsupported argument" against the provider schema. Two signals, two tools: fmt for form, validate for meaning.

One more habit that keeps diffs honest: commit formatting-only changes on their own, never mixed with behavior changes. terraform fmt can touch a hundred lines in a file you meant to edit in three places; mixing that with a security-group change makes review noise that hides real risk. Run fmt on the whole repository in an isolated commit, let CI hold the line, and every future pull request shows reviewers only the changes you actually intended — the same review-discipline principle from Terraform Versus Other IaC Tools, applied at the syntax level.

Quiz

1. A file contains `ami = "ami-123"` twice inside one resource block. What happens and why?

Question 1 options

2. Which set of label counts is correct for resource, variable, and provider blocks?

Question 2 options

3. What is the effect of splitting a configuration across network.tf, compute.tf, and variables.tf files?

Question 3 options

4. Which CI sequence correctly validates a configuration without configuring the real state backend?

Question 4 options

Flashcards

Question

What are the four parts of an HCL block, and what distinguishes an attribute from a nested block?

Answer

A block has a type keyword, zero or more labels, and a braced body; the body holds attributes and nested blocks. An attribute is `name = expression` — one expression, at most once per body. A nested block opens its own body, may repeat when the provider schema allows, and can nest further.

Question

Which top-level blocks take no labels, one label, and two labels?

Answer

No labels: `terraform`, `provider`, `locals`. One label: `variable`, `output`, `module`. Two labels: `resource` and `data`, whose labels are the provider type (e.g. `aws_instance`) and your local name — together forming the address used in state.

Question

Why do provider blocks use an alias argument instead of a second label?

Answer

`provider` blocks are distinguished per resource by the `provider` meta-argument, and HCL provider configuration uses `alias = "us"` inside the body to define additional configurations of one provider. A resource selects one with `provider = aws.us`; anything unselected falls back to the default (no alias) configuration.

Question

What do terraform fmt, -check, -diff, and -recursive each do?

Answer

`fmt` rewrites files to canonical style; `-diff` prints the changes it would make; `-check` exits non-zero when any file is unformatted (the CI gate); `-recursive` descends into subdirectories such as modules. fmt proves parseability only — semantic correctness is `terraform validate` after init.

Question

What is the difference between fmt and validate, and why does CI run init -backend=false first?

Answer

fmt enforces formatting on parseable files; validate checks semantics — references resolve, arguments exist in provider schemas, types are satisfiable — and needs providers installed by init. `-backend=false` skips real backend configuration, letting validation run in CI without state credentials or backend errors.

Revision Notes

Key Takeaways

  • 1. All Terraform files in one directory concatenate into a single configuration; layout is for humans, and every name must be unique across the whole directory
  • 2. Label counts are fixed by the parser: two for resource/data, one for variable/output/module, none for terraform/provider/locals (providers use `alias`)
  • 3. Attributes and nested blocks are different mechanisms: attributes take one expression and cannot repeat; nested blocks open bodies and may repeat, which is why `dynamic` blocks exist for generated sets
  • 4. Newlines terminate statements, but count as whitespace inside brackets, braces, and parentheses — the source of most "Missing newline after argument" confusion
  • 5. `terraform fmt` (with `-check -recursive` in CI) makes style mechanical; `terraform validate` after `init -backend=false` proves semantics — different jobs, both in the pipeline

Interview Tips

  • • "Explain HCL's structure in thirty seconds." — bodies contain attributes (`name = expression`, unique per body) and blocks (type + fixed label count + nested body); files in a directory merge into one configuration, which is why names must be unique across files
  • • "How do you configure multiple AWS regions in one configuration?" — multiple `provider "aws"` blocks with `alias`, resources selecting via `provider = aws.alias`, and the un-aliased block as default; add that child modules receive providers explicitly through their `providers` argument
  • • "What is the difference between fmt and validate?" — fmt = parseability and canonical style; validate = semantic correctness against provider schemas, requiring init first; cite `init -backend=false` as the CI trick that avoids backend configuration during validation
  • • "Why does Terraform reject a repeated attribute instead of using the last value?" — HCL forbids duplicate attribute names per body so configuration cannot carry ambiguous intent; silent overrides are a bug factory in code reviewed by diff

Cheat Sheet

HCL Blocks & Formatting

Grammar: Block = type labels... { body }; Attribute = name = expression (one per body, newline-terminated). Newlines end statements; inside (), [], {} they count as whitespace, so literals and calls may span lines.

Top-level block reference:

Block Labels Repeat?
terraform 0 once per directory
provider 0 (alias argument distinguishes) many
variable 1 once per name
locals 0 many blocks; unique local names
output 1 once per name
module 1 once per name
resource / data 2 (type + name) once per address

Multi-provider selection:

provider "aws" { region = "eu-west-1" }
provider "aws" { region = "us-east-1", alias = "us" }
resource "aws_s3_bucket" "logs" { provider = aws.us }

Quality gates:

terraform fmt -check -recursive   # style
terraform init -backend=false && terraform validate   # semantics, no backend needed

Commit formatting-only changes separately from behavior changes; dynamic blocks exist because nested blocks (unlike attributes) cannot take for expressions directly.

Continue Learning

Next Topics

Related Reading