Search nomadLab

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.

Updated

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 four combinations of dynamodb_table and use_lockfile in the Terraform S3 backend. With dynamodb_table set and use_lockfile false, you get DynamoDB locking only, the deprecated path. With both set, Terraform takes both locks and both must succeed. With neither set, there is no locking at all and Terraform says nothing about it. With only use_lockfile set, you get the S3 lockfile, which is the current recommended setup. What each combination actually locks use_lockfile = false use_lockfile = true dynamodb_table set DynamoDB only The deprecated path Both locks taken Both must succeed dynamodb_table absent No lock at all Nothing warns you S3 lockfile only Where you want to land Dropping dynamodb_table without adding use_lockfile is a valid config that takes no lock. Terraform warns about dynamodb_table, and says nothing at all about the empty case.
The bottom left cell is the one that bites, because it is what you get by deleting a line and stopping there.

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.

Keep reading