TerraformV · The Terraform WorkflowProduction Terraform
fmt and validate: The Cheap Gates
What you'll learn
- Use terraform fmt to enforce a canonical HCL style across the team
- Use terraform validate to catch configuration errors before plan without calling provider APIs
- Configure a CI pipeline that runs fmt -check, init -backend=false, and validate on every pull request
- Distinguish syntax errors (caught by fmt and validate) from semantic errors (caught only at plan)
- Choose between terraform fmt -check, -diff, and -recursive for a given automation context
Prerequisites
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
Two commands in the Terraform workflow finish in under a second and require no network access. terraform fmt enforces a canonical HCL style. terraform validate checks that the configuration is internally consistent. They are the cheapest gates in the pipeline and they belong at the very front: in pre-commit hooks, in pull request checks, and in the first job of every CI run.
What fmt does
terraform fmt rewrites .tf files in the current directory to match the canonical Terraform style: two-space indentation, aligned equals signs, consistent argument ordering, single-line blocks where appropriate. The canonical style is documented in the HashiCorp style guide and is the only style fmt knows — there is no configuration file for it.
# Rewrite every .tf file in the current directory in place.
terraform fmt
# Rewrite every .tf file under the current directory, recursively.
# Use this in a repo with modules under subdirectories.
terraform fmt -recursive
# Check whether files are already canonical. Exit 0 if yes, non-zero
# if fmt would change anything. This is the CI gate.
terraform fmt -check -recursive
# Show the diff that fmt would apply, without rewriting. Useful in
# pre-commit hooks to show the author what would change.
terraform fmt -diff
The output of terraform fmt -check is the list of files that are not canonical. The exit code is what CI uses:
$ terraform fmt -check -recursive
main.tf
modules/network/main.tf
$ echo $?
3
Exit code 0 means everything is formatted. Exit code 3 means fmt would change something. Any other exit code indicates a parse error.
What validate does
terraform validate parses the configuration, walks the dependency graph, type-checks every variable and every expression, and reports any internal inconsistency. It does not call provider APIs. It does not read or refresh state. It does not authenticate anywhere.
# Validate after a plain init.
terraform validate
# Validate with JSON output, for CI to parse.
terraform validate -json
# Validate without color codes, for cleaner CI logs.
terraform validate -no-color
Sample output:
$ terraform validate
Success! The configuration is valid.
Sample JSON output for a failure:
$ terraform validate -json
{
"valid": false,
"error_count": 1,
"diagnostic": [
{
"severity": "error",
"summary": "Unsupported argument",
"detail": "An argument named \"instance_typ\" is not expected here.",
"range": {
"filename": "main.tf",
"start": { "line": 12, "column": 5, "byte": 180 },
"end": { "line": 12, "column": 25, "byte": 200 }
}
}
]
}
The JSON form is what CI parses. The severity, summary, detail, and range are all structured fields that a script can extract.
What validate does NOT catch
This is the lesson most operators learn the hard way. Validate is syntactic and structural, not semantic:
- It does not know whether
instance_type = "t2.micro"is a valid AWS instance type. The AWS provider knows that, at plan time. - It does not know whether the AMI ID you hard-coded still exists. The AWS provider checks at plan time.
- It does not know whether your IAM role has permission to create the resource. AWS knows at plan time.
- It does not refresh state. Drift is invisible to validate.
The boundary: validate catches the mistakes that live entirely inside the HCL files. It catches missing arguments, wrong argument names, mismatched types, references to undeclared variables or resources, and mis-formed expressions. It does not catch anything that requires the real world to answer.
The CI pipeline pattern
The cheap gates come first, before init. A pipeline that runs fmt and validate before init saves time and CI minutes on every pull request:
set -euo pipefail
# 1. Style gate. No init required.
terraform fmt -check -recursive
# 2. Init just enough for validate. No backend, no input prompts.
terraform init -backend=false -input=false
# 3. Internal consistency gate. No provider API calls.
terraform validate -no-color
# 4. Optional: stricter validate with JSON parsing in CI.
terraform validate -json | jq -e '.valid and .error_count == 0'
A typical CI job takes 5-15 seconds for this stage. The job that follows — terraform plan -detailed-exitcode against the real backend — takes 30-90 seconds and consumes state-lock minutes. The cheap gates should run first so that obvious mistakes never reach the expensive job.
What fmt and validate do not replace
Neither command replaces terraform plan. A plan that passes both gates can still:
- Produce a 200-line change set because a variable default changed.
- Fail at the provider API because of an invalid ARN.
- Replace a database because an immutable attribute was edited.
- Hit a rate limit and time out.
The cheap gates are a necessary first filter. They are not a substitute for the human review of the plan.
Production failure modes
1. fmt passes locally, fails in CI. Symptom: the developer commits a file, CI fails on fmt -check. Cause: the developer’s editor is not configured to run terraform fmt on save, and the developer’s manual formatting drifts from the canonical style. Recovery: install an editor integration (the official Terraform extension for VS Code runs terraform fmt on save). Long-term: enforce fmt in pre-commit hooks so the bad commit never reaches the repo.
2. validate passes, plan fails with an argument error. Symptom: terraform validate returns success, then terraform plan exits with Error: Unsupported argument. Cause: the argument is valid for a provider version that is not the one currently downloaded. Validate checks against the schema in the provider plugin, but the lockfile may point at an older version than the configuration expects, or the configuration is missing a required_providers block and is using a default. Recovery: run terraform init -upgrade, then re-validate.
3. validate fails with “Missing required argument” for a value supplied via a variable. Symptom: validate reports a missing argument even though the variable is declared and has a default. Cause: the variable is declared in one file and the resource is in a module that does not see the variable. Recovery: pass the variable explicitly into the module call, or check that the variable block has no validation block that the value violates.
4. fmt rewrites files unexpectedly on a shared module. Symptom: a developer runs terraform fmt in a directory containing a module they did not write, and the module’s files change. Cause: terraform fmt -recursive rewrites everything under the current directory. Recovery: scope fmt to the directories you own (terraform fmt main.tf variables.tf) or run it from the module root.
5. validate succeeds with a stale state. Symptom: validate returns success; plan returns “No changes”. The real world has drifted. Cause: validate does not read state. Plan refreshes state and sees that the real world matches the configuration, but neither knows about manual changes that happened since the last apply. Recovery: this is not a validate failure; it is a drift problem. Use terraform plan -refresh-only to detect and reconcile.
6. JSON parse failure on terraform validate -json. Symptom: CI script that parses validate JSON crashes with a parse error. Cause: an older Terraform version emits slightly different JSON. Recovery: pin the Terraform version in CI to match the lockfile, or wrap the parse in jq with a default that returns failure on any error.
Security implications
fmt and validate are pure-local. They do not call any external system. There is no security implication beyond what every other local command has: the user running the command can read every file in the directory and write to the ones fmt touches. The risk to manage is the write risk: if the working directory is on a read-only filesystem or in a protected branch checkout, terraform fmt (without -check) will fail. Use -check in any read-only context.
Performance implications
Both commands are CPU-bound and run on local files. A repo with 100 .tf files takes under 100ms. A monorepo with 5,000 .tf files takes 1-2 seconds. The -recursive flag is what makes fmt scale, and it is what you should always use in CI.
Verification
Run through this checklist on a sample module:
# fmt -check exits non-zero if any file is not canonical.
terraform fmt -check -recursive
echo "fmt exit: $?"
# init without backend so validate does not need state.
terraform init -backend=false -input=false
# validate returns "Success! The configuration is valid." or JSON.
terraform validate
# validate -json can be parsed and asserted on.
terraform validate -json | python3 -c "import sys, json; d=json.load(sys.stdin); sys.exit(0 if d['valid'] else 1)"
echo "validate exit: $?"
A passing run leaves you with a Success! line (or a JSON valid: true), fmt exit code 0, and no errors. That is the gate every pull request must clear before plan runs.
What comes next
The next lesson is terraform init itself — what it does on disk, the flags you will reach for, the lockfile workflow, and the four init failures you will hit on a real team.
Knowledge check · 7 questions
Q1. Which statement best describes the difference between terraform fmt and terraform validate?
Q2. terraform validate confirms that an instance_type argument is a valid AWS instance type.
Q3. Which CI command sequence runs the cheapest gates first?
Q4. Which of the following does terraform validate catch? (Select all that apply.)
Q5. You want CI to fail if any .tf file is not formatted to canonical style. Which command is correct?
Q6. What does the -json flag on terraform validate produce?
Q7. A pull request runs fmt -check and validate in CI. fmt passes. validate returns 'Success! The configuration is valid.' A reviewer approves. The next job runs plan and produces 47 changes including the destruction of the production database. What went wrong?
Passing score: 75%. Answers are checked in this browser.