A previous run was killed before it released the lock
CommonCtrl-C, a CI job timeout, a runner scaled away, a lost network connection, a laptop closed. Terraform tries to release on interrupt, but a hard kill gives it no chance, so on a remote backend the lock persists with the dead run's metadata still attached. Local state is different: the operating system drops the lock with the process. Reproduced with Terraform 1.14.0: after kill -9 on the holder, the next plan ran at once, though a stale .terraform.tfstate.lock.info file was left behind.
Confirm
# Read the Created and Who from the error, then check that run. # For CI, find the job that matches and confirm how it ended.
A creation time matching a run you know ended abnormally, and a Who naming a machine or job that is no longer running. Both together make this safe to clear.
Fix
- Confirm the holder is genuinely gone before doing anything else. That means checking the actual job or machine, not inferring from elapsed time.
- Then release it with the lock ID from the error message. The ID is required, and on S3 Terraform checks it against the stored lock and refuses if they differ, so a lock that has changed hands since you read the error is not removed.
terraform force-unlock <LOCK_ID>
Only after confirming the holder is dead. If you are guessing, you are choosing between a blocked pipeline and corrupted state, and the first is much cheaper. On the pg backend this does not work: the lock is a Postgres advisory lock held by the other session, released only when that session ends (find it in pg_locks). With local state, force-unlock from another terminal cannot clear a lock held by a live process: it reports Failed to unlock state: LocalState not locked (reproduced).