Runbook: Investigate a Stuck State Lock
1 · Prerequisites
Confirm every item is in place before any state change.
- Troubleshooting State Issues
- The 3 AM Test: Production Under Pressure
- Common State Mistakes
- State Migrations and Refactoring
- State Versioning and Retention
- Controlled Apply: Saved Plans, Approvals, and Concurrency
- Read access to the backend lock item and to the audit trail for the identity named in the lock
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The full
Error acquiring the state lockblock is copied verbatim, including every field:ID,Path,Operation,Who,Version,Created. TheIDis the only argumentforce-unlockaccepts and it is not recoverable once the terminal scrolls - · The workspace and backend key in
Pathare confirmed to be the ones you think you are operating on. A lock on a workspace you did not intend to touch is a different problem - ·
terraform state pullstill works. Reads do not need the lock, so evidence gathering is unaffected by the lock being held - only the write path is blocked - · The age of the lock is compared against the longest apply this workspace has ever legitimately taken, not against a generic one-hour rule
- · The identity in
Whohas been looked up. It isuser@hostnameas reported by the client that took the lock, and on some backends it is not distinguishing at all - · Nobody has run
force-unlockor-lock=falseyet, and the incident channel knows not to
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Preserve the evidence before anything else. Record the lock ID, path, operation, who, version and creation time. The ID is required to unlock and disappears with the scrollback
- 2Classify the operation.
OperationTypeApplymeans a write may have been in flight.OperationTypePlanmeans it almost certainly was not. The aftermath is different and so is the urgency - 3Proof 1 - find the process. Locate the job or session named in
Who. Is the CI run still executing? Is the runner still alive?ps -ef | grep "[t]erraform"on the host, if you can reach it - 4Proof 2 - read the write timeline. Compare the lock
Createdtimestamp against the modification times of the state object versions. A state write that landed after the lock was taken proves the holder was doing real work; a write in the last few minutes proves it may still be - 5Proof 3 - check the API side. The cloud audit log shows whether the identity that owns the run is still making calls. This is the proof that survives an unreachable host
- 6Require two independent proofs before unlocking. Age alone is not one of them. A long apply and a dead apply look identical from the clock
- 7Snapshot the state before removing the lock:
terraform state pull > /tmp/state-before-unlock.json, and record the current backend object version id. The unlock does not change state, but what happens next will - 8Unlock with the exact ID:
terraform force-unlock LOCK_ID. Answer the confirmation prompt deliberately; do not add-forceto skip it - 9Establish what the dead apply left behind. Run
terraform plan -detailed-exitcode. Exit 2 after anOperationTypeApplylock means the apply was partial and the estate is between two states - 10Converge, do not repair. Re-run plan and apply so Terraform reconciles. Do not hand-edit state to "tidy up" what the failed apply did
- 11Fix the cause, not the symptom. If the lock came from a killed CI job, the pipeline needs a timeout and a lock-timeout. If it came from an operator closing a laptop, that is a change-process finding
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓The lock is gone: re-running the failed command no longer reports
Error acquiring the state lock - ✓
terraform plan -detailed-exitcodeexits 0. Exit 2 means the estate is still mid-apply, and the incident is open until that converges - ✓The state lineage is unchanged from the snapshot taken before the unlock:
jq -r .lineage /tmp/state-before-unlock.jsonagainst a freshterraform state pull - ✓The state serial is either unchanged or has advanced exactly once, by the run that converged it. Two unexplained advances mean two writers reached the state
- ✓The unlock decision is written down with the two proofs that supported it, the ID, and the name of the person who made the call
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶An unlock cannot be undone; the lock is gone. What is recoverable is the state, which is why the snapshot in step 7 is taken before the unlock and not after
- ↶If a second writer did reach the state, restore the pre-incident version of the state object from backend version history, then plan and expect exit 0
- ↶If the unlock turns out to have been wrong and the original holder is still applying: stop. Do not race it with a second apply. Let it finish or kill it deliberately, then reconcile from one state, not two
- ↶A
-lock=falseplan used for reading during the incident is harmless and needs no rollback. A-lock=falseapply is not, and anything it wrote must be reconciled against the state explicitly - ↶If the state was restored from an older version, every resource created between that version and now is now unmanaged. Enumerate them from the cloud API before closing the incident
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate to the owner of the job named in
Whobefore unlocking, whenever that person or team can be reached inside the incident window. Their answer is worth more than any of the three proofs - · Escalate to the platform team if the lock reappears immediately after being cleared. That is a live writer or a backend fault, not a stale lock
- · Escalate to security if
Whonames an identity nobody recognises, or a host that should not have write access to this state. An unexplained lock is an access question before it is an availability question - · Escalate to the change owner if the lock is held by a legitimate long-running apply. Waiting is the correct action, and it needs an owner and an expected finish time rather than a silent stall
- · Escalate to the backend owner if the lock item cannot be read at all. A lock you cannot inspect is not a lock you can safely remove
Removing the lock is one command and takes four seconds. Proving that the lock should be removed is the entire job, and it is the part that gets skipped at 2am because the command is right there and the error message practically suggests it.
Terraform is blunt about the stakes: “If you unlock the state when someone else is holding the lock it could cause multiple writers.” Multiple writers to one state does not produce a clean error. It produces a state that is missing whatever the losing writer recorded, discovered days later as resources Terraform wants to create that already exist.
So this runbook is a proof procedure with an unlock at the end, not an unlock procedure with some checks in front of it.
What the lock is, and what removing it does not fix
The lock is coordination metadata. It sits beside the state, not inside it:
Backend
|-- state data: resource instances, attributes, outputs, serial
|-- lock metadata: lock ID, owner, operation, timestamp
`-- configuration: resource addresses and dependency graph
A lock error therefore tells you nothing about whether the state is healthy, and clearing it repairs nothing. Treating a lock as state corruption is a recognised way to turn a five-minute wait into an afternoon: the operator force-unlocks, then starts “fixing” a state that was never broken.
force-unlock also does not stop the process that took the lock. It
removes the backend’s record of the lock. If that process is alive, it
carries on applying, and now nothing is stopping a second apply from
starting alongside it.
Read the error properly
The lock error carries six fields and they are not equally useful:
Error: Error acquiring the state lock
Error message: lock held by another process
Lock Info:
ID: 7c2e1c91-4cc0-2b1d-a64b-4db28d71b82d
Operation: OperationTypeApply
Who: ci-job-1842
Version: 1.9.8
Created: 2026-08-13 02:14:03 +0000 UTC
Path: infra-production
| Field | What it is worth |
|---|---|
ID | The only argument force-unlock accepts. Copy it before the terminal scrolls; it is not recoverable from anywhere else in the CLI |
Operation | The most useful field. OperationTypeApply means a write may have been in flight; OperationTypePlan means it almost certainly was not |
Who | A claim, in the form user@hostname, recorded by the client that took the lock. Not a process handle, and on some backends it is not even distinguishing |
Version | The Terraform version of the holder. A version nobody on your team runs is a strong hint about which pipeline it was |
Created | The start of the operation, not the last sign of life. This is the field people over-trust |
Path | The state key. Confirm it is the workspace you meant to be operating on |
The three ways a lock gets stuck
They look identical from the error message and have completely different aftermaths.
The holder died. SIGKILL, an evicted CI runner, a closed laptop, an
OOM. The process is genuinely gone and the lock was never released. If
the operation was an apply, it stopped mid-graph: some resources exist,
some do not, and the state may or may not have recorded the ones that
do. This is the case force-unlock was built for.
The holder is alive and you cannot see it. A network partition, a
runner on a subnet you cannot reach, a colleague on a train. The apply
is still making API calls. This is the case where force-unlock causes
the incident it was meant to resolve.
The holder finished and the release failed. The backend rejected the delete, or the process was killed in the window between the last state write and the unlock. Nothing is wrong with the estate at all; only the lock is left behind. The serial will have advanced and the plan will be clean.
Your job in the next three steps is to tell these apart.
Proof 1: find the process
ps -ef | grep '[t]erraform'Absence here is weak evidence on its own - you may simply be on the
wrong host, and a container hostname in Who may refer to something
that no longer exists by design. Presence, on the other hand, is
decisive: a running terraform process against this workspace ends the
investigation and the answer is to wait.
For a CI-held lock, the equivalent check is the pipeline itself: is the job still in a running state, and does its log show recent output?
Proof 2: read the write timeline
This is the proof most people skip and it is the cheapest of the three. Reads do not require the lock, so all of it works while the lock is held.
terraform state pull > /tmp/state-before-unlock.json
jq -r '"serial=\(.serial) lineage=\(.lineage)"' /tmp/state-before-unlock.json
# When was the state object last written, and how often?
aws s3api list-object-versions \
--bucket acme-tfstate-prod \
--prefix production/terraform.tfstate \
--query 'Versions[0:5].[LastModified,VersionId,IsLatest]' \
--output tableLine the newest version timestamps up against the lock’s Created
value and read the shape:
| Write timeline | Reading |
|---|---|
No write after Created | The holder took the lock and did nothing, or died before its first write |
A write after Created, then nothing for a long time | The holder was working and stopped. Consistent with a dead apply |
| A write in the last few minutes | The holder is alive. Stop here; do not unlock |
The last row is the one that earns this step its place. It is the only check in the runbook that can catch a live writer you cannot reach, using nothing but the backend you already have access to.
Proof 3: check the API side
The audit log for the identity the run uses - CloudTrail, Azure
Activity Log, GCP Cloud Audit Logs - answers whether that identity is
still making calls. It is the proof that survives an unreachable host
and an unhelpful Who field, and it is the one worth building a saved
query for before you need it.
The unlock, and what to do the moment after
# Substitute the ID from the lock error before running:
LOCK_ID=7c2e1c91-4cc0-2b1d-a64b-4db28d71b82d
terraform force-unlock "$LOCK_ID"Answer the confirmation prompt rather than skipping it with -force.
The prompt is the last place a wrong lock ID or a wrong working
directory gets caught, and the whole cost of reading it is one line.
Then find out what the dead apply left behind:
terraform plan -detailed-exitcode -no-color
echo "exit=$?"Exit 0 after an OperationTypePlan lock is the expected, boring
outcome. Exit 2 after an OperationTypeApply lock is also expected: the
apply stopped mid-graph and the estate is between two states.
That is recoverable and the recovery is convergence, not repair. Terraform does not roll back on failure; the state records what succeeded, and the next apply creates what is missing. What breaks this is somebody deciding to tidy the state up by hand first - a manual edit diverges the state from reality in a way the next plan cannot detect.
Backends differ, and yours may have moved
The lock is implemented by the backend, so the artefact you inspect depends on which one you run:
- S3 with a DynamoDB table. The lock is a table item keyed on the
bucket and state key, with a companion digest item. Read it, do not
delete it - removing the row by hand skips the lock-ID check that
force-unlockperforms, which is the safety mechanism stopping you from clearing somebody else’s lock. DynamoDB-based locking is deprecated and will be removed in a future minor version. - S3 with
use_lockfile = true. The lock is an object next to the state with a.tflockextension. The role needss3:GetObject,s3:PutObjectands3:DeleteObjecton that object specifically, and a role that was scoped for DynamoDB locking will not have them. - Local state. Local state files cannot be unlocked by another
process, so
force-unlockhas nothing to act on across machines.
If a .tflock object exists and the DynamoDB table also has an item,
somebody has changed the backend configuration and the two halves of
the estate are locking differently. Resolve that before unlocking
anything.
The change that removes most of these pages
Most “stuck lock” pages are not stuck locks. They are contention: a second run arrived while the first was legitimately applying, failed immediately, and the person holding the pager read a lock error and assumed the worst.
-lock-timeout turns that into a wait instead of a failure:
terraform apply -lock-timeout=10mPair it with a concurrency group in the pipeline so two runs against
the same state queue rather than race, and a job timeout so a hung
runner is killed at a known point instead of holding the lock until
someone notices.
Common patterns
| Symptom | Likely cause | First move |
|---|---|---|
Lock is minutes old, Operation is apply | Normal contention | Wait, or re-run with -lock-timeout |
| Lock cleared, reappears immediately | A live writer, or a backend fault | Do not unlock again; escalate |
Who names a container hostname that no longer exists | Ephemeral CI runner | Prove death from the pipeline and the audit log, not the hostname |
| Serial advanced but the lock remains | The holder finished; the release failed | Unlock; expect a clean plan |
| Plan is clean after unlocking an apply lock | The apply had not started writing, or it completed | Nothing further; record it |
| Two unexplained serial advances after unlocking | Two writers reached the state | Restore from version history; reconcile from one state |
force-unlock denied by the backend | The role can write state but not the lock item | Fix the IAM policy; a lock you cannot remove is a permissions gap |