Quick answer: Terraform state locking is a mutual-exclusion mechanism that lets only one plan, apply or state-modifying command run against a given state file at a time. Before touching state, Terraform acquires a lock in the backend (a DynamoDB item, an S3 lock object, an Azure blob lease, a GCS lock file or an HCP Terraform run); anyone else who tries gets an “Error acquiring the state lock” message until the first run finishes. It matters because two concurrent writes to state silently lose changes and orphan real cloud resources.
Locking is the least glamorous Terraform feature and one of the most important. This guide explains the race condition it prevents, how locking works in each popular backend, how to configure it correctly on S3 (including the new native lock file), what to do when you hit a stale lock, when force-unlock is safe, and the locking mistakes that teams make in CI pipelines.
The problem locking solves
Imagine two engineers, Asha and Rohit, both running terraform apply on the same project within the same minute, with no locking:
- Both read the current state,
serial = 41. - Asha’s apply creates a security group and writes state
serial = 42containing it. - Rohit’s apply, still working from the old in-memory state, creates an IAM role and writes
serial = 42as well, overwriting Asha’s file.
Asha’s security group now exists in AWS but appears in nobody’s state. The next plan wants to create it again and fails with “already exists”. Multiply this by a CI system that triggers an apply on every merge and you have a daily source of mystery drift, with resources whose dependencies and ordering (see Understanding Resource Dependencies and Ordering in Terraform) are no longer tracked anywhere. Locking makes step 3 impossible: Rohit’s command waits (or fails fast) until Asha’s lock is released.
Locking only exists for remote backends, which is one more reason the local backend is unsuitable for teams, as covered in Understanding Terraform State: What It Is and Why It Matters.
How a lock works under the hood
When you run any command that may write state, Terraform:
- Writes a small lock record to the backend containing a lock ID (a UUID), the operation (
OperationTypeApply), who is running it, the machine name, a timestamp and the Terraform version. - Performs the plan or apply.
- Deletes the lock record.
If step 1 fails because a record already exists, Terraform prints the existing lock’s metadata and exits. That metadata is what you read to decide whether a lock is genuinely held or left over from a crashed run. Read-only commands such as terraform state list and terraform output do not take a lock.
Locking support by backend
| Backend | Lock mechanism | Extra setup needed? |
|---|---|---|
| s3 (Terraform 1.10+) | .tflock object written with S3 conditional writes |
No; set use_lockfile = true |
| s3 (classic) | Item in a DynamoDB table keyed by LockID |
Yes; create the table and set dynamodb_table |
| azurerm | Blob lease on the state blob | No |
| gcs | .tflock object in the bucket |
No |
| remote / cloud (HCP Terraform) | Workspace lock; runs are queued | No |
| consul | Consul session lock; lock = true by default |
No |
| pg | PostgreSQL advisory lock | No |
| local | OS file lock on the same machine only | Not usable across machines |
Older articles describe Consul and DynamoDB as the two locking options. Today every mainstream backend locks, and the S3 backend no longer needs DynamoDB at all.
Configuring locking on S3: native lock file vs. DynamoDB
The modern approach, available since Terraform 1.10, needs one extra line:
terraform {
required_version = ">= 1.10.0"
backend "s3" {
bucket = "tkh-terraform-state-123456789012"
key = "payments/prod/terraform.tfstate"
region = "ap-south-1"
encrypt = true
use_lockfile = true
}
}
Terraform writes payments/prod/terraform.tfstate.tflock next to the state using an S3 conditional PUT, which fails atomically if the object already exists. The IAM role running Terraform needs s3:PutObject, s3:GetObject and s3:DeleteObject on that path.
If your team is on an older Terraform or has an existing DynamoDB table, the classic configuration still works and can even be combined with the lock file during a transition:
terraform {
backend "s3" {
bucket = "tkh-terraform-state-123456789012"
key = "payments/prod/terraform.tfstate"
region = "ap-south-1"
encrypt = true
dynamodb_table = "terraform-state-locks" # table with partition key "LockID" (String)
use_lockfile = true # optional: lock in both during migration
}
}
Create the DynamoDB table with on-demand billing and a single string partition key named exactly LockID. Its cost is a few rupees a month. For the complete backend setup, including encryption and versioning, see Storing Terraform State Remotely: Benefits and Best Practices.
Handling a stuck lock
Locks are released automatically on success, failure or Ctrl+C. They are not released if the process is killed with SIGKILL, the machine loses power, or a CI runner is terminated mid-apply. The next run then fails:
$ terraform plan
Error: Error acquiring the state lock
Error message: operation error S3: PutObject, ... PreconditionFailed
Lock Info:
ID: 7d1c4a1e-2b7f-4f3c-9a2e-1f0b6c9d8e55
Path: tkh-terraform-state-123456789012/payments/prod/terraform.tfstate
Operation: OperationTypeApply
Who: ci-runner@gha-runner-42
Version: 1.10.3
Created: 2025-11-04 09:12:31 UTC
Before doing anything, check the Who and Created fields. If a colleague or a pipeline is genuinely applying right now, wait. If the run died twenty minutes ago and nothing is still executing, release the lock with the ID shown:
terraform force-unlock 7d1c4a1e-2b7f-4f3c-9a2e-1f0b6c9d8e55
Terraform asks for confirmation. The lock ID requirement is a safety feature: you cannot blindly unlock without first reading who holds it. Never automate force-unlock in a pipeline; a human should always confirm that the previous run is truly dead.
For transient contention, for example several pipelines queuing on one state, use -lock-timeout so Terraform retries instead of failing instantly:
terraform apply -lock-timeout=10m -auto-approve tfplan
Six locking mistakes to avoid
- Running with
-lock=falseto “fix” a stuck lock. You have just recreated the race condition the lock prevents. Diagnose andforce-unlockinstead. - Automating
force-unlockin CI. If a pipeline unlocks whatever it finds, a slow but healthy apply gets trampled. Keep it manual. - Forgetting
dynamodb_tableoruse_lockfileon S3. Unlike Azure and GCS, S3 does not lock unless you ask. Audit every S3 backend block. - Granting lock permissions to the bucket but not the lock table. The run fails with a confusing
AccessDenied. Give the roledynamodb:GetItem,PutItemandDeleteItemon the table. - Killing CI jobs with SIGKILL. Configure runners to send SIGINT and allow a grace period so Terraform can release the lock.
- Assuming locking prevents drift. A lock serialises Terraform runs; it does nothing about someone editing resources in the console. Run regular plans for that.
Frequently asked questions
Does terraform plan take a lock?
Yes, because refresh may update state. Use -lock=false only for read-only speculative plans in pull-request checks where no state write can happen, and never for apply.
Does locking slow Terraform down?
Negligibly. Acquiring and releasing a lock is two small API calls. The waiting you experience is other runs genuinely holding the lock.
Can I lock individual resources instead of the whole state?
No. Locks are per state file. If contention is a problem, split large root modules into smaller ones with their own state, which also speeds up plans.
Should I still create a DynamoDB table for new projects?
Not if everyone on the team runs Terraform 1.10 or later. Use use_lockfile = true and skip the table. Keep DynamoDB only while older Terraform versions are still in use.
Key takeaways
- State locking prevents two Terraform runs from writing state at once, which would otherwise lose changes and orphan resources.
- Azure, GCS, HCP Terraform, Consul and PostgreSQL backends lock automatically; S3 needs
use_lockfile = trueor a DynamoDB table. - Read the lock metadata before acting; use
force-unlock <ID>only when the holding run is confirmed dead, and never disable locking to work around it. - Use
-lock-timeoutfor queued pipelines and let CI runners shut Terraform down gracefully.
Want to see locking, remote state and CI pipelines working together on real AWS and Azure accounts? Our DevOps course covers production Terraform workflows with mentor support and placement assistance. For bite-sized video explanations, subscribe to our YouTube channel.



