How to Fix Terraform 'Error acquiring the state lock' (2026)

Oct 20, 2026 · 13 min · Ajay Kumar

This guide is for anyone staring at Error acquiring the state lock in a terminal or a failed CI job, and for whoever has to clean up the state afterwards. By the end you will know how to tell a real lock from a stale one, unlock safely, clear leftover locks on S3, DynamoDB, GCS and Azure, and repair state with moved and removed blocks, refresh-only plans and S3 version restores. Commands match Terraform 1.16.6 and OpenTofu 1.13.1 (current as of October 2026; swap terraform for tofu), and the outputs shown are from Terraform 1.12.0 runs in a scratch directory. You need the CLI, access to the state backend, and the AWS CLI for the S3 steps. If you want the background on backends and why S3 locking changed, read the companion post on Terraform state in 2026 first.

What does "Error acquiring the state lock" mean?

Terraform takes a lock on the state before any operation that might write it, so two runs cannot both read serial N and write N+1. When the lock is already held, you get this. I reproduced it by running plan while a slow apply was in progress:

Error: Error acquiring the state lock

Error message: resource temporarily unavailable
Lock Info:
  ID:        6eaf5994-888e-990d-52a6-54e2939a4b8a
  Path:      terraform.tfstate
  Operation: OperationTypeApply
  Who:       ajaykumar@Ajays-MacBook-Pro.local
  Version:   1.12.0
  Created:   2026-10-20 03:21:15.785646 +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.

The Error message line depends on the backend. On the local backend it is an OS file-lock error. With S3 native locking it is an HTTP 412 PreconditionFailed from PutObject, because the lock is a conditional write that only succeeds if <key>.tflock does not exist yet (the UK Ministry of Justice cloud platform has a public runbook showing exactly this). With DynamoDB it is a ConditionalCheckFailedException. All three mean the same thing: something else wrote a lock first.

The Lock Info block is the part to read. Who is user@host of the process that took the lock, Operation tells you whether it was a plan or an apply, and Created tells you how old it is. A five-minute-old apply lock from a CI runner is almost certainly real. A three-day-old plan lock from a laptop is almost certainly stale.

Stuck Terraform lock: decide before you unlock Lock error Read Lock Info: ID, Who, Created Is a run still active? Check CI, ask the Who Wait for it retry with -lock-timeout=5m Back up state terraform state pull > backup force-unlock LOCK_ID only a lock you know is stale terraform plan expect no surprise changes Unlock failed? delete .tflock or DynamoDB item yes no ok fails Never use -lock=false to get past this. It does not remove the lock; it lets two writers in at once.
The order of checks this guide follows. Every unlock path starts with confirming no run is active and taking a state backup.

Step 1: Check whether the lock is real

Before anything else, find the process in Who. Look at running CI jobs for that workspace, check for an open Atlantis or HCP Terraform run, and ask the person whose laptop is named. If a run is active, the fix is to wait. Make your own commands and pipelines wait automatically instead of failing:

# Retry acquiring the lock for up to 5 minutes before erroring.
terraform plan  -lock-timeout=5m -out=tfplan
terraform apply -lock-timeout=5m tfplan

In my test, terraform plan -lock-timeout=60s simply blocked for 15 seconds until the competing apply finished, then printed No changes. The state mv docs describe the flag as retrying lock acquisition for a period before returning an error. In CI, also set a concurrency group per workspace so two pipelines never queue against the same state in the first place.

If the Who is a CI runner that was killed (cancelled job, evicted pod, OOM), the lock is stale: the process that would have released it no longer exists.

Step 2: Back up the state before touching anything

Unlocking does not modify state, but whatever you do next might. Take a copy now:

# Works for every backend; writes the current remote state to a local file.
terraform state pull > "backup-$(date -u +%Y%m%dT%H%M%SZ).tfstate"

# Confirm what you saved: format version, serial and lineage.
jq '{version, terraform_version, serial, lineage}' backup-*.tfstate
{
  "version": 4,
  "terraform_version": "1.12.0",
  "serial": 1,
  "lineage": "1ea60f4d-d3e9-fc9f-6507-6e0ba4ddbfe2"
}

On S3, also confirm versioning is on, because it is your real rollback:

aws s3api get-bucket-versioning --bucket acme-tfstate-prod
# Expect: { "Status": "Enabled" }

Step 3: Force-unlock safely

With the run confirmed dead and a backup taken, release the lock using the ID from the error:

# Prompts for confirmation; add -force to skip the prompt in scripts.
terraform force-unlock 6eaf5994-888e-990d-52a6-54e2939a4b8a

The force-unlock docs say it does not modify your infrastructure, and the state locking page says it should only be used on your own lock when automatic unlocking failed, because unlocking someone else's live lock "could cause multiple writers". Run it from a directory initialised against the same backend and workspace as the locked state, or it will look for the lock in the wrong place. A wrong or already-released ID is harmless; on the local backend I got Failed to unlock state: LocalState not locked.

