Most of the time, terraform plan and terraform apply tell you everything you need: what will change, what changed, and what failed. But sooner or later you hit the run where the error message is cryptic, the provider does something you can’t explain, or Terraform simply crashes. That’s when you stop reading the pretty output and start reading the logs — the raw, verbose trace of what Terraform’s core and the provider plugins are actually doing under the hood.
Debugging is a small but very real part of operating Terraform, and the HashiCorp Terraform Associate exam expects you to know how to turn logging on, where the output goes, and how to interpret the handful of errors every practitioner eventually meets. This guide covers exactly that: the TF_LOG family of environment variables, the meaning of each log level, crash.log, and a field guide to the most common Terraform errors and how to fix them.
This post is the diagnostics companion to the everyday CLI. For the commands themselves — fmt, validate, console, state — see the Terraform commands cheat sheet; for evaluating expressions interactively, the functions and expressions guide covers terraform console in depth. Here we focus on what those commands don’t show you: the logs.
When Normal Output Isn’t Enough
Terraform’s standard output is deliberately human-friendly. It hides the machinery: the gRPC calls between Terraform core and each provider, the DAG (dependency graph) walk, the state refresh requests, the retries. Ninety-nine percent of the time you want it hidden. The other one percent, you need to see it — typically when:
- A provider returns an error that doesn’t match anything in your configuration (“why is it calling that API?”).
planandapplydisagree, or anapplyfails partway through and you need to know exactly which resource and which request broke.- A run hangs, and you want to see the last request before it stalled.
- Terraform crashes with a Go panic and a backtrace.
- You’re filing a bug report and the maintainers ask for a debug log.
The tool for all of these is the TF_LOG environment variable. It is not a command-line flag — it’s an environment variable you export before running Terraform, which means it applies to whatever terraform command you run next.
Turning On Logging with TF_LOG
Set TF_LOG to a log level and run any Terraform command. The logs stream to stderr:
export TF_LOG=TRACE
terraform apply
To turn logging back off, unset it:
unset TF_LOG
For a one-off run without changing your shell environment, prefix the command:
TF_LOG=DEBUG terraform plan
That single-command form is the one to reach for most of the time — it captures logs for exactly one run and leaves your shell clean afterward, so you don’t accidentally generate gigabytes of TRACE output on every subsequent command.
The Log Levels, From Loudest to Quietest
TF_LOG accepts five levels. They are ordered by verbosity — each level includes everything more severe than itself:
| Level | Verbosity | What it surfaces |
|---|---|---|
TRACE | Highest | Everything: every gRPC call to providers, graph walking, state operations, internal decisions. The default when TF_LOG is set to an unrecognized value. |
DEBUG | High | Detailed operational messages — useful without the full RPC firehose. |
INFO | Medium | High-level informational messages about what Terraform is doing. |
WARN | Low | Warnings only. |
ERROR | Lowest | Errors only. |
A few things worth committing to memory:
TRACEis the most complete and the one HashiCorp treats as fully supported for debugging. The other levels are best-effort and their exact output can change between versions. If you’re chasing a real bug, useTRACE.- If you set
TF_LOGto any value Terraform doesn’t recognize — sayTF_LOG=1orTF_LOG=yes— it falls back toTRACE. So there’s no way to “accidentally” get a quiet log by typo; you get the loudest one. - Logs are not part of Terraform’s stable, machine-readable interface. Don’t parse them in automation. For structured, parseable output use the
-jsonflag onplan/applyinstead — that’s a separate, supported feature.
Splitting Core and Provider Logs
By default TF_LOG sets the level for both Terraform core and every provider plugin, which is a lot of noise. Since Terraform 0.15 you can target them separately:
| Variable | Controls | Typical use |
|---|---|---|
TF_LOG | Both core and providers | Broad debugging |
TF_LOG_CORE | Terraform core only | ”Is this a graph/state/core problem?” |
TF_LOG_PROVIDER | Provider plugins only | ”Is this the AWS/Azure/GCP provider misbehaving?” |
This split is the single biggest quality-of-life improvement when debugging. If a resource behaves strangely, set TF_LOG_PROVIDER=TRACE and leave core quiet — now you see only the provider’s API conversation:
TF_LOG_PROVIDER=TRACE terraform apply
Conversely, if plan produces a dependency cycle or a strange ordering, TF_LOG_CORE=TRACE shows you core’s graph walk without the provider’s HTTP chatter drowning it out. When both TF_LOG and one of the specific variables are set, the more specific one wins for its component.
Persisting Logs to a File with TF_LOG_PATH
TRACE output scrolls off the terminal in seconds. To keep it, point TF_LOG_PATH at a file. It requires TF_LOG (or one of the core/provider variants) to also be set — the path variable only tells Terraform where to write, not whether to write:
export TF_LOG=TRACE
export TF_LOG_PATH=./terraform-debug.log
terraform apply
Now the human-readable summary still appears in your terminal, and the full trace lands in terraform-debug.log for you to grep, diff, or attach to a bug report. This is the standard recipe when you need to reproduce an issue and share it: set the level, set the path, reproduce, then read the file.
# Find the failing RPC call quickly
grep -i "error" terraform-debug.log | head
crash.log: When Terraform Panics
There’s a difference between Terraform reporting an error and Terraform crashing. An error is expected and handled — bad credentials, an invalid argument, a locked state. A crash is an unexpected Go panic inside Terraform core or a provider, and it’s a bug, not a misconfiguration.
When Terraform core panics, it writes a file called crash.log to the current working directory. That file contains the panic message and the full Go stack trace (backtrace) at the moment of the crash. You are not expected to fix the crash yourself — you’re expected to:
- Capture more context by re-running with
TF_LOG=TRACEandTF_LOG_PATHset, if you can reproduce it. - File an issue with the Terraform (or provider) maintainers.
- Attach
crash.logand the trace log to the report.
Provider crashes are handled similarly — a provider plugin that panics produces its own crash output. The key exam-relevant fact: crash.log is generated automatically on a panic, it lives in the current directory, and it exists so maintainers can diagnose the bug. It is not something you commit or keep; treat it as a bug-report artifact.
A Field Guide to Common Terraform Errors
Most “why did Terraform do that?” moments never need TRACE logs — they need you to recognize a familiar error. Here are the ones you’ll see most, and the exam loves to describe as scenarios.
”Error acquiring the state lock”
Terraform locks state during any operation that could write to it, so two applies can’t corrupt the file. If a previous run was killed (Ctrl-C at the wrong moment, a crashed CI job, a dropped network connection to the backend), the lock can be left behind:
Error: Error acquiring the state lock
Lock Info:
ID: 4d1f...
Operation: OperationTypeApply
The fix is terraform force-unlock <LOCK_ID>, using the ID from the message — but only after you’re certain no other apply is genuinely running. Force-unlocking a live operation is how you corrupt state. When it’s a stale lock, it’s safe and routine. (The commands cheat sheet lists this under recovery.)
”Cycle: …”
A dependency cycle means resource A depends on B and B depends (directly or transitively) on A, so Terraform can’t order the graph:
Error: Cycle: aws_security_group.a, aws_security_group.b
Visualize it with terraform graph, then break the loop — often by splitting a rule out of a resource (for example, using a standalone aws_security_group_rule instead of inline rules that reference each other), or by removing an unnecessary depends_on.
”value depends on resource attributes that cannot be determined until apply”
This is the classic count/for_each trap. The number of instances (count) or the set of keys (for_each) must be known at plan time. If you derive them from an attribute that only exists after another resource is created — an ID, an ARN, a computed value — Terraform can’t plan:
Error: Invalid count argument
The "count" value depends on resource attributes that cannot be
determined until apply.
Fixes, in order of preference: base count/for_each on input variables or locals that are known up front rather than computed attributes; or, when you genuinely must create the dependency first, do a two-stage apply with -target to build the upstream resource, then apply normally. The count vs for_each guide covers how to choose the meta-argument in the first place.
”Provider produced inconsistent result after apply”
This one is usually not your fault. It means the provider told Terraform it would set a value one way during plan, then set it differently after apply — a provider bug. Note the resource and attribute, check whether upgrading the provider fixes it, and if not, report it upstream (this is a good time to capture a TF_LOG_PROVIDER=TRACE log).
”Unsupported argument” / “Unsupported block type”
Almost always a version mismatch: your configuration uses an argument that doesn’t exist in the provider version you actually have installed. Check your required_providers version constraints and the lock file, and confirm what got installed. The providers guide explains version constraints and how the provider block resolves them.
Backend and init errors
When you change a backend configuration, terraform init may refuse to proceed and ask you to reconcile:
terraform init -reconfigure— ignore the existing backend config and start fresh (don’t migrate state).terraform init -migrate-state— move the existing state to the new backend.
Reach for these when init complains that “Backend configuration changed.” The state management guide covers how backends and state fit together.
A Debugging Workflow You Can Reuse
Put it together into a repeatable routine rather than flailing at TRACE logs every time:
| Symptom | First move |
|---|---|
| Cryptic provider error | TF_LOG_PROVIDER=TRACE, reproduce, read the last API call |
| Strange ordering / cycle | terraform graph, then TF_LOG_CORE=TRACE if needed |
| Run hangs | TF_LOG=TRACE + TF_LOG_PATH, look at the final request |
| Terraform panics | Grab crash.log, re-run with TRACE, file a bug |
count/for_each won’t plan | Move the value to a variable/local, or two-stage with -target |
| Stale state lock | terraform force-unlock <ID> (verify nothing is running) |
| An expression returns the wrong value | terraform console to test it in isolation |
The general principle: narrow before you widen. Use the most specific log variable (TF_LOG_PROVIDER or TF_LOG_CORE) before the broad TF_LOG, persist with TF_LOG_PATH so you can read at leisure, and always try to reproduce with logging on rather than guessing after the fact.
Debugging in CI/CD Pipelines
In automation, the same variables apply — you just set them in the pipeline environment. A useful pattern is to keep runs quiet by default and flip on debug logging only when a job fails and you re-run it:
# Enable verbose logs for a single re-run by setting a pipeline variable
env:
TF_LOG: ${{ vars.TF_DEBUG == 'true' && 'TRACE' || '' }}
TF_LOG_PATH: ${{ vars.TF_DEBUG == 'true' && 'terraform.log' || '' }}
Then archive terraform.log (and crash.log, if present) as a build artifact so you can inspect a failure without shell access to the runner. Never leave TF_LOG=TRACE on for every pipeline run — trace logs are enormous, slow down runs, and can leak sensitive values that appear in provider requests into your CI logs.
Security note: debug logs can contain sensitive data — tokens in request headers, secrets in resource payloads. Treat any
TF_LOGoutput like a secret: don’t paste it into public issues without scrubbing, and don’t store it in world-readable CI artifacts.
How the Exam Tests This
The Terraform Associate exam won’t ask you to read a raw TRACE dump, but it does test the surrounding knowledge in recognizable ways:
- Enabling logs. “How do you enable verbose logging for a single Terraform run?” → set the
TF_LOGenvironment variable (and know it’s an env var, not a flag). - Levels. Recognize that
TRACEis the most verbose/complete level. - Persisting output. Know
TF_LOG_PATHwrites logs to a file and requiresTF_LOGto be set. - Crashes vs errors. Know that a panic produces
crash.logfor bug reporting, distinct from an ordinary error. - Scenario recovery. “A teammate’s apply was interrupted and now every operation fails with a lock error — what do you run?” →
terraform force-unlock. - The
count/for_eachunknown-value trap shows up constantly.
Frequently Asked Questions
Is TF_LOG a command-line flag or an environment variable?
An environment variable. You export TF_LOG=TRACE (or prefix a single command with TF_LOG=TRACE terraform ...). There is no --log-level flag on the Terraform CLI.
What’s the difference between TF_LOG, TF_LOG_CORE, and TF_LOG_PROVIDER?
TF_LOG sets the level for both Terraform core and provider plugins at once. TF_LOG_CORE targets only core; TF_LOG_PROVIDER targets only the providers. Use the specific ones to cut noise when you already suspect which component is misbehaving.
Why is nothing written to my TF_LOG_PATH file?
TF_LOG_PATH only sets the destination — you must also set TF_LOG (or TF_LOG_CORE/TF_LOG_PROVIDER) to a level. With no level set, there’s nothing to write.
What should I do when I see crash.log?
Don’t try to fix the panic yourself. If you can, reproduce it with TF_LOG=TRACE and TF_LOG_PATH set, then open an issue with the Terraform or provider maintainers and attach crash.log along with the trace log. crash.log is a bug-report artifact, not something to commit.
How do I fix “The count value depends on resource attributes that cannot be determined until apply”?
Base count (or for_each) on values known at plan time — input variables or locals — instead of attributes computed by another resource. When the dependency is unavoidable, apply the upstream resource first with -target, then run a normal apply.
Can debug logs leak secrets?
Yes. TRACE logs can include request payloads and headers containing credentials or secret values. Scrub logs before sharing them publicly and keep CI log artifacts access-controlled.
Conclusion
Debugging Terraform is less about memorizing a tool and more about knowing which layer to look at. TF_LOG and its TF_LOG_CORE/TF_LOG_PROVIDER variants let you dial verbosity for exactly the component you suspect; TF_LOG_PATH keeps the output so you can actually read it; crash.log captures the rare true panic for the maintainers. Around those tools sits a small library of common errors — lock conflicts, cycles, the count/for_each unknown-value trap, provider version mismatches — that you learn to recognize on sight.
The fastest way to make that recognition automatic is scenario practice: questions that hand you a symptom and ask for the command or the cause. The Sailor.sh HashiCorp Terraform Associate mock exams include troubleshooting and CLI scenarios with explanations of why each answer is right, so the difference between force-unlock and -reconfigure, or between an error and a crash, becomes reflexive. Warm up with the free Terraform Associate practice questions, and round out your CLI fluency with the commands cheat sheet and the core workflow guide. For the full objective map, see the Terraform Associate exam guide for 2026.