Error Acquiring the State Lock: When Terraform force-unlock Is Safe
Someone cancels a pipeline. Ten minutes later the next apply fails, then the one after that, then the deploy for a completely unrelated service that happens to share the state. Within half an hour there is a Slack thread with six people in it and someone has already pasted terraform force-unlock with a shrug emoji.
That command is not a "clear the error" button. It is you signing a statement that says: I have checked, and no other process is currently writing this state. If that statement is false, Terraform does exactly what you told it and lets a second writer in alongside the first. Nothing errors. You find out later, when a plan wants to destroy production because half of it disappeared from state.
So the interesting question is never "how do I force-unlock". It is "how do I know it is safe to". That takes about ninety seconds, and this is how you spend them.
read the Who and Created fields in the Lock Info block, then open your CI run history and confirm that run is finished or cancelled. Only then run terraform force-unlock <LOCK_ID> using the exact ID from the error. If a run really is still in flight, wait: forcing it produces two concurrent writers, which is far worse than five minutes of waiting. And never use -lock=false to get past this.
The exact error: Error acquiring the state lock
Error: Error acquiring the state lock
Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Path: my-bucket/prod/terraform.tfstate
Operation: OperationTypeApply
Who: runner@fv-az1024-321
Version: 1.9.8
Created: 2026-03-19 14:23:01.123456 +0000 UTC
Info:
Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.
Everything you need to make the decision is in that block, and most people scroll straight past it to the ID.
- Who is
user@hostnamefrom the machine that took the lock. A CI runner hostname here (runner@fv-az...,root@runner-xyz) tells you immediately this is a pipeline lock, not a colleague at a laptop. - Created is when the lock was taken. Compare it to how long your longest apply legitimately takes. Two minutes old is probably a live run. Nine hours old is a corpse.
- Operation distinguishes
OperationTypePlanfromOperationTypeApply. A dead plan lock is harmless to clear. A dead apply lock means resources may have been created that never made it into state.
Why a cancelled CI job leaves the lock behind
Terraform releases the lock as part of shutting down cleanly. It also handles an interrupt: the first Ctrl+C asks it to stop gracefully, finish what it is doing and unlock. What it cannot handle is SIGKILL, and that is precisely what a cancelled or timed-out CI job eventually sends. The process disappears mid-flight, and the lock is just a row in a table or an object in a bucket that nobody deleted.
This is worth being precise about, because the lock is the symptom and rarely the real damage:
Where the lock actually lives, by backend
You cannot inspect or clean up a lock you cannot find, and it is different for every backend:
| Backend | Where the lock lives | Manual inspection |
|---|---|---|
S3 with use_lockfile = true | An object next to the state, key + .tflock | aws s3api head-object |
S3 with dynamodb_table (deprecated) | Item in the table, LockID = bucket/key | aws dynamodb get-item |
| azurerm | Blob lease on the state blob | az storage blob show, lease state |
| gcs | Lock object in the same bucket | gsutil ls |
| HCP Terraform / Terraform Cloud | Workspace-level lock | Workspace UI has an unlock action |
| GitLab-managed state (http) | Held by GitLab for that state name | Infrastructure > Terraform states page |
One change worth knowing if you are on the S3 backend: use_lockfile now does locking natively through S3 conditional writes, and dynamodb_table and dynamodb_endpoint are deprecated and slated for removal. If you are still running a lock table purely for locking, that is a migration you can do in an afternoon and it removes an entire class of "the table got deleted" incidents.
The decision, in the right order
Step 1: Confirm nothing is actually running
Not "ask in Slack and wait ten seconds". Look at the run history in GitHub Actions, GitLab CI, Jenkins, Atlantis, whatever executes your Terraform, and find the run whose start time matches Created and whose runner matches Who. You want to see it in a terminal state: cancelled, failed, timed out.
If the state is shared by more than one pipeline, check them all. This is the moment where "I only looked at the pipeline I was running" becomes a state corruption incident.
Step 2: Force-unlock with the exact ID
terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890
Terraform prints a warning and asks you to type yes. Read the warning rather than reflexively confirming; it is showing you the same lock info again as a last chance. On success you get a short confirmation that the state has been unlocked, and nothing else happens: force-unlock never touches your infrastructure or your state contents.
The command takes the lock ID as a positional argument and has one flag, -force, which only skips the confirmation prompt. There is no "unlock everything" mode, and that is deliberate.
Step 3: When force-unlock itself fails
If the backend is unreachable, or the lock ID no longer matches what is stored, you can go to the storage layer directly. For the S3 native lock:
aws s3api head-object --bucket my-bucket --key prod/terraform.tfstate.tflock
aws s3api delete-object --bucket my-bucket --key prod/terraform.tfstate.tflock
For a DynamoDB lock table:
aws dynamodb get-item \
--table-name terraform-locks \
--key '{"LockID": {"S": "my-bucket/prod/terraform.tfstate"}}'
aws dynamodb delete-item \
--table-name terraform-locks \
--key '{"LockID": {"S": "my-bucket/prod/terraform.tfstate"}}'
Delete only the lock item. The same table usually also holds an item whose LockID ends in -md5. That one is not a lock, it is the checksum Terraform uses to detect a stale read from S3. Delete it and your next run will refuse to proceed because the state in S3 does not match the digest it expected, and now you have a second, more confusing problem on top of the first.
Step 4: Reconcile, because the lock was never the real issue
terraform plan -detailed-exitcode
If the killed run was an apply, this is the important step. Exit code 0 means no changes and you are clean. Exit code 2 means there is a diff, and you now need to read it properly: a plan that wants to create resources you can see already existing in the console means the apply created them and died before writing state. Those need terraform import, or deleting by hand, before anything else happens on this stack.
Verification
terraform plan
A clean plan that gets past the refresh stage without a lock error is the proof. If you want to check the lock is genuinely gone at the storage layer, aws s3api head-object on the .tflock key should return a 404, or get-item on the DynamoDB key should return an empty response rather than an item.
One more check worth doing after any forced unlock on a versioned bucket: list the state object versions and confirm the most recent one has a sensible timestamp and size. A state file that suddenly shrank by 90% is the signature of a split-brain write, and the fix is restoring the previous object version, which is the same reflex as keeping a real point-in-time recovery path for a database.
What people get wrong
Using -lock=false. This is the worst thing in this article. It does not clear the stale lock, it tells Terraform to ignore locking entirely for this run, which means you will happily write state on top of whatever the other process is doing. The error text itself says it is not recommended, and it is in there for backends that genuinely cannot lock, not as an escape hatch.
Putting force-unlock in the pipeline as a pre-step. I have seen this "fix" more than once: a step that unlocks before every apply so the job never gets blocked. That deletes your concurrency protection completely. The whole point of the lock is to stop two pipeline runs from writing at once, and this guarantees they can.
Deleting the DynamoDB table or emptying it. Beyond the -md5 problem above, you are also blowing away locks for every other state that shares the table. Someone else's live apply is now unprotected.
Assuming force-unlock rolls something back. It does not. It removes a lock record. Any resources created before the process was killed are still there, still billing, and now invisible to Terraform.
Retrying the pipeline until it works. If a run really is in flight, retrying just queues up more contenders, and whichever one wins will be operating on a state that is about to be overwritten by the run you did not wait for.
When it is still broken
- The lock reappears within seconds of unlocking. Something is genuinely running. Look for a scheduled pipeline, an Atlantis autoplan, or a drift-detection job you forgot about.
- Terraform complains the state does not match the expected digest. You (or someone) removed the
-md5item, or an S3 write is still settling. Wait a couple of minutes and retry before touching anything; if it persists, restore the state object from bucket versioning. - Force-unlock says the lock ID does not match. The stored lock is from a different operation than the error you are reading, usually because a second run took the lock in between. Re-run the command that failed to get a fresh error and a fresh ID.
- It happens every week. Then fix the cause, not the symptom: give the job a timeout longer than your slowest apply, make cancellation send a graceful signal with a grace period rather than an immediate kill, split one giant state into per-environment states so a stuck lock does not block everyone, and move to S3 native locking with bucket versioning on.
The general lesson is the same one as reading the conditions on a Kubernetes HPA: the tool already told you what it knows. The Lock Info block has the who, the when and the what. Ninety seconds of reading it is the difference between an inconvenience and a state corruption postmortem.
Reference: terraform force-unlock documentation and the S3 backend documentation.
Frequently asked questions
- Is it safe to run terraform force-unlock?
- It is safe only after you have confirmed no other Terraform process is writing that state. Read the Who and Created fields in the Lock Info block, then find the matching run in your CI history and verify it is cancelled, failed or finished. If a run is genuinely still executing, force-unlocking lets a second process write state concurrently, which can corrupt it.
- Why does my Terraform state stay locked after I cancel a CI job?
- Terraform releases the lock during a clean shutdown and on a graceful interrupt, but a cancelled or timed-out CI job usually ends with SIGKILL, which the process cannot trap. The lock record is left behind in DynamoDB, as a .tflock object, or as a blob lease, depending on your backend, and nothing removes it automatically.
- Should I just use terraform apply -lock=false to get past a state lock error?
- No. That flag does not clear the stale lock, it disables locking for the run entirely, so if another process is mid-apply you will both write state and one set of changes will be lost. Terraform's own error text calls it not recommended. Use force-unlock with the specific lock ID after verifying nothing else is running.
- Can I delete the lock directly from the DynamoDB table?
- Yes, delete the item whose LockID is bucket-name/path/to/terraform.tfstate, but only that item. The table also holds an item whose LockID ends in -md5, which is the state checksum Terraform uses to detect stale reads from S3. Deleting that one causes Terraform to refuse the next run because the state does not match the digest it expected.