Skip to main content
RunBook Academy

← All runbooks in Terraform

high riskdata loss risk~25 min

Runbook: Investigate a Stuck State Lock

1 · Prerequisites

Confirm every item is in place before any state change.

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 lock block is copied verbatim, including every field: ID, Path, Operation, Who, Version, Created. The ID is the only argument force-unlock accepts and it is not recoverable once the terminal scrolls
  • · The workspace and backend key in Path are 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 pull still 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 Who has been looked up. It is user@hostname as reported by the client that took the lock, and on some backends it is not distinguishing at all
  • · Nobody has run force-unlock or -lock=false yet, 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.

  1. 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
  2. 2Classify the operation. OperationTypeApply means a write may have been in flight. OperationTypePlan means it almost certainly was not. The aftermath is different and so is the urgency
  3. 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
  4. 4Proof 2 - read the write timeline. Compare the lock Created timestamp 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
  5. 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
  6. 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
  7. 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
  8. 8Unlock with the exact ID: terraform force-unlock LOCK_ID. Answer the confirmation prompt deliberately; do not add -force to skip it
  9. 9Establish what the dead apply left behind. Run terraform plan -detailed-exitcode. Exit 2 after an OperationTypeApply lock means the apply was partial and the estate is between two states
  10. 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
  11. 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-exitcode exits 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.json against a fresh terraform 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=false plan used for reading during the incident is harmless and needs no rollback. A -lock=false apply 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 Who before 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 Who names 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
FieldWhat it is worth
IDThe only argument force-unlock accepts. Copy it before the terminal scrolls; it is not recoverable from anywhere else in the CLI
OperationThe most useful field. OperationTypeApply means a write may have been in flight; OperationTypePlan means it almost certainly was not
WhoA 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
VersionThe Terraform version of the holder. A version nobody on your team runs is a strong hint about which pipeline it was
CreatedThe start of the operation, not the last sign of life. This is the field people over-trust
PathThe 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

Read-only / Safeon the host named in Who, if you can reach it
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.

Read-only / Safedid the holder write, and when
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 table

Line the newest version timestamps up against the lock’s Created value and read the shape:

Write timelineReading
No write after CreatedThe holder took the lock and did nothing, or died before its first write
A write after Created, then nothing for a long timeThe holder was working and stopped. Consistent with a dead apply
A write in the last few minutesThe 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

Destructiveremoves the lock; does not stop the holder
# 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:

Read-only / Safethe state is unlocked; that does not mean it is complete
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-unlock performs, 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 .tflock extension. The role needs s3:GetObject, s3:PutObject and s3:DeleteObject on 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-unlock has 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:

Configuration changewait for the lock instead of failing on it
terraform apply -lock-timeout=10m

Pair 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

SymptomLikely causeFirst move
Lock is minutes old, Operation is applyNormal contentionWait, or re-run with -lock-timeout
Lock cleared, reappears immediatelyA live writer, or a backend faultDo not unlock again; escalate
Who names a container hostname that no longer existsEphemeral CI runnerProve death from the pipeline and the audit log, not the hostname
Serial advanced but the lock remainsThe holder finished; the release failedUnlock; expect a clean plan
Plan is clean after unlocking an apply lockThe apply had not started writing, or it completedNothing further; record it
Two unexplained serial advances after unlockingTwo writers reached the stateRestore from version history; reconcile from one state
force-unlock denied by the backendThe role can write state but not the lock itemFix the IAM policy; a lock you cannot remove is a permissions gap

References

  1. State Locking
  2. terraform force-unlock command
  3. S3 backend (use_lockfile, DynamoDB locking deprecation)
  4. terraform plan command (-lock, -lock-timeout, -detailed-exitcode)
  5. terraform state (remote state and backups)