validate & fmt
Before you reach for a linter, a policy engine, or a full integration test, Terraform ships two checks that cost almost nothing to run: terraform validate and terraform fmt. They are the cheapest, fastest quality gate in any pipeline — fully offline, no cloud credentials, no state, and they finish in well under a second on most modules. Running them first means malformed or unformatted code fails in CI before the slower, costlier stages ever start. OpenTofu exposes the identical tofu validate and tofu fmt commands.
Why these two run first
A validation pipeline is a funnel. Each stage is slower and more expensive than the last: validate and fmt take milliseconds, tflint and security scanners take seconds, plan takes seconds-to-minutes and needs credentials, and terraform test or Terratest can take minutes and spin up real resources. Putting the cheapest checks at the top of the funnel gives developers the fastest possible feedback and stops obviously broken code from consuming cloud quota.
Both checks are deterministic and require no network access, which makes them ideal for pre-commit hooks where a developer expects instant results.
terraform validate
validate parses the configuration and verifies it is syntactically valid and internally consistent. It confirms argument names exist on a resource, that types line up, that references point at declared resources and variables, and that required arguments are present. It evaluates expressions against the provider schemas — so the working directory must be initialized first — but it never calls a provider API and never reads state.
terraform init -backend=false
terraform validate
Output:
Success! The configuration is valid.
When something is wrong, validate pinpoints the file, line, and cause:
╷
│ Error: Reference to undeclared resource
│
│ on main.tf line 14, in resource "aws_eip" "nat":
│ 14: instance = aws_instance.gateway.id
│
│ A managed resource "aws_instance" "gateway" has not been declared in the
│ root module.
╵
Initializing with -backend=false skips backend configuration so no remote-state credentials are needed — exactly what you want in CI. The -json flag emits machine-readable diagnostics for tooling that turns errors into inline pull-request annotations:
terraform validate -json
Output:
{
"format_version": "1.0",
"valid": true,
"error_count": 0,
"warning_count": 0,
"diagnostics": []
}
terraform fmt
fmt rewrites .tf and .tfvars files into Terraform’s canonical style: two-space indentation, aligned = signs within a block, and consistent spacing. The style is fixed and non-configurable, so every formatted codebase looks identical and diffs stay small.
In CI you never want fmt to rewrite files — you want it to fail when files are not already formatted. The -check flag does exactly that, and -recursive walks subdirectories. Pair them with -diff to show what would change:
terraform fmt -check -recursive -diff
Output:
modules/network/main.tf
--- old/modules/network/main.tf
+++ new/modules/network/main.tf
@@ -3,3 +3,3 @@
- cidr_block="10.0.0.0/16"
+ cidr_block = "10.0.0.0/16"
The command exits 3 when changes are needed and 0 when everything is already formatted, so CI can gate on the exit code without parsing output.
What they catch — and what they miss
This is the most important thing to internalize: passing validate and fmt does not mean your infrastructure is correct. They check the shape of the code, not its behavior or safety.
| Concern | Caught by validate/fmt? | Tool that catches it |
|---|---|---|
| Syntax errors, unbalanced braces | Yes | validate |
| Undeclared references, type mismatches | Yes | validate |
| Missing required arguments | Yes | validate |
| Inconsistent formatting | Yes | fmt -check |
| Invalid AMI ID, bad region, name collisions | No | terraform plan |
| Unused declarations, deprecated syntax, naming rules | No | tflint |
| Open security groups, unencrypted buckets | No | Checkov / tfsec |
| Org policy violations (e.g. mandatory tags) | No | policy as code |
| Resources behave correctly end-to-end | No | terraform test / Terratest |
A configuration can validate cleanly and still fail at
apply— for example referencing an AMI that does not exist in the target region.validateanswers only “is this configuration well-formed?”, never “will this apply succeed?”
Wiring into pre-commit
The pre-commit framework gives developers feedback before code leaves their machine. The pre-commit-terraform repo provides ready-made hooks:
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.96.1
hooks:
- id: terraform_fmt
- id: terraform_validate
Install the hooks once with pre-commit install, and every git commit now runs both checks against the staged files.
Wiring into CI
In a GitHub Actions workflow, run the two checks as a fast first job that gates the rest of the pipeline. No cloud credentials are required because formatting is non-mutating and validation initializes without a backend:
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
Output:
Success! The configuration is valid.
Keeping this job credential-free and dependency-light means it runs in seconds on every push and fails fast on the most common mistakes, leaving the slower plan, scanning, and test stages to handle the harder problems.
Best Practices
- Run
validateandfmt -checkas the very first job in CI so cheap failures surface before any expensive stage. - Use
terraform init -backend=falsefor validation so no remote-state credentials are needed in CI. - Enforce formatting with
terraform fmt -check -recursiveso unformatted code cannot merge; format-on-save locally to keep it green. - Add
terraform_fmtandterraform_validatepre-commit hooks for instant feedback before commit. - Treat a passing
validateas “well-formed,” not “correct” — always layer a linter, security scan, andplanon top. - Use
validate -jsonto post diagnostics as inline pull-request annotations. - Remember both checks are offline; rely on
planand tests to catch provider, credential, and runtime issues.