Error Acquiring the State Lock: When force-unlock Is Safe
The lock is not your problem. The lost update it prevents is. Two applies at once do not conflict, they silently orphan a resource that bills forever.
Your pipeline just failed with this:
Error: Error acquiring the state lock
Lock Info:
ID: 4f2ac1e8-9b71-44de-a913-8c2f5b1d0e77
Path: org-tfstate-prod/platform/network/terraform.tfstate
Operation: OperationTypeApply
Who: runner@arc-runner-9f2b1
Version: 1.13.2
Created: 2026-09-06 11:04:22.117 +0000 UTC
Most answers to this get to terraform force-unlock within one sentence. That
is the right command and usually the wrong instinct, because it treats the lock
as the problem. The lock is the thing protecting you. What you need to know
before you remove it is what it is protecting you from, because that damage is
silent, permanent, and shows up on a bill eleven months later.
What the lock actually prevents#
Two Terraform applies running at once do not produce a merge conflict. There is no merge. State is read, modified, and written whole, so concurrency produces a lost update:
t0 A reads state, serial 1847
t1 B reads state, serial 1847
t2 A creates a NAT gateway, writes state, serial 1848
t3 B creates a subnet, writes state, serial 1848
B wrote over A's version. B's state has the subnet
and has never heard of the NAT gateway.
Now look at what exists in the world versus what exists in the file:
the NAT gateway exists
it is in no state file
nothing will ever plan it, change it, or destroy it
it bills forever
That is an orphaned resource, and it is the characteristic damage of running without a lock. Note how quiet it is. Nothing errored. Both applies reported success. Both engineers went to lunch. The only artifact is a line item in a region your team does not use, which somebody queries next year.
This is why -lock=false is documented as dangerous and why both plan and
apply take the lock by default. The plan surprises people, since a read only
operation seems like it should not need one, but a plan still reads state and
you still do not want it reading a file that is being rewritten underneath it.
Read the Lock Info before you touch anything#
Every field in that error is there for a reason, and two of them answer the only question that matters:
Who: runner@arc-runner-9f2b1
Created: 2026-09-06 11:04:22.117 +0000 UTC
Who tells you where to go look. Created tells you how long it has been
there. Together they answer whether this is a live operation or a corpse, and
that distinction is the entire decision.
Go and look. If Who is a CI runner, open that pipeline run. If it names a
person, ask them. This takes ninety seconds and it is the difference between the
two sections below.
force-unlock does not stop anything#
This is the misconception that turns a stuck pipeline into an orphaned resource, and it is worth stating flatly.
terraform force-unlock deletes the lock. That is all it does. It does not
signal the holding process, it does not cancel the apply, it does not reach
across to that CI runner and stop anything. If the operation named in Who is
genuinely still running, after you force-unlock it is still running, and it
will finish and write state whenever it finishes.
What you have done is remove the guard that was stopping a second writer. The first writer is still there. If you now run your own apply, the sequence at the top of this post is exactly what happens, and you built it by hand.
So the question is never "is this lock annoying enough to remove". It is "is the process that took this lock definitely finished". Those are different questions and only one of them is about the lock.
Finding out who holds it#
Who gives you a hint, not a fact, and on a fleet of ephemeral runners the
hostname may mean nothing by the time you read it. You can inspect the lock
directly.
With use_lockfile, the lock is an object at your state key plus a .tflock
suffix, and you can read both its metadata and its contents:
# When was it created, and does it still exist?
aws s3api head-object \
--bucket org-tfstate-prod \
--key platform/network/terraform.tfstate.tflock
# The same Lock Info the error printed, from the source.
aws s3 cp s3://org-tfstate-prod/platform/network/terraform.tfstate.tflock -
On a legacy DynamoDB backend the equivalent is a get-item against the lock
table for the item whose LockID corresponds to your state path.
Either way the useful move is the same one: take the identity and timestamp, go to the system that would have run that operation, and get a yes or no on whether it is still going. CI pipeline run, Kubernetes job, the person's terminal.
When force-unlock is safe#
Safe: you have positively confirmed the operation is not running. You found
the pipeline job and it shows cancelled. The runner was evicted and no longer
exists. The engineer named in Who confirms their laptop slept mid apply. In
each case you have evidence about the process, not about the clock.
Stale locks are real and common. A lock outlives the process that took it whenever that process dies without releasing: a cancelled CI job, an evicted runner, a laptop that went to sleep, a network partition mid apply. Clearing those is exactly what the command is for.
terraform force-unlock 4f2ac1e8-9b71-44de-a913-8c2f5b1d0e77
Unsafe: you are inferring from elapsed time. "It has been forty minutes" is not evidence. An apply that has been running for forty minutes may be creating an RDS instance, which legitimately takes that long. Force unlocking it and applying gives you two operations writing the same state, which is precisely the lost update above, except now you caused it deliberately and on purpose.
The tell is the motive. People force-unlock because they are impatient far more often than because they know. If your reason is that it has been a while, that is not a reason, that is a feeling.
Wait instead of failing#
Most of the time the lock is held by a legitimate concurrent operation and the correct behaviour is to queue, not to error out and make a human decide. That is one flag:
terraform apply -lock-timeout=10m
Terraform retries acquisition until the timeout rather than failing immediately.
In CI this converts a class of red builds into builds that take slightly longer,
and it removes most of the situations where somebody is tempted to reach for
force-unlock in the first place.
If you take one change away from this post, it is adding -lock-timeout to your
pipeline, not learning the unlock command.
The S3 locking mechanism changed and most setups have not#
If your backend is S3, there is a good chance you are running a DynamoDB table you no longer need.
The historical answer was a DynamoDB table with a LockID partition key, and a
great deal of existing material still presents it as a requirement. HashiCorp's
S3 backend documentation now states that DynamoDB-based locking is deprecated
and will be removed in a future minor version, and dynamodb_table is marked
deprecated.
The replacement is a lock object written next to the state file using a conditional write, enabled with one argument:
terraform {
backend "s3" {
bucket = "org-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
}
}
use_lockfile is an opt-in boolean and defaults to false, so upgrading
Terraform does not turn it on for you. The lock becomes an object at your state
key with a .tflock suffix.
Two practical notes for the migration. The arguments can be configured
simultaneously, which is what lets you move across without a flag day while
older Terraform versions in the estate still expect DynamoDB. And the IAM
permissions change shape: lockfile locking needs s3:GetObject, s3:PutObject
and s3:DeleteObject on <your-key>.tflock, alongside s3:ListBucket on the
bucket and s3:GetObject and s3:PutObject on the state object itself.
s3:DeleteObject is not required on the state file, and policies that grant
blanket delete on the bucket are worth narrowing while you are in there.
Three things the lock does not protect#
Worth knowing precisely, because people trust it for cases it does not cover.
Two states managing one resource. Import the same object into two configurations and both will manage it, each holding its own lock, neither aware of the other. Every apply reverts the other's changes. Locking cannot help here, because the states are different files and the lock is per state file.
Changes made in the console. A person editing the resource directly takes no lock at all. That is drift, and it is a different problem with a different fix.
A plan you approved and an apply that re-plans. This one bites pipelines:
terraform plan you read it, you approve it
...someone else applies...
terraform apply re-plans from CURRENT state
and applies THAT
The lock was held correctly for each individual operation and you still applied something you never reviewed. The fix is a saved plan file, not a better lock.
If you think it already happened#
Suppose you force-unlocked last quarter and you are now wondering. The
uncomfortable property of an orphaned resource is that Terraform cannot find
it for you. terraform plan compares your configuration against your state.
A resource that is in neither is not a diff, it is invisible. Running plan and
seeing "no changes" tells you nothing at all about orphans.
Detection has to come from the cloud side, by comparing what exists against what any state file claims:
- Inventory the account independently. AWS Config, Resource Explorer, or a tagged inventory export give you the real list.
- Diff that against the resources across your state files.
terraform state listper state, or parsing the state JSON, gives you the claimed list. - Anything in the first set and not the second is unmanaged. Some of it is legitimate, console-created or managed elsewhere, which is exactly why a tagging convention that records the owning state is worth having before you need it.
Then decide per resource: terraform import it into the right state, or delete
it. The cost of not having that inventory is that you cannot tell the difference
between an orphan and something a different team owns, and the safe default when
you cannot tell is to leave it running, which is how the bill keeps growing.
Quick reference#
| Situation | Action |
|---|---|
| Lock error in CI | Add -lock-timeout=10m and let it queue |
| Lock present, unsure if live | Read Who and Created, go look at that runner or person |
| Confirmed dead process | terraform force-unlock <ID> |
| Process might still be live | Do not unlock. Unlocking does not stop it. |
| "It has been a while" | Not a reason. Go and confirm. |
| S3 backend with a DynamoDB table | Set use_lockfile = true, migrate, drop the table |
| Two configs managing one resource | Locking will not save you, fix the ownership |
The thing underneath#
A lock is a coordination primitive, and the useful question about any coordination primitive is not how to remove it but what invariant it holds. Here the invariant is that exactly one operation reads-modifies-writes the state at a time, and the failure mode when it breaks is a lost update, which is one of the oldest results in concurrency and not a Terraform quirk at all.
Terraform just makes the consequence unusually durable. A lost update in a database costs you a row. A lost update here leaves infrastructure running in a cloud account with nothing in the world that remembers it exists.
Related reading: A Timeout Tells You Nothing for the same read-modify-write hazard in an API, and How GitHub Actions OIDC to AWS Actually Works for the identity your pipeline uses to take this lock. State as a database you operate, corruption and recovery, blast radius and splitting state without an outage, and the refactoring operations that destroy things are Terraform in Production. The locking lesson this post draws on is a free preview.