Skip to main content
RunBook Academy

TerraformXXI · Testing, Linting, and Static AnalysisProduction Terraform

Format and Validate Checks

Foundation⏱ ~10 minbash

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

Not yet marked complete on this device.

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 for and if expressions
  • Malformed arguments to jsonencode and jsondecode

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.

CheckNetworkStateProviderCatches
fmt -checkNoNoNoStyle drift
validateNoNoNoSyntax, references, types
planYesReadsReadsValue errors, drift
applyYesWritesWritesReal-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 -diff as the first step of every Terraform CI job. A failed fmt is a failed PR.
  • Run terraform init -backend=false && terraform validate as the second step. The -backend=false flag is the production choice: CI does not need backend credentials.
  • Add a pre-commit hook that runs terraform fmt on staged files. The standard hook is terraform-fmt from the pre-commit framework.
  • Do not treat validate as 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 fmt between 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

  1. Q1. What does `terraform fmt -check` do?

  2. Q2. `terraform validate` requires a configured backend to succeed.

  3. Q3. Which of the following does `terraform validate` NOT catch?

  4. Q4. Why do production CI pipelines pass `-backend=false` to `terraform init`?

  5. Q5. Which of the following are properties of `terraform fmt -check`? (Select all that apply.)

  6. Q6. A file passes `terraform validate` but the plan still fails. What is the correct interpretation?

  7. 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.