On HCP Terraform, the lock belongs to the workspace. The workspace settings page has a Locking section, and a user with admin access to the workspace can force unlock it even if another user locked it. If a run that cannot be discarded holds the lock, HashiCorp's support article covers that case.

Step 4: Clear a stale lock directly when force-unlock fails

Sometimes force-unlock itself fails: the lock was written by a different Terraform version, the backend config changed since, or credentials lack delete permission. Then remove the lock object by hand, after the same "is anything running" check.

S3 native lock (use_lockfile = true). The lock is an object at the state key plus .tflock, as documented in the S3 backend reference. Inspect it, then delete it:

BUCKET=acme-tfstate-prod
KEY=network/terraform.tfstate   # for non-default workspaces: env:/<workspace>/network/terraform.tfstate

aws s3 cp "s3://$BUCKET/$KEY.tflock" - | jq .   # shows ID, Operation, Who, Created
aws s3 rm "s3://$BUCKET/$KEY.tflock"

DynamoDB lock (deprecated, still common). The lock item's LockID is <bucket>/<key>. A separate item with the -md5 suffix holds the state checksum; do not delete that one to clear a lock.

TABLE=terraform-locks
aws dynamodb get-item --table-name "$TABLE" \
  --key '{"LockID":{"S":"acme-tfstate-prod/network/terraform.tfstate"}}'

aws dynamodb delete-item --table-name "$TABLE" \
  --key '{"LockID":{"S":"acme-tfstate-prod/network/terraform.tfstate"}}'

If you use both mechanisms during a migration, Terraform takes both locks, so check both places.

GCS. The lock is <prefix>/<workspace>.tflock, written with a does-not-exist precondition (backend source):

gcloud storage cat gs://acme-tfstate/network/default.tflock
gcloud storage rm  gs://acme-tfstate/network/default.tflock

azurerm. The lock is a lease on the state blob. Break it:

az storage blob lease break \
  --account-name acmetfstate --container-name tfstate \
  --blob-name network.terraform.tfstate

Step 5: Move off DynamoDB locking for good

Terraform 1.11.0 made S3 native locking generally available and deprecated the DynamoDB arguments; the S3 backend docs say DynamoDB locking will be removed in a future minor version. Migrate in two phases so nothing running an old config can bypass the lock:

terraform {
  required_version = ">= 1.11.0"   # OpenTofu: >= 1.10.0

  backend "s3" {
    bucket       = "acme-tfstate-prod"
    key          = "network/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true

    # Phase 1 only. Remove once every pipeline and laptop uses this config.
    dynamodb_table = "terraform-locks"
  }
}
terraform init -reconfigure   # re-initialise with the changed backend settings
terraform plan -lock-timeout=5m

Grant the CI role s3:GetObject, s3:PutObject and s3:DeleteObject on *.tflock keys, which the S3 backend docs list as required for the lock file. In phase 2, delete dynamodb_table, run terraform init -reconfigure again, and retire the table.

Step 6: Rename and remove resources with moved and removed blocks

Lock errors often show up mid-refactor, which is when people reach for terraform state mv and state rm. Those commands change state immediately, with no plan and no review. Prefer the declarative versions, which go through a normal plan. This configuration renames terraform_data.web to frontend and stops managing terraform_data.slow without destroying it:

# moved: Terraform >= 1.1. removed: Terraform >= 1.7. Both in OpenTofu.
resource "terraform_data" "frontend" {
  input = "v1"
}

moved {
  from = terraform_data.web
  to   = terraform_data.frontend
}

removed {
  from = terraform_data.slow
  lifecycle {
    destroy = false   # forget it, do not delete the real object
  }
}

The plan shows exactly what will happen, with zero destroys:

  # terraform_data.web has moved to terraform_data.frontend
    resource "terraform_data" "frontend" {
        id     = "aaad12c5-716a-dbec-0c7d-cee04484f25d"
        # (2 unchanged attributes hidden)
    }

 # terraform_data.slow will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)

Plan: 0 to add, 0 to change, 0 to destroy.

The state rm docs themselves point to removed blocks as the alternative. If you must use the imperative commands, preview first: terraform state mv -dry-run terraform_data.web terraform_data.x printed Would move "terraform_data.web" to "terraform_data.x" and changed nothing. To adopt something you removed into another state, pair this with an import block; the Terraform import guide walks through that.

Step 7: Find and accept drift with plan -refresh-only

After a messy incident, someone has usually changed real infrastructure by hand. A refresh-only plan shows how the real world differs from state, without proposing changes to infrastructure:

# Show drift only. Exit code 2 means differences were found (with -detailed-exitcode).
terraform plan -refresh-only -detailed-exitcode

# Accept the real-world values into state (does not change infrastructure).
terraform apply -refresh-only

