Terraform: Error Acquiring the State Lock, and the Config That Silently Takes No Lock
How to read the lock block before you break it, when to clear a DynamoDB record by hand, and the migration to S3-native locking that has one combination which locks nothing and warns about nothing.
A CI job dies partway through an apply, and every run after it stops at the same wall of text.
Error: Error acquiring the state lock
Error message: ConditionalCheckFailedException: The conditional
request failed
Lock Info:
ID: a3f1c8e2-9b44-4d7e-8c11-2f6a9e0d5b3c
Path: my-tfstate-bucket/prod/terraform.tfstate
Operation: OperationTypeApply
Who: runner@ip-10-0-3-44
Created: 2026-06-10 01:58:12.4471 +0000 UTC
Nothing is broken. Terraform is refusing to let two processes write the same state file, which is its job. The process holding the lock died without cleaning up, so the lock sits there blocking everyone.
There is a safe way out and an unsafe one, and the unsafe one can leave you with a half-merged state file. Behavior below was read from the S3 backend documentation and the backend source in hashicorp/terraform on 22 August 2026.
Read the lock block before you touch it
That block is not boilerplate. Four fields decide whether this lock is safe to break.
ID is what you feed to force-unlock, copied exactly. Who is the user and host that took it, so a runner@ip- from a job your dashboard shows as dead is a stale lock, and your coworker’s laptop while they are online is not. Operation matters because OperationTypeApply means resources may have changed without state being written back, while OperationTypePlan is harmless to break.
Created is the field people skip and the one that decides it. Compare it to now. Four hours old against a ninety-second apply means the process is gone. Forty seconds old means slow down, because something may genuinely be running and breaking a live lock is how state gets corrupted.
The whole question is whether a real process is still holding this lock. Not whether the lock is in your way. Everything below assumes the answer is no.
The safe path
terraform force-unlock a3f1c8e2-9b44-4d7e-8c11-2f6a9e0d5b3c
That releases the lock through whichever backend holds it, and it is the right answer for the large majority of stuck locks. Two things trip people up. It has to run against the same backend config and workspace as the locked state, so run it from the same directory with terraform init already done, and check terraform workspace show first, because unlocking the wrong workspace does nothing and looks like the command failed.
Then run a plan straight away. You want to know now whether the dead apply created resources it never recorded, not on the next deploy.
When force-unlock will not do it: the DynamoDB table
If force-unlock errors out, usually on permissions, and you are on the classic S3 plus DynamoDB setup, you can delete the lock record yourself. Before you do, know that the table holds two kinds of item keyed by LockID.
One is the lock, keyed on the bare state path: my-tfstate-bucket/prod/terraform.tfstate. The other ends in -md5 and is not a lock. It is the checksum Terraform uses to notice that state changed underneath it, and the suffix is hardcoded in the backend as stateIDSuffix = "-md5" with the comment “store the last saved serial in dynamo with this suffix for consistency checks.” Delete that one and your next run complains that the state does not have the expected content.
Look before you delete:
aws dynamodb scan --table-name terraform-locks
Then remove only the item whose LockID is the path with no suffix. That path is printed in the error block as Path:, so copy it from there rather than reconstructing it.
aws dynamodb delete-item \
--table-name terraform-locks \
--key '{"LockID": {"S": "my-tfstate-bucket/prod/terraform.tfstate"}}'
This is the manual override, and manual overrides are where the mistakes live. If force-unlock works, use it.
DynamoDB is deprecated, and one migration state locks nothing
For years, setting up remote state meant an S3 bucket plus a DynamoDB table, because S3 could not do the conditional writes Terraform needed. S3 can now. Terraform 1.10 added use_lockfile as an experiment, 1.11 made it stable, and the S3 backend docs now say plainly that “DynamoDB-based locking is deprecated and will be removed in a future minor version.” As of Terraform 1.15.9, dynamodb_table still works and emits a deprecation warning pointing you at use_lockfile.
The lock becomes an object next to your state at prod/terraform.tfstate.tflock, written and deleted around each operation.
terraform {
backend "s3" {
bucket = "my-tfstate-bucket"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
Here is the part to be careful with. use_lockfile defaults to false, and dynamodb_table is simply absent by default, so the two settings produce four states and one of them is a trap.
The bottom left cell is not a hypothetical. In the backend’s Lock method, the first branch reads if !c.useLockFile && c.ddbTable == "" and returns with no lock and no error. That is by design, since the S3 backend has always allowed unlocked state. It also means that removing dynamodb_table in one commit and adding use_lockfile in another leaves a window where your applies quietly run with no locking, and the only sign is that the deprecation warning you were trying to silence went away.
Migrating without that window
Set both, in the same change.
terraform {
backend "s3" {
bucket = "my-tfstate-bucket"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
use_lockfile = true
}
}
The source calls this “double locking” and notes the design decision that both must succeed. Terraform takes the file lock first, then the DynamoDB lock, and if the DynamoDB side fails it releases the file lock again rather than leaving a half-held lock behind. So a teammate still running the old config cannot apply concurrently with you while the rollout is in flight.
Roll that everywhere, confirm every pipeline and every engineer is on it, then drop the dynamodb_table line in a separate change and delete the table after that. Not the same day.
The permissions change too. DynamoDB locking wanted dynamodb:GetItem, PutItem, and DeleteItem. S3-native locking wants s3:GetObject, s3:PutObject, and s3:DeleteObject on the lockfile itself, which the docs spell out as arn:aws:s3:::mybucket/path/to/my/key.tflock. If your bucket policy names the state key exactly rather than a prefix, it does not cover the .tflock object, and the failure looks like a permissions error rather than a locking one. Non-default workspaces need the same grant under <workspace_key_prefix>/*/.
Clearing a stuck S3-native lock is the same sequence. Try force-unlock with the ID from the error. If that will not move it, the lock is an object, so aws s3 rm s3://my-tfstate-bucket/prod/terraform.tfstate.tflock finishes it. Harder to hurt yourself here than in DynamoDB, since there is no digest item sitting next to it to delete by mistake.
Never break a live lock
Two applies writing the same state at once give you a lost update if you are lucky and a half-merged state file if you are not, which is an afternoon and a backup to recover from. That is the whole reason the lock exists.
So check Who and Created first. If it is a CI runner, look at whether that job is still running or died. If it is a person, message them. Thirty seconds of checking beats an hour of state surgery, and a genuinely live operation will release the lock on its own if you wait.
Cutting down how often this happens
Give Terraform permission to wait rather than fail on the first attempt:
terraform apply -lock-timeout=120s
That covers the common case where one pipeline stage is a few seconds from releasing as the next one starts.
The bigger lever is the runner. Most stale locks come from a process killed mid-apply: an OOM kill, a spot reclamation, a job timeout that fires during the apply step. Give apply jobs memory headroom, keep the apply stage off spot instances, and set the CI timeout longer than your slowest realistic apply so the runner is not shot halfway through.
And if several pipelines contend for one enormous state file all day, the locking is not the problem you have. Smaller state files contend less, apply faster, and put less at risk when something does go wrong.
Next time the error appears, read the lock block, decide whether it is stale, and use force-unlock with the exact ID. That handles nearly all of it without touching DynamoDB. Then go look at whether your backend block still names a lock table, and which of the four cells above you are actually in.