Hedronite · Cert Lesson · Terraform Associate (004) · Tue 2026-10-20

fmt -check, validate, and TF_LOG three commands that catch problems before plan does

Style, internal consistency, and a log you can hand to the right maintainer. None of them needs a cloud account.

Lesson Class: Cert (Terraform Associate 004 · objectives 3c, 3g, 7c · tests Terraform 1.12)
Tested with: Terraform 1.14.3 (one comparison on 1.16.5) · hashicorp/random 3.9.1 · jq
Lab: lab/ unformatted root + child module · broken copy · run.sh · real output in lab/out/
Source: HashiCorp exam content list · fmt, validate and debugging docs · Troubleshoot tutorial · Brikman ch. 9-10
Recurse or Miss It
fmt -check scans one directory unless told otherwise.
Validate Is Not Plan
Validate checks the configuration; plan checks the values.
A Level, Then a Path
TF_LOG_PATH alone writes an empty file.
The check passed. The child module was never asked.

The problem

A pull request changes a root module, which is the directory where Terraform runs, and a local child module that the root calls with source = "./modules/labels". The reviewer wants three answers before anyone runs terraform plan against real infrastructure.

  1. Is the code in canonical style, so that a diff shows real changes instead of whitespace noise?
  2. Is the code internally consistent, with no references to undeclared variables and no misspelled arguments?
  3. When something does fail later, how does the team collect enough detail to tell a problem in Terraform core from a problem in a provider? Core is the program that reads configuration and builds the plan. A provider is the plugin that talks to a service's API (application programming interface).

Without cheap checks each answer arrives late: style drift becomes review noise, a typo becomes a failed plan in a pipeline that needs credentials, and a vague failure becomes a bug report nobody can act on.

The HashiCorp Terraform Associate (004) exam, which tests Terraform 1.12, asks about exactly these commands and variables, under three objectives: 3c "Validate a Terraform configuration", 3g "Apply formatting and style adjustments to a configuration", and 7c "Describe when and how to use verbose logging".

The idea, in plain words

terraform fmt -check answers the style question. terraform validate answers the consistency question, and it works only after terraform init has run. TF_LOG and its relatives answer the third question by turning on detailed logs when you need to see inside a run. None of the three contacts a cloud API. The validate documentation says it "does not validate remote services, such as remote state or provider APIs", which is why all three are cheap enough to run on every commit.

Tested with: Terraform 1.14.3 (darwin_arm64) for every step except one validate -var comparison on 1.16.5, the hashicorp/random provider v3.9.1 from the public Terraform Registry, jq, and local state. Nothing was applied and no cloud account was involved. Quoted error wording is the tested version's text, not necessarily 1.12's.

Formatting with terraform fmt (objective 3g)

terraform fmt formats configuration file contents "so that it matches the canonical format and style". It is "intentionally opinionated and has no customization options" because its goal is one style across Terraform codebases; otherwise every diff would carry personal preferences about alignment.

The canonical format "may change in minor ways between Terraform versions", so the documentation recommends running terraform fmt after upgrading. Yevgeniy Brikman, in Terraform: Up & Running, recommends running it as a commit hook so that all committed code uses one style.

FlagEffect
-checkExit status 0 if the input is formatted. Otherwise a non-zero status, plus a list of improperly formatted file names. Implies no overwriting.
-diffDisplays the diffs of formatting changes.
-recursiveAlso processes subdirectories. By default only the current directory is processed.
-write=falsePrevents overwriting files. Implied by -check.
-list=falseStops the command listing the files with formatting inconsistencies.

The lab configuration was written unformatted on purpose: equals signs in main.tf and the child module were not aligned, and a copy of the configuration sat in broken/. The first two checks differ by one flag:

$ terraform fmt -check -no-color
main.tf
[exit 3]

$ terraform fmt -check -recursive -no-color
broken/main.tf
broken/modules/labels/main.tf
main.tf
modules/labels/main.tf
[exit 3]

