Open-Source Wikis

/

Terraform

/

Background

/

Pitfalls

hashicorp/terraform

Pitfalls

Specific mistakes that experienced Terraform contributors have all made at least once. Reading this list is faster than re-deriving each lesson.

Configuration addresses ≠ state addresses

A resource "aws_instance" "web" { count = 3 } block has one configuration address (aws_instance.web) but three state addresses (aws_instance.web[0], [1], [2]). Code that mixes them up usually fails on for_each or count configurations.

The address types in internal/addrs make this distinction explicit:

  • addrs.Resource, addrs.AbsResource — configuration-level.
  • addrs.AbsResourceInstance — state-level.

When in doubt, look at the type of the variable; the compiler is your friend here.

Marks must be reapplied after cty.UnmarkDeep

Code that wants to operate on a cty.Value (encode it, hash it, decode it) often calls cty.UnmarkDeep(v) to get a clean value, runs its logic, and then forgets to reapply the marks to the result. The result is a sensitive value silently being treated as non-sensitive — the worst possible failure mode.

Pattern:

unmarked, marks := v.UnmarkDeep()
result := doStuffWith(unmarked)
result = result.WithMarks(marks)  // <-- don't forget this

internal/lang/marks/ has helpers; use them instead of rolling your own.

Unknown values must propagate

A cty.Value can be unknown (its concrete value isn't yet known) but still have a known type. The engine handles this by producing changes that are themselves partially unknown.

Code that assumes a value is concrete — v.AsString() without checking v.IsKnown() — will panic on unknown values. Conditional expressions (?:) and references to unset values regularly produce unknowns. The fix is always to check IsKnown() first and either propagate the unknown or produce a partial result with the right type.

Ephemeral values can't end up in state

The marks.Ephemeral mark indicates a value that exists only for the duration of one operation. Anything that tries to persist an ephemeral value to state, plans, or non-ephemeral outputs must either error or strip the value. The engine enforces this at the persistence boundary, but it's easy to introduce a path that bypasses the check.

When adding new code that persists values (state writes, plan writes, output rendering), search for marks.Ephemeral to see how existing code handles it.

-target produces deferred actions

When the user passes -target=aws_instance.web, Terraform plans only that resource and its dependencies. Anything that depends on the targeted resource but wasn't itself targeted gets recorded as a deferred action — a change that the engine knows it should make but can't fully describe yet because the upstream isn't applied.

Code that assumes "every resource in the configuration has a change in the plan" misses this case. Look for the IsDeferred field on ResourceInstanceChange and the DeferredResources list on Plan.

State and plan are concurrent objects during a walk

states.SyncState and plans.ChangesSync are mutex-protected wrappers, but they only protect their own contents — not the values inside them. Returning a pointer to a *states.ResourceInstanceObject and then mutating it without going back through SyncState.SetResourceInstanceCurrent is a data race.

The pattern to follow: read via Sync, copy out the relevant fields, do your work locally, write back via Sync. Never mutate fields on objects retrieved from Sync.

Schemas come from providers, not from the engine

Terraform Core has no knowledge of aws_instance's attributes. Every schema it uses comes from a GetProviderSchema RPC call against the provider plugin. Code that hardcodes attribute names from a specific provider doesn't belong in core.

The exception is the language-level schemas — terraform { required_providers { ... } }, module {}, output {}, etc. Those live in internal/configs/.

Diagnostics.Append accepts almost anything

diags = diags.Append(otherDiags)         // tfdiags.Diagnostics
diags = diags.Append(&otherDiagnostic)   // *tfdiags.Diagnostic
diags = diags.Append(err)                // error
diags = diags.Append(hclDiags)           // hcl.Diagnostics

This is convenient. It's also a foot-gun: passing a value (not a pointer) of tfdiags.Diagnostic works at compile time but produces nil-pointer panics inside Append because the path that handles Diagnostic expects a pointer. Always pass &d or use tfdiags.Diagnostics{d} directly.

tfdiags.Diagnostics is not safe to mutate concurrently

The vertex-level Execute returns a Diagnostics; the walker collects them into a single Diagnostics for the whole walk. The collection is mutex-guarded by the walker, but if your Execute shares a Diagnostics value with another goroutine and both append to it, you'll race.

The pattern: each goroutine builds its own local Diagnostics, returns it, and the walker handles aggregation.

Backend changes require user prompts

terraform { backend "..." {} } changes are interactive by default. Tests and CI run with -input=false, but the migration logic in internal/command/meta_backend_migrate.go still distinguishes the cases. Adding a new migration scenario means adding both an interactive code path and a deterministic non-interactive path. Forgetting the latter shows up as silent test failures.

The local backend wraps "regular" backends

Most backends aren't operations backends — they only do state storage. The CLI wraps them in local.Local to actually run plans and applies. Code that thinks it's running against the configured backend is usually running against local.Local against the configured backend's state manager. The distinction matters when reasoning about hooks, lifecycle, and view rendering.

Don't compare cty.Value with ==

cty.Value has its own Equals method that returns another cty.Value (yes, really — it can be unknown if either input is unknown). Use v1.RawEquals(v2) for Go-level structural equality, or v1.Equals(v2) for cty-level equality (and unwrap the result).

The walker is concurrent; Execute must be too

Every GraphNodeExecutable.Execute runs in its own goroutine. Touching package-global state (other than through EvalContext) is a race. The exception is true read-only globals (e.g. the function table, the schema cache).

Tests use MockProvider, real runs use GRPCProvider

The provider interface is the same, but tests synthesize values directly while production goes through gRPC. Code that works in tests can fail in production if it depends on side effects of gRPC marshaling — for example, marks that are stripped at the wire boundary but preserved in MockProvider.

When a test passes but the e2e test fails, the gRPC marshaling difference is a frequent culprit.

Hooks block the walker

Hooks are called from inside vertex evaluation. A slow hook (one that does I/O on every call) blocks parallelism. The CLI's progress hooks are deliberately cheap; new hooks should follow the same discipline.

State reads need locks

The default statemgr.Filesystem acquires a terraform.tfstate.lock.info lockfile around state operations. Reads bypass the lock by default, but writes do not. Code that bypasses statemgr and reads state directly from disk can return stale data — always go through the manager.

terraform init does too many things

internal/command/init.go (50 KB) coordinates module installation, backend migration, provider installation, and lock-file maintenance, with a precedence order between the various flags (-upgrade, -reconfigure, -migrate-state, -input=false, -plugin-dir=...). Adding a new flag rarely fits cleanly. Read the existing flag-handling carefully before writing new code in this file.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Pitfalls – Terraform wiki | Factory