Guide · Backend
Terraform State Explained: Locking, Drift, Imports and Recovery
29 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Terraform state is a JSON file, a plain-text data format, that records which real object each resource block in your code manages. Terraform, a tool that builds cloud infrastructure from code files, reads it before deciding any change.
- The state file keeps every attribute, meaning each property of each object, in plain text, passwords included. Marking a value sensitive only hides it on screen. In our run, one generated database password sat in the file three times.
- A backend is where state is stored, such as a local file or a cloud storage bucket. Locking stops two runs writing state at once. Drift means someone changed a real object outside Terraform.
- Import blocks adopt objects that already exist, moved blocks rename without destroying, and removed blocks stop tracking. A removed block without its lifecycle setting, the line saying whether to destroy, destroys in Terraform but only forgets in OpenTofu, an open-source fork (a separately developed copy) of Terraform.
- Beginners can stop after the drift section. Engineers then get the tested recovery drill. It shows why the local .backup copy is one run behind, why uploading an old copy is refused, and how import blocks adopt forgotten objects.
What goes wrong when Terraform loses track of its state?
Terraform forgets which real servers, databases and passwords it created. Its next plan then offers to create everything again, or to replace things it remembers wrongly. Terraform state is the memory that prevents this.
Picture a common setup. A four-person team in Pune runs its online shop's cloud resources from one Terraform folder. Two laptops apply from it, and so does a CI pipeline, which is automation that runs on every merge. The setup is made up, but every command result below came from our own test runs.
Three things go wrong in setups like this. Without a lock, two applies start at the same moment and both write the state file. Someone fixes a server by hand, so the code and the real world quietly disagree. Then one day the state file is deleted or half-written, and the next plan wants to build the shop from scratch.
This guide explains what the state file holds, why it leaks secrets, how backends and locks protect it, and how to recover it. Every example ran on Terraform 1.16.6 or OpenTofu 1.13.2 with local files only, so you can repeat it on a laptop with no cloud account.
Which words do you need before reading about Terraform state?
Six words carry the whole guide. Each one is tied to the shop's setup.
- Resource: one block in your code that describes one real object, such as a server, a storage bucket or a generated password.
- Provider: a plugin that talks to one system's API, the interface programs use to control it, such as a cloud platform or your own disk. The shop's code uses the random and local providers in our tests.
- Plan: Terraform compares your code with what it knows about the real objects and lists the changes it would make.
- Apply: Terraform makes those changes, then writes down the result.
- State: the written-down result, a record of every object Terraform manages and the ID that identifies it in the real system.
- Backend: the place the state is stored and, for most backends, locked while a run writes it.
What is Terraform state and why does Terraform need it?
Terraform state is the record that links each resource in your code to one real object. The docs say Terraform uses it to map real-world resources to your configuration and to keep track of metadata.
Your code says what should exist, not which object already does. If the code says one server, Terraform needs to know whether that server is already running, and under which ID. The state answers that, so a second apply changes the existing server instead of creating a twin.
By default the state lives in a file named terraform.tfstate next to your code. Before planning, Terraform refreshes it: it asks each provider how every recorded object looks now. The docs also ask you never to edit this file by hand.
How one Terraform run uses state
Drag to exploreNotice that the plan compares your code with the refreshed state, not with your memory of the cloud. If the state is missing, the IDs to look up are missing too.
Diagram connections:
- State file to Refresh (read last known IDs)
- Refresh to Real objects (look up each ID)
- Real objects to Plan (current values)
- Your .tf files to Plan (desired values)
- Plan to Apply (you approve)
- Apply to New state written (record new IDs)
- Your .tf files: What you want to exist
- State file: IDs and attributes from the last run
- Refresh: Ask providers how each object looks now
- Real objects: Servers, buckets, files, passwords
- Plan: Diff of wanted against refreshed state
- Apply: Create, change or destroy objects
- New state written: Serial goes up; old copy kept as backup
An everyday picture helps. A cloakroom gives you a token for your coat. The coats on the hooks are the real objects, your coat request is the code, and the token register is the state.
Without the register, every coat is still on its hook, but nobody can tell which one is yours. Lose the register and the attendant either hands out the wrong coat or takes in a second one.
Where the picture breaks: a real register holds a number, but Terraform's state also copies what is inside each coat. That includes anything secret in the pockets, which the next section shows.
Here is the shop's code reduced to three resources you can run anywhere. random_pet makes a server name, random_password makes a database password, and local_file writes both into a config file on disk.
terraform {
required_providers {
random = { source = "hashicorp/random", version = "3.9.1" }
local = { source = "hashicorp/local", version = "2.9.1" }
}
}
resource "random_pet" "web" {
length = 2
}
resource "random_password" "db" {
length = 16
special = false
}
resource "local_file" "app_config" {
filename = "${path.module}/app.env"
content = "SERVER=${random_pet.web.id}\nDB_PASSWORD=${random_password.db.result}\n"
}
output "db_password" {
value = random_password.db.result
sensitive = true
}Run terraform init, then terraform apply. You should see Apply complete! Resources: 3 added, 0 changed, 0 destroyed, and the output line db_password = (sensitive value) during the plan.
What is inside the Terraform state file?
It is a JSON document with a few header fields and one entry per resource. Each entry stores the provider, the address in your code and every attribute the provider returned.
This is a trimmed copy from our run of the code above. We dropped most attributes and replaced the real password with a fake one. Every value shown is as Terraform wrote it.
{
"version": 4,
"terraform_version": "1.16.6",
"serial": 4,
"lineage": "e9068e21-ac6e-a407-6687-0a8dbd6c47af",
"outputs": {
"db_password": { "value": "FAKEpassw0rd1234", "type": "string", "sensitive": true }
},
"resources": [
{
"mode": "managed", "type": "random_password", "name": "db",
"provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
"instances": [
{
"attributes": { "id": "none", "length": 16, "result": "FAKEpassw0rd1234" },
"sensitive_attributes": [[{"type": "get_attr", "value": "bcrypt_hash"}], [{"type": "get_attr", "value": "result"}]]
}
]
}
]
}| Field | What it holds | Why you care |
|---|---|---|
| version | Format of the state file itself, 4 in our run | Terraform 1.16.6 and OpenTofu 1.13.2 both read version 4 in our tests. |
| terraform_version | The Terraform version that last wrote the file, 1.16.6 here | Tells you which version a team last used. |
| serial | A counter that rises when Terraform writes changed state | Lets Terraform refuse to overwrite newer state with older state. |
| lineage | A random ID fixed when the state was first created | Stops you pushing one project's state over another's. |
| outputs | Root module output values, with their sensitive flag | Sensitive outputs are stored here in plain text. |
| resources | Every managed object, its provider and its attributes | This is the mapping, and also where secrets sit. |
Notice that the serial was already 4 after one first apply of three resources. In a separate test, adding three resources raised it by 4, while an apply with no changes left it alone. Treat serial as a count of state writes, not of applies.
You can read the same data without opening the file. terraform state list prints every tracked address, and terraform state show random_password.db prints one entry with secrets masked.
Why does the Terraform state file contain secrets?
Because Terraform stores every attribute it needs to compare next time, and a password is an attribute. The docs say values marked sensitive are stored in both state and plan files.
The sensitive flag only changes what the screen shows. In our run, the plan showed db_password = (sensitive value) and terraform output masked it too. Yet the same password appeared three times in terraform.tfstate. It sat in the output, in random_password.db and inside the local_file content.
Anyone who can read the file can also read the value. terraform output -raw db_password printed the 16-character password in our run, and so did terraform show -json.
Plan files carry the same risk. A saved plan from terraform plan -out holds secrets too, so a CI pipeline that uploads plans as build artefacts is storing secrets there as well.
How do you keep secrets out of state or protect them?
- Keep state out of Git. The docs tell you to exclude it from Git workflows, because it is plain text.
- Use a backend that encrypts at rest and restricts who can read it. The S3 backend (S3 is Amazon's object storage service) has an encrypt option, and the docs say HCP Terraform, HashiCorp's hosted service, encrypts state at rest.
- Prefer values that never reach state. Terraform 1.10 added ephemeral values and resources, which exist only during a run and are never saved. Terraform 1.11 added write-only arguments, which a provider accepts but never stores in state; their names usually end in _wo.
- Watch data sources too. A data source reads existing data, and its result is saved in state. Our local_sensitive_file data source put a fake token straight into the state file.
We tested the ephemeral route with an ephemeral random_password block. The state file never mentioned it. When we passed it into a normal resource argument, Terraform 1.16.6 stopped with Error: Invalid use of ephemeral value.
Ephemeral values can only flow into places that are not saved, such as a provider's write-only arguments. Which resources offer write-only arguments depends on each provider, so check its docs.
How does OpenTofu encrypt state?
OpenTofu can encrypt the whole state file with a key you control. Its docs call AES-GCM, a standard authenticated cipher, the only currently supported encryption method. They also list an external-program method and an unencrypted mode used for migration. Keys can come from a passphrase, a cloud key service, OpenBao (an open-source secrets store) or an external program.
State encryption arrived in OpenTofu 1.7. Here is the setup we ran on OpenTofu 1.13.2, with a passphrase passed in as a variable.
terraform {
required_providers {
random = { source = "hashicorp/random", version = "3.9.1" }
}
encryption {
key_provider "pbkdf2" "team_key" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.team_key
}
state {
method = method.aes_gcm.main
}
}
}
variable "state_passphrase" {
type = string
sensitive = true
}
resource "random_password" "db" {
length = 16
special = false
}After apply, the state file held only encrypted_data, encryption_version, lineage, meta and serial. The word result did not appear in it at all. A wrong passphrase failed with a decryption error, and Terraform 1.16.6 rejected the encryption block as unsupported.
The SevyDevy guide on secrets management covers where those secrets should live instead. Here the lesson is narrower: whoever can read the state can read the secrets inside it.
Where should Terraform state live, and what is a backend?
A backend decides where Terraform stores state. Terraform uses the local backend by default, which is just the terraform.tfstate file in your folder.
Local state breaks as soon as two people share a project. The docs list the problems: each person must have the latest copy, and nobody else may run Terraform at the same time. A remote backend keeps one shared copy for the whole team.
Built-in backends include s3 for Amazon S3, an object storage service, and gcs, azurerm, pg, kubernetes and others. HCP Terraform uses a cloud block instead, and a configuration with a cloud block cannot also have a backend block.
One rule surprises people: a backend block cannot refer to variables, locals or data sources. Leave those arguments out and pass them at init time, which the docs call partial configuration. In our run, terraform init -backend-config="path=state/shop.tfstate" did this for a local backend. Keep credentials out of it: the docs say these values are saved in the .terraform folder and in plan files.
| Setting | What the docs say | What to do |
|---|---|---|
| use_lockfile | Turns on S3 native locking with a .tflock object. Defaults to false. | Set it to true. With neither this nor a DynamoDB table, the S3 backend does not lock. |
| dynamodb_table | The older lock using a table in DynamoDB, AWS's key-value database. Marked deprecated. | Move to use_lockfile; plan to drop the table. |
| encrypt | Enables server-side encryption of the state and lock files. | Turn it on, or set kms_key_id to use your own key from AWS's key service. |
| Bucket versioning | Highly recommended on the bucket, to allow for state recovery. | Enable it before you need it. It is your undo button. |
S3 native locking arrived in Terraform 1.10.0 in November 2024. The S3 backend page now says state locking is an opt-in feature of that backend, so an old setup with no lock settings has no lock.
For the shop, a sensible setup is one S3 bucket with versioning and encryption on and use_lockfile set to true. Read access goes only to the identity the CI pipeline runs as and a few engineers. We could not run S3 from our test machine, so this part rests on the docs alone.
How does Terraform state locking work?
Before any operation that could write state, Terraform takes a lock on it. If another run already holds the lock, Terraform stops instead of writing. The docs say it does not continue when locking fails.
On the local backend, the lock is an operating-system file lock on terraform.tfstate. In the Terraform 1.16.6 source it is an fcntl lock, the standard Unix file-locking call, on non-Windows systems. It is held only while the process runs.
We tested it by starting a slow apply and running a plan four seconds later. The plan failed at once with Error acquiring the state lock, Error message: resource temporarily unavailable. Below that it printed the lock's ID, path, operation, user, Terraform version and creation time.
While the apply ran, a small .terraform.tfstate.lock.info file held those details. It disappeared when the apply finished. Adding -lock-timeout=30s made the second plan wait about 15 seconds instead, then run normally.
Two runs reach for the same state
Drag to exploreNotice that the second run never touches the state while the first holds the lock. Its only choices are to fail now or to wait with a timeout.
Diagram connections:
- Run A: apply to Lock held (acquire)
- Run B: plan to Lock free? (try to acquire)
- Lock free? to Error acquiring lock (No, no timeout)
- Lock free? to Wait and retry (No, with timeout)
- Lock held to Lock released (apply finishes)
- Lock released to Wait and retry (B gets the lock)
- Run A: apply: Starts first and takes the lock
- Lock held: Local: OS file lock. S3: .tflock object
- Run B: plan: Starts 4 seconds later
- Lock free?: Checked once, or retried with -lock-timeout
- Error acquiring lock: Prints lock ID, who and which operation
- Wait and retry: Our -lock-timeout=30s waited about 15 s
- Lock released: Run A wrote state and let go
What should you do when you see Error acquiring the state lock?
Read the lock info
Note the Who, Operation and Created lines. They tell you which person or pipeline holds the lock and since when. If a parse or decryption error appears instead of Lock Info, the state file itself is broken; go to the recovery section.
Check that run is really dead
Ask the person or look at the CI job. A lock held by a live apply is doing its job, so wait or retry with -lock-timeout.
Unlock only your own stale lock
If your run crashed and left the lock, run terraform force-unlock with the lock ID. The docs warn that unlocking someone else's live run can cause two writers.
Never reach for -lock=false
The docs call it dangerous when others might run against the same workspace. It skips the lock instead of fixing it.
force-unlock behaves differently on local state. Our attempt from a second process failed with Failed to unlock state: LocalState not locked. The docs agree that local state files cannot be unlocked by another process.
You rarely need it locally. We killed an apply with kill -9, a force-kill that gives the process no chance to clean up. Its .terraform.tfstate.lock.info file stayed behind, yet the next plan ran at once. The operating system had released the lock with the process, so a leftover info file proves nothing.
That killed apply left a different problem. The resource it was creating never reached state, so the next plan proposed creating it again. With a real cloud object, check whether it already exists before you apply.
Do not confuse the state lock with .terraform.lock.hcl. That file is the dependency lock file: it records the provider versions and checksums terraform init chose, and the docs say to commit it. It never locks state.
What is drift and how does terraform plan -refresh-only catch it?
Drift is any difference between what the state says and what really exists, caused by a change made outside Terraform. A refresh-only plan shows that difference without proposing any change to real objects.
In the shop, picture someone deleting a config file on the server during a late-night fix. We copied that by deleting a file managed by a local_file resource named motd, then ran terraform plan -refresh-only.
It printed Note: Objects have changed outside of Terraform, then local_file.motd has been deleted, with every old attribute. It ended by saying it would not undo these changes, only record them if applied.
Pause and predict: what does a normal terraform plan say about the same deletion? Make a guess before reading on.
It said only local_file.motd will be created, Plan: 1 to add. There was no note that anything had changed outside Terraform. A normal plan quietly folds drift into its proposal.
We then edited the file's content by hand instead. The local provider reports a changed file as gone, so a normal plan again said will be created and would have overwritten the hand edit. How drift shows up depends on each provider.
| Command | What it did in our run | Use it when |
|---|---|---|
| terraform plan -refresh-only | Listed the deletion under changes made outside Terraform. Proposed no actions. | You want to see drift on its own. |
| terraform apply -refresh-only | Wrote the refreshed view into state, so the file was forgotten. | The outside change was right and you accept it. |
| terraform plan | Planned to create the file again, with no drift note. | You want the code to win. |
| terraform plan -refresh=false | Said No changes, because it skipped the check. | Rarely. The docs warn the plan may be wrong. |
| terraform plan -detailed-exitcode | Exited with 2 while drift existed, 0 after we fixed it. | A nightly CI drift check. |
The old terraform refresh command is deprecated. The docs call it an alias for terraform apply -refresh-only -auto-approve and warn it is risky. With misconfigured credentials it can wipe every object from state without asking.
Terraform 1.17.0, released on 21 October 2026, adds a -minimal-refresh option that refreshes only resources with proposed changes. We have not tested it, so check its docs before relying on it.
How do import, moved and removed blocks change Terraform state safely?
They let you change what state tracks through your code, so the change shows up in a normal plan before anything happens. The older terraform import, state mv and state rm commands change state immediately, with no plan to review.
| Goal | Command | Block in code | Block added in |
|---|---|---|---|
| Start tracking an existing object | terraform import | import | Terraform 1.5.0, June 2023 |
| Rename or move a tracked object | terraform state mv | moved | Terraform 1.1 |
| Stop tracking without destroying | terraform state rm | removed, with lifecycle destroy = false | Terraform 1.7.0, January 2024 |
Import blocks adopt objects that already exist
An import block names a real object's ID and the address it should get in your state. Terraform reads the object during the plan and shows will be imported, then saves it on apply.
Suppose the shop's API key suffix was created by hand months ago. random_string lets you import by the value itself, so this sample works without a cloud account. The value here is fake.
import {
to = random_string.api_key_suffix
id = "FAKEsuffix12"
}
resource "random_string" "api_key_suffix" {
length = 12
}With the other three resources already applied, the plan said Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. After apply, a second plan with the import block still present said No changes.
The import only matches when your resource block matches the real object. In one test our block said special = false, but the imported value had the default of true. The plan said Warning: this will destroy the imported resource and planned to replace it.
So read every import plan for must be replaced. To get a starting block, terraform plan -generate-config-out=generated.tf writes one for you; in 1.16.6 it printed Config generation is experimental.
Two limits matter. A provider must support import for that resource type; local_file failed with Resource Import Not Implemented. Terraform 1.16.0 added import blocks inside child modules, and 1.16.6 accepted one in our test. OpenTofu 1.13.2 rejected the same block: Import blocks are only allowed in the root module.
Moved blocks rename without destroying
A moved block tells Terraform that an object recorded at one address now lives at another. Terraform updates the state before planning, so nothing is destroyed.
moved {
from = random_pet.web
to = random_pet.frontend
}We renamed random_pet.web to random_pet.frontend. Without the moved block, the plan was 2 to add and 2 to destroy: the pet, plus the config file that uses its name. With it, the plan said random_pet.web has moved to random_pet.frontend and Plan: 0 to add, 0 to change, 0 to destroy. The pet kept its old ID.
The refactoring docs strongly recommend keeping old moved blocks in shared modules. Someone still on an old version needs them, and removing one is a breaking change.
Removed blocks forget objects on purpose
A removed block takes an object out of state. With lifecycle destroy = false, the real object stays where it is. This suits handing a resource to another team or tool.
removed {
from = local_file.app_config
lifecycle {
destroy = false
}
}After deleting the local_file block and adding this removed block, the plan said local_file.app_config will no longer be managed by Terraform, but will not be destroyed. After apply, app.env was still on disk and gone from state.
Leave out the lifecycle block and the two tools disagree. Terraform 1.16.6 planned to destroy the object. OpenTofu 1.13.2 planned 1 to forget and printed Warning: Missing lifecycle from the removed block. Always write destroy explicitly.
How do you recover a corrupted or deleted Terraform state file?
You restore the newest good copy, then make it match reality with a refresh-only plan and import blocks. Where that copy comes from decides how much work is left.
What failure looks like
We cut a state file down to its first 300 bytes. The next plan failed with Error acquiring the state lock, followed by Unsupported state file format: The state file could not be parsed as JSON. A broken file can surface as a lock error.
We then deleted the state completely. The plan offered to create every resource again: Plan: 2 to add. With real cloud objects, that means duplicates or name clashes.
Why the .backup file is not the latest copy
Local state keeps terraform.tfstate.backup, the state as it was before the latest command that changed it. In our run, one second apply took the live file to serial 6 while the backup stayed at serial 3.
Restoring that backup made Terraform plan the second apply's change again, with must be replaced on the resource it had changed. The backup is one step behind, so the plan after restoring it needs careful reading. Local state mv and state rm also wrote their own timestamped backups, named like terraform.tfstate.1793189595.backup.
Why state push refuses an old copy
terraform state push uploads a state file to the backend, with two safety checks. In our run it refused the backup with cannot import state with serial 3 over newer state with serial 6.
It also refused a file with a different lineage, calling it unrelated state. The -force flag skips both checks, and the docs say that is not recommended. Use it only when you know the newer copy is the broken one.
Recovering state step by step
Drag to exploreNotice that restoring a copy is the middle of the job, not the end. The refresh-only plan and the imports bring that older copy up to date.
Diagram connections:
- Plan fails or state lost to Stop all runs (first)
- Stop all runs to Current state readable? (then check)
- Current state readable? to Pick newest good copy (No)
- Current state readable? to plan -refresh-only (Yes)
- Pick newest good copy to Restore it (download)
- Restore it to plan -refresh-only (then)
- plan -refresh-only to Import the orphans (objects missing)
- Import the orphans to Plan says No changes (plan and apply)
- Plan fails or state lost: Parse error, empty file or everything new
- Stop all runs: Pause CI so nobody writes meanwhile
- Current state readable?: Does terraform state list work?
- Pick newest good copy: Bucket version, .backup or a pulled copy
- Restore it: Copy the version back, or push with care
- plan -refresh-only: See what changed since that copy
- Import the orphans: Import blocks for objects made later
- Plan says No changes: Resume CI and record what happened
The recovery drill we ran
We applied one resource and saved a copy of the state as Monday's version. On Tuesday we added a random_string and applied again. Then we deleted the state and restored Monday's copy, as you would restore an older bucket version.
The plan wanted to create the Tuesday resource, because Monday's state had never heard of it. We wrote an import block with its real ID. The plan changed to 1 to import, 0 to add, and after apply it said No changes.
Stop every run
Pause CI pipelines and tell the team. Nobody should apply while you are repairing state.
Save what you have
Run terraform state pull and save the output, even if it looks wrong. Copy any .backup files too.
Find the newest good copy
With a versioned bucket, list the state object's versions and pick the newest that parses. Locally, check the .backup and timestamped backup files.
Restore it
Copying the old version back as the current file worked with no check in our local test. Pushing it instead needs -force for a lower serial, so double-check first.
See what changed since
Run terraform plan -refresh-only and read every line. It shows objects that changed or vanished after that copy.
Adopt the orphans
Objects created after the copy are missing from state. Add import blocks with their real IDs, plan, and look for will be imported, not will be created.
Finish at No changes
Apply, then plan again until it says No changes. Write down what failed so the next fix is faster.
A pushed copy does not keep its old number either. When we forced the serial-3 backup over the state, which an earlier test push had raised to serial 7, the result had serial 4, below what it replaced. Expect that, and keep your own copy before any push.
How does OpenTofu 1.13 handle state differently from Terraform 1.16?
Mostly it does not. OpenTofu 1.13.2 read and planned against a state file that Terraform 1.16.6 had written in our test. Three differences showed up in our runs.
| Behaviour | Terraform 1.16.6 | OpenTofu 1.13.2 |
|---|---|---|
| Encrypt the whole state file | No encryption block; rejected as unsupported | Built in since 1.7, with an encryption block |
| removed block with no lifecycle | Plans to destroy the object | Plans 1 to forget, with a warning |
| import block inside a child module | Accepted; planned 1 to import | Rejected: only allowed in the root module |
| import, moved, refresh-only in the root module | Worked as shown above | Same results on our samples |
The bigger difference is the second row. A config written for OpenTofu that leans on the forget default would destroy real objects under Terraform. Writing destroy = false, or true, removes the doubt in both tools.
How do you explain Terraform state in a system design interview?
Interviewers ask about state when you propose infrastructure as code, which is managing servers and networks through reviewed code files. Show you know the risks, not only the commands.
Say what state is
A record that maps each resource in code to a real object ID, refreshed before every plan.
Name the risk in the file
It stores attributes in plain text, including secrets, so access to state means access to secrets.
Pick the backend
A remote backend with locking, encryption and versioning, such as S3 with use_lockfile on. Limit who can read it.
Limit the blast radius
Blast radius is how much one failure can damage. Keep separate state per environment or component, so one broken file cannot block everything.
Handle drift
Run a nightly plan with -detailed-exitcode and treat exit code 2 as an alert. Review with -refresh-only.
Change state through code
Use import, moved and removed blocks so every state change is planned and reviewed.
Plan the recovery
Bucket versioning, a written drill, and import blocks to adopt objects created after the restored copy.
Questions people ask
- What is Terraform state in simple words?
- It is Terraform's record of what it built. For each resource in your code, the state stores the real object's ID and its attributes. Terraform reads it, refreshes it against the real systems, and compares it with your code to decide what to change. Without it, Terraform cannot tell an existing server from one it still needs to create.
- Should I commit terraform.tfstate to Git?
- No. The state file is plain text and holds secrets such as generated passwords, and the Terraform docs tell you to keep it out of Git workflows. Git also cannot lock it, so two people can overwrite each other. Use a remote backend with locking, encryption and versioning instead, and commit .terraform.lock.hcl, which is safe to share.
- Does sensitive = true encrypt values in the state file?
- No. It only hides the value in plan output and terraform output. In our Terraform 1.16.6 run, a sensitive password appeared three times in plain text in terraform.tfstate. To keep a value out of state, use ephemeral values or write-only arguments from Terraform 1.10 and 1.11. Otherwise protect the backend itself.
- What does Error acquiring the state lock mean?
- Another run holds the lock on the same state, or a crashed run on a remote backend failed to release it. Terraform stops rather than risk two writers. Read the lock info to see who holds it. Wait or retry with -lock-timeout if that run is alive. Use terraform force-unlock with the lock ID only for your own stale lock. If a parse or decryption error appears instead of Lock Info, the state file is broken; follow the recovery steps.
- What is the difference between plan -refresh-only and terraform refresh?
- terraform plan -refresh-only shows what changed outside Terraform and changes nothing until you apply it. terraform refresh is deprecated and works like apply -refresh-only -auto-approve, writing the result straight into state without asking. The docs warn that with wrong credentials it can drop every object from state.
- Can I recover Terraform state if I deleted it?
- Usually, if you have a copy. Restore the newest version from a versioned bucket, the local .backup file or a saved terraform state pull. Then run terraform plan -refresh-only and add import blocks for objects created after that copy. With no copy at all, you must import every object again with import blocks.
- Is .terraform.lock.hcl the same as the state lock?
- No. .terraform.lock.hcl is the dependency lock file. It records which provider versions and checksums terraform init chose, and you commit it to Git. The state lock is temporary and lives in the backend, such as an OS file lock locally or a .tflock object in S3, only while a run writes state.
Sources and further reading
- Terraform docs v1.17.x: State (purpose, terraform.tfstate, backup, refresh)
- Terraform docs v1.17.x: Sensitive data in state (plain text, ephemeral, write-only)
- Terraform docs v1.17.x: Remote state
- Terraform docs v1.17.x: State locking
- Terraform docs v1.17.x: Backend block (local default, no variables, cloud block)
- Terraform docs v1.17.x: S3 backend (use_lockfile, DynamoDB deprecated, versioning, encrypt)
- Terraform docs v1.17.x: terraform plan (refresh-only, refresh=false, detailed-exitcode, lock-timeout)
- Terraform docs v1.17.x: terraform refresh (deprecated)
- Terraform docs v1.17.x: import block reference
- Terraform docs v1.17.x: Refactoring with moved blocks
- Terraform docs v1.17.x: removed block reference
- Terraform docs v1.17.x: terraform state push (lineage and serial checks)
- Terraform docs v1.17.x: terraform state rm
- Terraform docs v1.17.x: terraform force-unlock
- Terraform docs v1.17.x: Dependency lock file
- Terraform source at v1.16.6: internal/states/statemgr/filesystem_lock_unix.go (fcntl lock)
- Terraform CHANGELOG, v1.5 branch: 1.5.0 import block and generate-config-out
- Terraform CHANGELOG, v1.7 branch: 1.7.0 removed block and import for_each
- Terraform CHANGELOG, v1.10 branch: 1.10.0 S3 native locking and ephemeral values
- Terraform CHANGELOG, v1.16 branch: 1.16.0 import blocks in modules, 1.16.6 date
- Terraform CHANGELOG at v1.17.0: release date and -minimal-refresh
- HashiCorp releases index for Terraform (1.17.0 newest as of 28 October 2026)
- OpenTofu v1.13.2 release page (21 October 2026)
- OpenTofu docs v1.13.x: State and plan encryption
- OpenTofu docs v1.13.x: Resource syntax, removing resources (removed block)
- OpenTofu docs v1.7: What's new (state encryption, removed block)