TerraformXXI · Testing, Linting, and Static AnalysisProduction Terraform
Format and Validate Checks
What you'll learn
- Run terraform fmt and terraform fmt -check and explain what each does
- Distinguish terraform fmt from terraform validate and place them on the testing pyramid
- Wire fmt and validate into a CI gate as the first cheap checks before any provider call
- Identify the failure modes that fmt and validate catch and the ones they deliberately do not
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
Every Terraform change goes through a pipeline before it reaches a
production apply. The cheapest two checks in that pipeline are
terraform fmt and terraform validate. They run in under a
second on most modules, catch a defined class of errors, and fail
the merge before anyone reads a PR. They are not redundant with
each other and they do not catch the same things.
What fmt actually does
terraform fmt rewrites HCL files in a canonical style
documented as the Terraform language style. The tool is a
formatter, not a linter. It rewrites:
- Indentation (two spaces, hard requirement)
- Alignment of
=signs inside the same block - Blank line placement between top-level blocks
- Trailing whitespace
- Final newline
It does not rename resources, reorder arguments for semantic
meaning, or change values. Two files that differ only in
whitespace will produce the same plan after fmt is applied to
both.
# Severity: CONFIGURATION - rewrites files in place.
terraform fmt -recursive
The -recursive flag walks the directory tree. On a typical
module the command runs in tens of milliseconds. There is no
network call, no provider call, no state read.
The CI gate: -check
In CI, rewriting files is the wrong behaviour. The gate wants a non-zero exit code that fails the build, not silent edits to the working copy:
# Severity: READ-ONLY - exits non-zero if formatting is wrong.
terraform fmt -check -recursive -diff
Exit codes:
0 - all files already canonical
1 - at least one file would be reformatted
2 - parse error (malformed HCL)
3 - I/O error
The -diff flag prints the change that would be made. Most CI
listings render the diff inline so the contributor can see
exactly what their editor missed. The combination
-check -recursive -diff is the production gate.
What validate actually does
terraform validate parses the configuration and checks that
references and expressions are well-formed. It does not
contact the provider APIs and does not read real state. It
does require an initialised working directory because it
needs to know which provider plugins are configured.
# Severity: READ-ONLY - does not contact providers or state.
terraform init -backend=false
terraform validate
The -backend=false flag skips backend initialisation. It is
the right choice in CI because you do not want validate to
read or write remote state.
What validate catches:
- Syntax errors in HCL
- Unknown arguments on resources and data sources
- Type mismatches in expressions
- Missing required arguments
- References to undeclared variables, locals, outputs, or resources
- Errors in
forandifexpressions - Malformed arguments to
jsonencodeandjsondecode
What validate does not catch:
- Whether the expression evaluates to the value you expect
- Whether the provider will accept the configuration
- Whether the resource will actually be created
- Dynamic blocks whose conditions reference values only known at plan time (the validator sees them as syntactically valid)
fmt vs validate
The two checks overlap less than people assume. The right mental model is a pyramid:
fmt - is the file stylistically Terraform?
validate - is the file semantically parseable?
plan - would the provider accept this configuration?
apply - will the cloud accept this state transition?
A file that passes fmt can still fail validate. A file
that passes validate can still produce a 200-line plan diff.
The gates are cumulative, not alternatives.
| Check | Network | State | Provider | Catches |
|---|---|---|---|---|
fmt -check | No | No | No | Style drift |
validate | No | No | No | Syntax, references, types |
plan | Yes | Reads | Reads | Value errors, drift |
apply | Yes | Writes | Writes | Real-world acceptance |
Wiring the cheap checks into CI
The standard pipeline order on a pull request:
# 1. Format gate
terraform fmt -check -recursive -diff
# 2. Initialise without backend
terraform init -backend=false
# 3. Validate
terraform validate
# 4. Everything else (test, plan, policy) - covered in later lessons
GitHub Actions fragment:
name: terraform-checks
on: [pull_request]
jobs:
fmt-validate:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.x
- run: terraform fmt -check -recursive -diff
- run: terraform init -backend=false
- run: terraform validate
The job runs in under a minute on a small module. It is the floor of the pipeline. Every other check builds on the assumption that the configuration is well-formed.
Production failure modes
The cheap checks fail in specific, recognisable ways. Each one has an observable symptom.
1. fmt -check fails on a single file
Symptom: CI prints a diff for modules/network/main.tf and
exits 1. Cause: the contributor’s editor uses tabs or has a
formatter that overrides terraform fmt. Fix: run
terraform fmt locally, commit the result, push.
2. fmt exits with code 2 (parse error)
Symptom: the command prints
Error: Argument block argument or similar and exits 2.
Cause: malformed HCL, usually a missing brace or quote. fmt
cannot reformat what it cannot parse. Fix: read the line
number, fix the syntax, retry. A parse error usually also
breaks validate.
3. validate complains about an unknown argument
Symptom:
Error: Unsupported argument
on main.tf line 12, in resource "aws_instance" "web":
12: ami_id = var.ami
Cause: the argument is wrong for the provider version, or the
contributor typoed (ami_id instead of ami). Fix: check
the provider docs for the current schema. Run
terraform providers to confirm the version in the lock
file.
4. validate complains about an undeclared variable
Symptom:
Error: Reference to undeclared input variable
on main.tf line 7, in resource "aws_instance" "web":
7: ami = var.ami_id
Cause: variable "ami_id" is missing from variables.tf, or
the file is in a modules/ subdirectory the root does not
load. Fix: declare the variable, or check the module path.
5. validate passes but plan fails
Symptom: CI green on fmt+validate, red on plan. Cause: the
expression is syntactically valid but refers to a value
validate cannot compute (a value that only the provider
returns at plan time). This is correct behaviour. validate
is a grammar check; plan is a semantic check. Fix: do not
treat validate as proof of correctness. The remaining
lessons in this part cover the stronger checks.
6. validate runs against the wrong working directory
Symptom: CI fails with Backend reinitialisation required or
No configuration files in this directory. Cause: the
runner checked out a subdirectory or the CI command runs
from the wrong path. Fix: pin working-directory in the CI
action. A common mistake is to run terraform validate
against a module directory while the CI expects the root.
Security and performance implications
fmt and validate are local-only. They make no network
calls. They read no state. They write no files when run
with -check. They are safe to run on any developer
workstation with no IAM permissions at all. Run them in the
pre-commit hook before the secret scanner so that the
formatter has already normalised the file (some secret
scanners miss secrets that are broken across whitespace).
Performance: fmt is bounded by file size and count. It does
not load plugins. validate requires init first, which
downloads provider plugins (init -backend=false still
downloads providers). On a cold cache, init is the
dominant cost. A CI cache keyed on the
.terraform.lock.hcl keeps init to a few seconds on
subsequent runs.
Production guidance
- Run
terraform fmt -check -recursive -diffas the first step of every Terraform CI job. A failed fmt is a failed PR. - Run
terraform init -backend=false && terraform validateas the second step. The-backend=falseflag is the production choice: CI does not need backend credentials. - Add a pre-commit hook that runs
terraform fmton staged files. The standard hook isterraform-fmtfrom the pre-commit framework. - Do not treat
validateas proof of correctness. It is a grammar check. The plan is the next gate. - Pin the Terraform version in CI to match the version the
team uses locally.
terraform fmtbetween 1.5 and 1.9 may reformat differently in edge cases. A version mismatch produces a fmt diff the contributor cannot reproduce locally.
Verification
# Format gate (CI style)
terraform fmt -check -recursive -diff
echo "fmt exit: $?"
# Initialise without backend
terraform init -backend=false
# Validate
terraform validate
Both commands return exit 0 on a well-formed module. Any non-zero exit is a pipeline failure on the contributor’s PR.
Knowledge check · 7 questions
Q1. What does `terraform fmt -check` do?
Q2. `terraform validate` requires a configured backend to succeed.
Q3. Which of the following does `terraform validate` NOT catch?
Q4. Why do production CI pipelines pass `-backend=false` to `terraform init`?
Q5. Which of the following are properties of `terraform fmt -check`? (Select all that apply.)
Q6. A file passes `terraform validate` but the plan still fails. What is the correct interpretation?
Q7. CI fails on PR #842 with `terraform fmt -check -recursive -diff` exiting 1. What is the next step?
Passing score: 75%. Answers are checked in this browser.