With a remote backend (e.g. S3), both Engineer A and Engineer B's Terraform CLI point at the exact same state file — there's no local-only state to isolate them from each other; they are, by design, operating on shared data.
Shared StateA's Terraform reaches out to the backend's locking mechanism — for S3, that's a separate DynamoDB table configured specifically for this purpose — and writes a lock record containing a unique lock ID, who holds it, and when.
Lock AttemptThe DynamoDB write succeeds because no lock record currently exists for this state file. A now holds the lock, and proceeds into its normal refresh → graph → plan sequence. Nothing about B's situation has changed yet.
CheckpointB's Terraform independently attempts the exact same lock-acquisition write against the same DynamoDB table — it has no idea A is running; the two CLI processes never talk to each other directly.
Lock AttemptDynamoDB's conditional write (used specifically for this purpose) rejects B's attempt because a lock record already exists. This isn't a race that "usually" works — the whole mechanism exists because a plain read-then-write would have a race condition; the conditional write is atomic.
RejectedB sees an error like Error acquiring the state lock, including who holds it (A's identity) and when they started. Crucially, nothing about B's apply has run — no refresh, no plan, no API calls — Terraform refuses to proceed at all without the lock.
BlockedA has no visibility into B's failed attempt at all — from A's side, this looks like a completely normal, uncontested apply proceeding through refresh, plan, and execution.
UnaffectedOnce A's apply completes (success or failure), Terraform deletes the lock record from DynamoDB as its final step — this is what actually frees the state file for the next apply attempt.
UnlockB re-runs terraform apply. This time the lock is free, so B acquires it and proceeds — but now against the updated state A just wrote, meaning B's own plan reflects A's changes as the new baseline, not what B originally saw.
RetryIf A's process is killed (laptop closes, network drops) before releasing the lock, the lock record is left stuck — this is the specific, narrow scenario terraform force-unlock exists for, and only after confirming no apply is genuinely still running.
Stale Lock| Term | Meaning |
|---|---|
| Remote Backend | Shared, external location (S3, Terraform Cloud, etc.) where state is stored |
| State Lock | Backend-enforced exclusive hold preventing concurrent writes to one state file |
| DynamoDB Table | Common S3-backend companion used specifically for atomic lock records |
| Conditional Write | An atomic "write only if not already present" operation preventing races |
| force-unlock | Manual override to clear a stuck lock — only safe if no apply is truly still running |
| Lock ID | Unique identifier in the lock record, shown in the "who holds it" error message |
| Stale Lock | A lock left behind by a crashed/killed Terraform process that never released it |
| Local State | State stored only on one machine's disk — no locking, no sharing, not for teams |
| Workspace | A separate state file under the same backend, one way to scope/limit locking |
| Drift | Divergence between state and real infrastructure — unrelated to locking, but caught by refresh |