Terraform: Error acquiring the state lock
Hi, it's BlueByte. You run terraform apply, and instead of a plan you get Error: Error acquiring the state lock. Nothing is broken — Terraform locks the state before any operation that could write it, and right now it thinks someone else holds that lock. The symptom is the command stopping immediately with a Lock Info: block and an Error message: line. We'll walk through what the lock is, how to read that block to tell a real conflict from a stale one, clear it safely per backend, and keep it from sticking.
What "Error acquiring the state lock" is telling you
Terraform prints the failure with the details of the lock it couldn't take:
╷
│ Error: Error acquiring the state lock
│
│ Error message: operation error S3: PutObject, ... api error
│ PreconditionFailed
│ Lock Info:
│ ID: 4d1e3f0e-2b7a-9c31-8f0a-3b2c1d4e5f6a
│ Path: my-tf-states/prod/terraform.tfstate
│ Operation: OperationTypeApply
│ Who: jenkins@ci-runner-7
│ Version: 1.9.5
│ Created: 2026-09-05 09:14:22.301 +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 changes by backend. With the newer S3 native lock you'll see an S3 PreconditionFailed conditional-write error on the .tflock object; with the older DynamoDB lock it's ConditionalCheckFailedException. Either way the Lock Info: block is identical, and it's the part you read first.
Why the lock got stuck
A held lock means one of a few things:
- Another run is genuinely in progress — a teammate or a CI job is applying right now. The lock is doing its job.
- A previous run was interrupted — someone hit Ctrl-C at the wrong moment, or the network dropped mid-apply, so Terraform never released the lock.
- A CI job was killed — the runner was cancelled or timed out, leaving the lock behind with the runner's identity in
Who. - A crash — Terraform or the backend died before the unlock step.
Only the first is a real conflict. The rest are stale locks: nothing is running, but the lock record is still there.
Read the Lock Info block before you touch anything
Don't force-unlock reflexively — read the block first. Who and Created tell you almost everything. If Who is your own CI runner and Created is an hour ago while no job is running, it's stale. If Who is a colleague and Created is a minute ago, someone is mid-apply — wait, don't unlock. Confirm no job is active in your CI dashboard and no one is running Terraform locally. Note the ID value; you'll need it to unlock.
When to just wait, and how -lock-timeout avoids the race
If the lock is real, the simplest fix is to wait. You can make Terraform wait for you instead of failing instantly:
terraform apply -lock-timeout=120sWith -lock-timeout, Terraform retries acquiring the lock for the given duration before giving up, so a run that overlaps another by a few seconds succeeds instead of erroring. The default is 0s — fail immediately. Do not reach for -lock=false to get past this; that disables locking entirely and lets two applies corrupt the state.
Clearing a truly stale lock with terraform force-unlock
Once you've confirmed nothing is running, remove the stale lock by its ID:
terraform force-unlock 4d1e3f0e-2b7a-9c31-8f0a-3b2c1d4e5f6aTerraform asks for confirmation; type yes. In a pipeline, skip the prompt with -force:
terraform force-unlock -force 4d1e3f0e-2b7a-9c31-8f0a-3b2c1d4e5f6aforce-unlock only removes the lock record — it never touches your state contents or your infrastructure. Behaviour depends on the backend: it can't unlock a purely local state file held by another process, but for remote backends (S3, DynamoDB, HCP Terraform) it clears the lock so the next run acquires it cleanly.
A real case: a CI job killed mid-apply
Your pipeline runs terraform apply against an S3 backend with a DynamoDB lock table. Someone cancels the job while it's applying. The next run fails with Error acquiring the state lock, Error message: ConditionalCheckFailedException, Who: gitlab-runner@runner-3, and Created: 2026-09-05 09:14. You check GitLab — no job is running, so it's stale. You copy the ID from the block, run terraform force-unlock -force <ID> from a maintenance shell, re-run the pipeline, and the apply proceeds normally. The lock table's item is gone; the state was never damaged, because the killed job never finished writing.
Confirm the lock is gone and the run proceeds
Re-run the operation and watch it get past the lock:
terraform planAcquiring state lock. This may take a few moments...
...
No changes. Your infrastructure matches the configuration.Reaching the plan — or the Acquiring state lock line followed by normal output — means the lock was acquired and released cleanly. On the S3 native lock, the .tflock object is gone from the bucket after the run.
Keep locks from getting stuck
Let interrupted runs finish rather than killing them — Terraform releases the lock on a clean exit, even after an error. In CI, use a concurrency group so two Terraform jobs never run against the same state, and give jobs a generous timeout so they aren't killed mid-apply. Set a modest -lock-timeout on shared states so brief overlaps wait instead of failing. Reserve force-unlock for confirmed-stale locks only.
How this differs from a state version mismatch
A lock error is about who holds the state right now. A different error — state snapshot was created by Terraform vX, which is newer than current vY — is about the format of the state, and no unlock will fix it; you upgrade Terraform to match. If the message names a version rather than a Lock Info: block, it's a version mismatch, not a lock.
Related questions
Can I just use -lock=false to get past this?
Don't. That disables state locking entirely, so two concurrent runs can write the state at the same time and corrupt it. Wait for the real run, or force-unlock a confirmed-stale lock instead.
force-unlock says it can't unlock the state.
A purely local state file held by another process can't be unlocked by a second Terraform. Close the other process. force-unlock is meant for remote backends (S3, DynamoDB, HCP Terraform), where it clears the lock record.
I ran force-unlock but the next run still fails to acquire the lock.
Either a run really is in progress (check the new Lock Info — the ID and Created will be newer), or you unlocked a different workspace or backend than the one you're applying. Match the workspace and re-read the block.
Where does the LOCK_ID come from?
It's the ID field printed in the Lock Info block of the error. Copy it verbatim into terraform force-unlock; it acts as a nonce so you can only unlock the exact lock you saw.
Is my state corrupted if a run was killed mid-apply?
Usually not. Terraform writes state as a whole object, so a job killed before the write leaves the previous state intact — it just leaves the lock behind. force-unlock removes only the lock, never the state.
References
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
More in this category
Git: fatal: detected dubious ownership in repository
Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.
Kubernetes: Internal error occurred: failed calling webhook
An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.
Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)
The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.
Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang
The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.