The plan docs define -refresh-only as updating only state and root module outputs, and -detailed-exitcode as 0 for no changes, 1 for error and 2 for changes present, which makes it a cheap nightly drift check in CI. Accepting drift into state is half the decision; the other half is whether the configuration should change to match, or a normal apply should put things back.

Step 8: Recover corrupt or overwritten state from S3 versioning

If state was overwritten by a stale copy, truncated, or deleted, versioning gets it back. List versions, download the last good one, and push it as a new write:

BUCKET=acme-tfstate-prod
KEY=network/terraform.tfstate

aws s3api list-object-versions --bucket "$BUCKET" --prefix "$KEY" \
  --query "Versions[?Key=='$KEY'].[VersionId,LastModified,Size]" --output table

aws s3api get-object --bucket "$BUCKET" --key "$KEY" \
  --version-id "<VERSION_ID>" good.tfstate

# Lineage must match the current state; if it does not, you have the wrong file.
jq -r .lineage good.tfstate
terraform state pull | jq -r .lineage

Terraform will refuse to push an older serial over a newer one. My test returned Failed to write state: cannot import state with serial 1 over newer state with serial 3. That is protection, not an obstacle. Bump the serial past the current one and push without -force:

CUR=$(terraform state pull | jq .serial)
jq --argjson s "$((CUR + 1))" '.serial = $s' good.tfstate > restore.tfstate
terraform state push restore.tfstate   # silent on success
terraform plan                          # should match what you expect

The state push docs say -force disables the lineage and serial checks and is not recommended. Restoring through state push rather than copying the object in S3 also keeps the DynamoDB checksum in step, if you still use one.

Troubleshooting

Symptom Likely cause Fix
Error acquiring the state lock, Created a few minutes ago A real run is in progress Wait; add -lock-timeout=5m and a CI concurrency group
Same error, Who is a dead CI runner or old laptop session Process was killed before unlocking Back up state, then terraform force-unlock LOCK_ID
S3 StatusCode: 412 PreconditionFailed on PutObject <key>.tflock already exists Same as above; if force-unlock fails, aws s3 rm the .tflock object
AccessDenied on the .tflock key after enabling use_lockfile CI role scoped only to the state key Allow s3:GetObject, s3:PutObject, s3:DeleteObject on *.tflock
Failed to unlock state: LocalState not locked Wrong lock ID, wrong directory, or already released Re-run the command to get the current lock ID; check workspace and backend
state data in S3 does not have the expected content DynamoDB -md5 digest disagrees with the state object, often after a manual S3 edit or restore Verify the state is correct, then update or remove the Digest item as the error text says, or drop dynamodb_table
cannot import state with serial N over newer state Pushing an older copy Confirm lineage, bump serial to current+1, push without -force
Plan wants to destroy and recreate after a rename Address changed with no moved block Add a moved block, re-plan
Plan wants to create a resource that already exists It was state rmed or state was lost import block, or restore state from a version
HCP Terraform workspace stuck locked by a run Cancelled or failed run did not release the lock Workspace Settings, Locking, Force unlock (workspace admin)

FAQ

Is it safe to run terraform force-unlock?

It is safe when the process holding the lock is definitely gone; the command does not touch infrastructure. It is dangerous when that process is still running, because two writers can then interleave and lose changes. Confirm with CI and the Who field first, and take a state pull backup.

Should I use -lock=false to get past the error?

No. It does not remove the lock, it just skips locking for your command, so a concurrent run can overwrite your writes or you theirs. HashiCorp's docs say it is not recommended. Use -lock-timeout to wait for a real lock, or force-unlock a stale one.

Can I delete the .tflock file in S3 directly?

Yes, if force-unlock fails and you have confirmed nothing is running. The lock is just an object at the state key plus .tflock, and Terraform's own unlock deletes the same object. Back up state first, and check the DynamoDB table too if you still have dynamodb_table set.

What is the difference between terraform state mv and a moved block?

state mv edits state immediately with no plan or review, and the change lives only in someone's shell history. A moved block is code: it is reviewed, applied through a normal plan, and also migrates anyone else using the module. Use moved (and removed) unless you are moving resources between separate states.

When a lock error lands on me, the first thing I read is Created and Who, and the first thing I run is terraform state pull into a timestamped file, before any unlock. In production I keep state buckets versioned, give only the CI role write access, put -lock-timeout and a per-workspace concurrency group in every pipeline, and make refactors go through moved and removed blocks so no one needs state surgery at 2 AM. Agents get the same rule as people: they can plan, but force-unlock and state push stay with a human, a point I make more broadly in what your AI agent does at 2 AM. Official references: state locking, force-unlock, S3 backend, removed block and OpenTofu S3 backend.

Related: Infrastructure as Code in 2026: Two Forks at 1.16 and 1.13, a $6.4B Owner, 912 Public State Files, and One Agent That Ran terraform destroy, Terraform State in 2026: S3 Locking Replaced DynamoDB, What Else Changed and It's 2 AM. Do You Know What Your AI Agent Is Doing?.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related