The plain check scanned only the current directory, so it reported main.tf and nothing from modules/labels. Adding -recursive also reached the child module and both files under broken/. The docs promise only a "non-zero" exit status, and this run printed 3; do not memorize the number. The status is the signal and the file list is a hint for the author, which a third run showed directly: terraform fmt -check -list=false -recursive printed no file names at all and still exited 3.

To see what would change, -diff shows the edit without writing it:

$ terraform fmt -check -diff -no-color modules/labels/main.tf
modules/labels/main.tf
--- old/modules/labels/main.tf
+++ new/modules/labels/main.tf
@@ -4,7 +4,7 @@
 
 output "labels" {
   value = {
-    env = var.env
+    env        = var.env
     managed_by = "terraform"
   }
 }
[exit 3]

The whole fix is one line: the canonical format pads env so its equals sign lines up with managed_by's. The diff is printed, not applied, so a reviewer can read it without touching the working tree. terraform fmt -recursive then listed the same four files as files it rewrote and exited 0, and a second terraform fmt -check -recursive printed nothing and exited 0, the state a passing CI (continuous integration) job expects.

The lab also contained a file named generated.tf.json, written in the JSON (JavaScript Object Notation) variant of the Terraform language and made deliberately ugly:

{"output":{"generated_by":{"value":   "a tool"}}}

No fmt run ever listed it, recursive or not. Yet the later plan read it and reported + generated_by = "a tool" under "Changes to Outputs". The language documentation explains both facts: Terraform code lives in .tf files, a JSON-based variant uses .tf.json, and a module is "a collection of .tf and/or .tf.json files". Terraform loads .tf.json files, and in this lab fmt never touched them.

One boundary matters here. The Troubleshoot Terraform tutorial notes that terraform fmt only parses HCL (HashiCorp Configuration Language, the syntax of .tf files) for interpolation errors or malformed resource definitions. Checking against the providers' expectations is a separate step, so validate comes after formatting.

Symptom: CI runs terraform fmt -check and passes, yet a child module under modules/ is still unformatted.

Cause: Without -recursive, only the current directory is scanned, and nested directories are separate modules that are not included automatically.

Fix: Run terraform fmt -check -recursive so every subdirectory is checked.

Validating with terraform validate (objective 3c)

terraform validate checks the configuration files in a directory for syntax errors and internal consistency, "regardless of any provided variables or existing state". That includes the correctness of attribute names and value types. It is safe to run automatically, as a post-save check in an editor or a CI test step, because it changes nothing and talks to nothing remote. Brikman lists the same strengths for static analysis: it runs fast and needs no authentication to a real provider.

Validation needs an initialized working directory, meaning the referenced providers and modules are installed. To initialize without touching a configured backend (the place state is stored), the docs give terraform init -backend=false. The lab ran validate first, before any init:

$ terraform validate -no-color

