| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1 |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu |
| Privilege | Normal user |
| Scope | terraform fmt workflow — canonical formatting, -check, -diff, -recursive, path targets, supported file types, CI enforcement, and how fmt differs from terraform validate. Does not cover HCL language reference, TFLint, Checkov, or full CI/CD pipeline setup. |
| Related guides | Terraform lab environment on Ubuntu Terraform init command Terraform HCL syntax Terraform providers Terraform Associate certification course |
terraform fmt is the fastest quality gate in the Terraform CLI. It rewrites configuration files to canonical spacing and alignment so teams stop debating indentation in code review. This guide starts with deliberately ugly but valid HCL, walks through check and diff modes, formats nested modules recursively, and shows why formatting is not the same as validation.
terraform is installed before you start. Run every command in an isolated directory under ~/terraform-labs/terraform-fmt/ so you do not collide with other lessons on the same VM.
What does terraform fmt do?
terraform fmt scans Terraform configuration files and rewrites them to HashiCorp canonical style:
- Indentation — two spaces per nesting level inside blocks
- Alignment — argument
=signs line up within a block when fmt can do so safely - Spacing — consistent gaps around operators and between arguments
fmt changes how configuration looks, not what it means. Resource names, provider settings, and argument values stay the same. If the configuration was already valid, terraform plan should not show new changes only because you ran fmt.
fmt is also safe to rerun. A second pass on already formatted files exits quietly and changes nothing.
Format Terraform configuration
This section covers default write behavior, read-only checks, and diff preview — the three modes you use on a single directory before you worry about recursion or path targets.
Format the current directory
Create a lab directory and add a misformatted root module file.
mkdir -p ~/terraform-labs/terraform-fmtMove into that directory — every command below assumes you are here:
cd ~/terraform-labs/terraform-fmtWrite main.tf with cramped spacing and missing indentation:
resource "local_file" "demo" {
content="fmt lab"
filename="${path.module}/demo.txt"
}Before you format, inspect the file so you can compare afterward:
cat main.tfSample output:
resource "local_file" "demo" {
content="fmt lab"
filename="${path.module}/demo.txt"
}Run fmt in the current directory. By default it writes changes back to disk and prints the name of each file it updated:
terraform fmtSample output:
main.tfThe single filename on stdout is fmt's change list. Read the file again to see canonical spacing:
cat main.tfSample output:
resource "local_file" "demo" {
content = "fmt lab"
filename = "${path.module}/demo.txt"
}Arguments are indented and the = signs align. That is the before/after you want in version control.
Check formatting without changing files
CI jobs and pre-commit hooks usually need a read-only check. Pass -check so fmt reports drift without rewriting files:
cat > main.tf << 'EOF'
resource "local_file" "demo" {
content="fmt lab"
filename="${path.module}/demo.txt"
}
EOFRecreate the ugly file above, then ask fmt whether anything is out of spec:
terraform fmt -checkSample output:
main.tffmt lists files that are not formatted. Capture the shell exit status — automation relies on it:
echo $?Sample output:
3Exit status 0 means every scanned file already matches canonical style. A non-zero status means at least one file would change; on Terraform 1.15.x unformatted native-syntax files typically return 3. Scripts in CI treat any non-zero status as failure.
Format the file, then rerun the check:
terraform fmtConfirm the directory is clean:
terraform fmt -checkNo output means no unformatted files were found. Capture the successful exit status separately:
echo $?Sample output:
0HashiCorp defines exit status 0 as success. fmt prints nothing on stdout when every scanned file is already formatted.
Preview changes with -diff
When you want to see what fmt would change, use -diff. Without -check, fmt still writes the rewrite to disk:
cat > main.tf << 'EOF'
resource "local_file" "demo" {
content="fmt lab"
filename="${path.module}/demo.txt"
}
EOFRun fmt with a unified diff:
terraform fmt -diffSample output:
main.tf
--- old/main.tf
+++ new/main.tf
@@ -1,4 +1,4 @@
resource "local_file" "demo" {
-content="fmt lab"
-filename="${path.module}/demo.txt"
+ content = "fmt lab"
+ filename = "${path.module}/demo.txt"
}The - lines are removed; the + lines are what fmt writes. Combine -check and -diff when a pipeline should fail on drift but still print the patch for logs:
cat > main.tf << 'EOF'
resource "local_file" "demo" {
content="fmt lab"
filename="${path.module}/demo.txt"
}
EOFRun the combined flags:
terraform fmt -check -diffSample output:
main.tf
--- old/main.tf
+++ new/main.tf
@@ -1,4 +1,4 @@
resource "local_file" "demo" {
-content="fmt lab"
-filename="${path.module}/demo.txt"
+ content = "fmt lab"
+ filename = "${path.module}/demo.txt"
}Because -check is set, fmt does not rewrite the file. Confirm the non-zero exit status:
echo $?Sample output:
3Choose which files terraform fmt processes
By default, fmt scans only the current directory. You can widen the scan with -recursive, narrow it with an explicit path, or rely on the built-in file-type rules documented below.
Format recursively
Create a nested module with misformatted configuration while the root main.tf from the previous section is already formatted:
mkdir -p modules/exampleWrite an unformatted nested module file:
printf '%s\n' 'resource "local_file" "nested" {' 'content="nested module"' 'filename="${path.module}/nested.txt"' '}' > modules/example/main.tfRun fmt without -recursive. Only files in the current directory are scanned, so the nested copy is skipped:
terraform fmtWith a formatted root and an ugly nested file, fmt prints nothing and leaves the module unchanged. Verify the module file is still cramped:
cat modules/example/main.tfSample output:
resource "local_file" "nested" {
content="nested module"
filename="${path.module}/nested.txt"
}Add subdirectories to the scan with -recursive:
terraform fmt -recursiveSample output:
modules/example/main.tfOnly the nested file appears because the previous non-recursive terraform fmt already formatted the root file. Terraform lists files it actually changes; -recursive merely adds subdirectories to the scan. Confirm the nested file was rewritten:
cat modules/example/main.tfSample output:
resource "local_file" "nested" {
content = "nested module"
filename = "${path.module}/nested.txt"
}Format a specific file or directory
You can pass a file or directory path instead of relying on the current working directory. Create a standalone file to target:
printf '%s\n' 'resource "local_file" "one" {' 'content="x"' 'filename="${path.module}/one.txt"' '}' > one.tfFormat just that file:
terraform fmt one.tfSample output:
one.tfInspect the result:
cat one.tfSample output:
resource "local_file" "one" {
content = "x"
filename = "${path.module}/one.txt"
}Point fmt at a directory to process every supported file in that folder (non-recursive unless you also pass -recursive):
printf '%s\n' 'resource "local_file" "nested" {' 'content="bad"' 'filename="${path.module}/nested.txt"' '}' > modules/example/main.tfRun fmt against the module path:
terraform fmt modules/exampleSample output:
modules/example/main.tffmt accepts - as a target to read from standard input, but stdin mode always disables -write and -check. For everyday module work, path arguments on disk are simpler.
Supported file types
Terraform 1.15.x documents these rules in terraform fmt -help:
| File pattern | Modified by fmt? |
|---|---|
.tf |
Yes — native HCL syntax |
.tfvars |
Yes — variable definition files |
.tftest.hcl |
Yes — Terraform test files |
.tf.json, .tfvars.json, .tftest.json |
No — JSON syntax is not rewritten |
| Other extensions | No — fmt errors if you pass an unsupported path |
Add sample variable and test files to see non-.tf formatting:
printf 'demo_label="unformatted"\n' > variables.tfvarsAdd a minimal Terraform test file with the same cramped style:
printf 'run "demo" {\ncommand = plan\n}\n' > demo.tftest.hclRun fmt on the directory:
terraform fmtSample output:
demo.tftest.hcl
variables.tfvarsRead the updated files:
cat variables.tfvarsSample output:
demo_label = "unformatted"The .tfvars file now has spaces around =. Inspect the test file next:
cat demo.tftest.hclSample output:
run "demo" {
command = plan
}fmt adds spaces around = in .tfvars and indents blocks inside .tftest.hcl the same way it does for .tf files.
Passing a .tf.json file fails with an explicit error — JSON targets are not supported:
printf '{"resource":{"local_file":{"demo":{"content":"json","filename":"demo.json"}}}}\n' > main.tf.jsonPoint fmt at the JSON file to see the rejection:
terraform fmt main.tf.jsonSample output:
╷
│ Error: Only .tf, .tfvars, and .tftest.hcl files can be processed with terraform fmt
│
│
╵The JSON file on disk is unchanged.
Use terraform fmt in CI
Most teams run one non-destructive check before plan or apply stages:
terraform fmt -check -recursiveThe command walks the repository tree, prints any unformatted relative paths, and exits non-zero when drift exists. A typical failure looks like this when a nested module was edited without fmt:
terraform fmt -check -recursiveSample output:
modules/example/main.tfCapture the status your pipeline will see:
echo $?Sample output:
3Fix locally with terraform fmt -recursive, commit the formatting diff, and rerun the check until the exit status is 0. Full GitHub Actions or GitLab pipeline YAML belongs in a dedicated CI/CD lesson — here the important part is the command and its exit code contract.
terraform fmt vs terraform validate
fmt is a formatter. terraform validate checks syntax and internal configuration consistency against installed provider schemas. It does not call remote provider APIs or other remote services.
Demonstrate formatted but invalid configuration
fmt can exit successfully even when configuration is semantically invalid for terraform validate. Create a separate error lab directory so you do not break the main fmt examples:
mkdir -p ~/terraform-labs/terraform-fmt-errorsSwitch into the error lab before you write invalid configuration:
cd ~/terraform-labs/terraform-fmt-errorsWrite configuration with a semantic error — missing quotes around a string value:
resource "local_file" "demo" {
content = "bad syntax"
filename = missing_quote
}Save that as main.tf. The file is already canonically formatted, so ask fmt whether it would change anything:
terraform fmt main.tfterraform fmt exits successfully and prints nothing because the file already matches canonical formatting. The bare missing_quote reference is still invalid for this configuration, which terraform validate catches next.
Validation requires an initialized working directory. Add provider requirements:
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}Save that as versions.tf, then initialize without configuring a remote backend:
terraform init -backend=falseSample output:
Terraform has been successfully initialized!Run validate on the formatted but invalid configuration:
terraform validateSample output:
╷
│ Error: Invalid reference
│
│ on main.tf line 3, in resource "local_file" "demo":
│ 3: filename = missing_quote
│
│ A reference to a resource type must be followed by at least one attribute
│ access, specifying the resource name.
╵fmt reported no changes; validate caught the reference error. Run both — fmt before you commit, validate before you plan.
fmt vs validate comparison
terraform fmt |
terraform validate |
|
|---|---|---|
| Purpose | Canonical formatting and style | Syntax and internal configuration consistency |
| Requires init | No | Yes — providers must be installed |
| Modifies files | Yes by default; -check disables writes |
No — read-only analysis |
| Catches invalid references and schema issues | No | Yes — based on installed provider schemas, not live API calls |
| Typical CI use | terraform fmt -check -recursive |
terraform init -backend=false then terraform validate |
| Exit code on failure | Non-zero when unformatted (3 on 1.15.x for drift) |
Non-zero when validation fails (1 for configuration errors) |
Use fmt first so reviews focus on behavior. Use validate to prove the configuration is internally consistent before you plan.
Common terraform fmt problems
| Symptom | Likely cause | Fix |
|---|---|---|
fmt exits 0 but files look unchanged |
Files already match canonical style | Expected — rerun after edits that break alignment |
| Nested module still misformatted | Omitted -recursive |
Run terraform fmt -recursive or fmt inside the module directory |
CI fails with exit 3 and a file list |
Committed code was not formatted | Run terraform fmt -recursive locally, commit the diff |
| fmt errors on a file | HCL parse error fmt cannot recover from | Fix syntax first; fmt formats valid native syntax |
| Large diff after upgrading Terraform | New fmt rules in a newer CLI release | Run fmt once on a branch, review the style-only diff, commit |
Passing .tf.json to fmt |
JSON targets are unsupported | Format .tf / .tfvars / .tftest.hcl only, or maintain JSON manually |
fmt fails with a parse error
Single-line blocks with multiple arguments can be valid after expansion but confuse the parser when everything is crammed on one line. If fmt prints Invalid single-argument block definition, split arguments across lines yourself or fix brace placement, then rerun fmt.
CI reports formatting drift on unchanged code
Another developer may have committed unformatted files. Run terraform fmt -check -diff -recursive locally to print the same diff CI sees, apply terraform fmt -recursive, and push the formatting-only commit.
References
Summary
terraform fmt is the quick formatting pass every Terraform repository should adopt.
You walked through:
- Rewriting misaligned
.tffiles - Checking style with
-check - Previewing patches with
-diff - Reaching into child modules with
-recursive
The exit status from -check is what CI scripts key on — non-zero means someone needs to run fmt before merge.
fmt does not replace terraform validate:
- Formatting keeps diffs readable
- Validation checks syntax and internal consistency against installed provider schemas after
terraform init
Run terraform fmt -check -recursive in automation, fix drift locally with terraform fmt -recursive, and treat validate as the next gate before terraform plan.
The mistake to avoid is assuming a green fmt run means configuration is deployable. When nested directories stay ugly, -recursive was probably missing. When CI fails with a short file list and exit code 3, format those paths and commit — no functional change, just canonical style.
Next in the Core Workflow track: terraform validate in depth, then terraform plan.