Error: Module not installed

  on main.tf line 24:
  24: module "labels" {

This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.
[exit 1]

Terraform cannot check how the root uses the child module until the module is installed, and nothing had been installed yet. After terraform init -backend=false -input=false the output included Installing hashicorp/random v3.9.1... and a note that Terraform "has created a lock file .terraform.lock.hcl". Validate then printed Success! The configuration is valid. and exited 0.

For machines, -json produces an object with valid (boolean), error_count, warning_count, and a diagnostics array whose entries carry severity, summary, detail and range. The broken/ copy changed one line to prefix = var.environment, a variable that was never declared. The lab piped the result through jq, a command-line JSON processor, and kept the interesting fields:

$ (cd broken) terraform validate -json | jq
{
  "valid": false,
  "error_count": 1,
  "warning_count": 0,
  "diag": [
    {
      "severity": "error",
      "summary": "Reference to undeclared input variable",
      "detail": "An input variable with the name \"environment\" has not been declared. This variable can be declared with a variable \"environment\" {} block.",
      "file": "main.tf",
      "line": 21
    }
  ]
}
[exit 1]

Validate did the job no formatter can do: it found a reference that points at nothing, named the file and line, and failed the run, with no credentials and no network beyond the earlier init. The docs warn that Terraform may hit an error before validation starts, and such an error is not subject to the JSON setting. Software that consumes the output must therefore be ready for stdout that is not valid JSON.

Now the part that surprises people. variable "env" has a validation rule that allows only dev or prod, and the lab passed a bad value to validate. Terraform 1.14.3 refused the flag:

$ terraform validate -no-color -var env=staging

Error: Failed to parse command-line flags

flag provided but not defined: -var

Terraform 1.16.5 accepted the flag and still printed Success! The configuration is valid. The current documentation lists -var and -var-file as options, while 1.14.3 did not have them. The exam tests 1.12, so do not rely on validate -var there. Even where it is accepted, the rule that allows only two values was not enforced by validate in this run. Plan enforced it:

$ terraform plan -input=false -no-color -var env=staging

Changes to Outputs:
  + generated_by = "a tool"
...
Error: Invalid value for variable

  on main.tf line 10:
  10: variable "env" {
    ├────────────────
    │ var.env is "staging"

env must be dev or prod.

This was checked by the validation rule at main.tf:13,3-13.
[exit 1]

This matches the documentation: to verify the configuration "in the context of a particular run (a particular target workspace, input variable values, etc)", use terraform plan, which includes an implied validation check. Brikman makes the broader point that validate is limited solely to syntactic checks, and that other tools exist to enforce policies.

Symptom: terraform validate succeeds, but terraform plan fails with "Invalid value for variable".

Cause: Validate checks the configuration, not the values that a particular run supplies, so a variable's validation rule is not exercised against those values.

Fix: Run terraform plan (or a test) with the real values to exercise the rule.

Reading logs with TF_LOG (objective 7c)

Terraform has detailed logs that stay off until you set the TF_LOG environment variable. Once set, the logs appear on stderr, the error output stream, separate from the normal output on stdout. The levels, in order of decreasing verbosity, are TRACE, DEBUG, INFO, WARN and ERROR. Setting TF_LOG=JSON produces TRACE-level output in a parseable JSON encoding, but the docs warn that this encoding is not a stable interface and may change at any time.

The reason to look is diagnosis. The troubleshooting tutorial names four kinds of problems: language, state, core and provider errors. Core logs belong in Terraform's GitHub issue tracker and provider logs in the provider's own, so choosing the right one matters when you file a bug. TF_LOG_CORE and TF_LOG_PROVIDER take the same levels as TF_LOG but activate only core logs or only provider plugin logs. TF_LOG_PATH appends the log to a file.

The lab first ran TF_LOG=INFO terraform plan with stderr redirected to a file. It produced 16 lines, beginning:

2026-10-20T13:48:14.040-0400 [INFO]  Terraform version: 1.14.3
2026-10-20T13:48:14.040-0400 [INFO]  Go runtime version: go1.25.5
2026-10-20T13:48:14.040-0400 [INFO]  CLI args: []string{"terraform", "plan", "-input=false", "-no-color"}

The first line records the exact Terraform version, the first thing a maintainer asks for. Further down, the same 16 lines show the random provider being started and stopped three times (three plugin process exited lines) during one plan, which is why provider problems can surface at more than one stage. The level is case-insensitive: TF_LOG=info produced the same 16 [INFO] lines, and TF_LOG=OFF produced none. A misspelled level is not rejected. TF_LOG=banana printed this warning:

[WARN] Invalid log level: "BANANA". Defaulting to level: TRACE. Valid levels are: [TRACE DEBUG INFO WARN ERROR OFF]

So a typo silently selects the most verbose level instead of failing, and the next plan floods the terminal.

The file variable behaved as documented. TF_LOG_PATH=path-only.log with no level set created the file with a size of 0 bytes. With TF_LOG=DEBUG TF_LOG_PATH=debug.log, the first run wrote 103 lines to the file and 0 lines to stderr. A second identical run grew it to 206 lines, which shows the append behavior.

The lab then split core from provider output:

SettingLines in fileLines from the provider plugin
TF_LOG_CORE=TRACE8320
TF_LOG_PROVIDER=TRACE239212

The provider lines carry the plugin's name, which is how you recognize them:

2026-10-20T13:48:14.699-0400 [INFO]  provider.terraform-provider-random_v3.9.1_x5: configuring server automatic mTLS: timestamp=2026-10-20T13:48:14.699-0400
2026-10-20T13:48:14.703-0400 [DEBUG] provider.terraform-provider-random_v3.9.1_x5: plugin address: address=/var/folders/f9/ybhjylss2171jy7_sm2jb5gw0000gn/T/plugin1717024864 network=unix timestamp=2026-10-20T13:48:14.703-0400

Searching for the plugin name separates the two sources; the tutorial's rule is to find the final error message and trace it back to its source. For TF_LOG=JSON, the first stderr line parsed by jq had the keys ["@level","@message","@timestamp"].

One documentation nuance: the debugging page says that even when TF_LOG_PATH is set, TF_LOG must be set for any logging to be enabled. The empty file from TF_LOG_PATH alone agrees with that. On Terraform 1.14.3, though, TF_LOG_CORE or TF_LOG_PROVIDER by themselves were enough to write the file, as the troubleshooting tutorial describes. For the exam, the safe answer is to set a level variable alongside the path.

Symptom: You set TF_LOG_PATH and the log file exists but is empty.

Cause: No level variable was set, so logging was never enabled and the path variable only decides where logs go once they exist.

Fix: Also set TF_LOG, or TF_LOG_CORE or TF_LOG_PROVIDER, to a valid level.

Putting it together

Back to the pull request. The reviewer's first two questions become a short sequence that runs before any plan touches real infrastructure:

terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validate -json | jq '{valid, error_count, warning_count}'
terraform plan -input=false

Each line gates the next. Format failures stop the run first because they are cheapest to fix, init installs what validate needs, validate catches undeclared references and misspelled arguments, and plan supplies the real variable values that validate never checks. In a script, the pipe to jq hides validate's own exit status behind jq's, so read the first command's status explicitly, as the lab did with PIPESTATUS[0]. Early errors also may not be JSON, so jq itself can fail on them.

The third question is answered when something fails. Rerun the failing command with a level and a path, for example TF_LOG_CORE=TRACE plus TF_LOG_PATH, for a suspected Terraform bug, since the tutorial says bug reports should use TRACE. Use TF_LOG_PROVIDER for a suspected provider bug. Then attach the file to the matching issue tracker, because the docs ask for the detailed log with any bug report. Plain TF_LOG covers both when the source is unknown.

Exam traps

Check yourself

Question 1
A CI job runs terraform fmt -check at the repository root and passes, but a module under modules/ has misaligned arguments. What went wrong and what is the fix?
tap to reveal
Without -recursive, fmt processes only the current directory, so the nested module was never scanned. Use terraform fmt -check -recursive.
Question 2
You run terraform validate in a fresh checkout that calls a local module and receive "Module not installed". What do you run next, and why does it avoid touching remote state?
tap to reveal
Run terraform init -backend=false, which installs modules and providers without accessing any configured backend. Then run validate again.
Question 3
A variable has a validation rule allowing only dev or prod. Validate reports success, yet a plan with -var env=staging fails. Why?
tap to reveal
Validate checks syntax and internal consistency regardless of provided variables or state, so it did not evaluate the value. Plan checks the configuration in the context of a particular run, including input values.
Question 4
You set only TF_LOG_PATH=run.log and run a plan. What do you find, and what should you add?
tap to reveal
The file is created but empty, because no level variable enabled logging. Add TF_LOG, or TF_LOG_CORE or TF_LOG_PROVIDER, with a valid level.
Question 5
You suspect a provider bug and want to file an issue with the right maintainers. Which variable do you set, and how do you recognize the relevant lines?
tap to reveal
Set TF_LOG_PROVIDER to TRACE, with TF_LOG_PATH to keep the output. Provider lines carry the plugin name, such as provider.terraform-provider-random_v3.9.1_x5 in the lab.

Sources