State: What It Is, Why It Matters, How to Not Break It
This chapter covers Terraform's state: what's in it, why it exists, how to move resources around, and what happens when it gets corrupted.
What State Actually Is
After every apply, Terraform writes a file called terraform.tfstate (JSON). It contains:
- A list of managed resources with their IDs.
- Every attribute Terraform knows about each resource.
- The dependency graph.
- Metadata (version, lineage, serial).
That file is Terraform's memory. Without it, Terraform has no idea what it has created.
A state file (slightly abridged):
{
"version": 4,
"terraform_version": "1.6.0",
"serial": 12,
"lineage": "a4b8c...",
"resources": [
{
"mode": "managed",
"type": "aws_s3_bucket",
"name": "notes",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"attributes": {
"id": "ada-notes-2026-04-19",
"bucket": "ada-notes-2026-04-19",
"region": "us-east-1"
}
}
]
}
]
}
You can read it. Don't edit it by hand.
Why State Exists
Three reasons.
Resource Identity
Cloud APIs give you IDs. Terraform needs to remember that "the S3 bucket in my config called notes" is the bucket with ID ada-notes-2026-04-19 in AWS. Without state, it couldn't.
Performance
Terraform could ask the cloud "what does everything look like?" on every plan. It does, sort of (refresh). But to know what it's managing, it consults state. Otherwise every plan would be an exhaustive cloud inventory.
Metadata
Some resources have attributes Terraform can't infer from the API: the original config that created them, the lineage of a resource, sensitive values marked during creation. State preserves this.
Local State
By default, Terraform writes state to terraform.tfstate in the current directory, plus terraform.tfstate.backup (the previous version).
Both are sensitive. They contain credentials, output values, and sometimes passwords in plaintext.
Rules for local state:
Gitignore it. Every
.gitignorefor a Terraform repo should include:terraform.tfstate terraform.tfstate.backup *.tfstate.lock.info .terraform/Don't share it over email or Slack. Treat it like a password dump.
Back it up. Your local state is fragile. A failed disk wipes your knowledge of the infrastructure (though the infrastructure survives).
Local state is fine for learning and solo projects. For teams, move to remote state (Chapter 7).
The State Commands
terraform state has subcommands for inspecting and surgically modifying state.
list
Every resource Terraform is tracking:
terraform state list
aws_s3_bucket.notes
aws_s3_bucket_versioning.notes
random_id.suffix
show
Full details of one resource:
terraform state show aws_s3_bucket.notes
Prints every attribute Terraform knows.
mv
Rename or move a resource within state.
# Renaming
terraform state mv aws_s3_bucket.notes aws_s3_bucket.main
# Moving into a module
terraform state mv aws_s3_bucket.notes module.storage.aws_s3_bucket.notes
When do you use this? When you refactor the config. If you just change the name in the .tf file, Terraform sees "old resource removed, new resource added" and destroys and recreates. state mv tells Terraform it's the same resource under a different name.
rm
Remove a resource from state without destroying it.
terraform state rm aws_s3_bucket.notes
The bucket still exists in AWS. Terraform just stops tracking it.
Use cases:
- Handing off a resource to another Terraform config.
- Before importing under a new address.
- Rare cases of corrupted state on a single resource.
Dangerous: if you state rm and forget, you'll think the resource is gone. It's not. You now have an orphan in AWS.
import
Bring an existing resource into state.
terraform import aws_s3_bucket.notes ada-notes-2026-04-19
Two arguments: the address in your config (aws_s3_bucket.notes), and the AWS ID (ada-notes-2026-04-19).
You still have to write the config; import only populates state. Terraform 1.5+ supports an import block that handles both:
import {
to = aws_s3_bucket.notes
id = "ada-notes-2026-04-19"
}
resource "aws_s3_bucket" "notes" {
bucket = "ada-notes-2026-04-19"
}
terraform plan -generate-config-out=generated.tf can even write the config for you, as a starting point.
refresh
Force a state refresh without planning.
terraform refresh
Rarely needed; plan does it automatically.
replace
Plan a replacement of one resource (like tainting, in older Terraform).
terraform apply -replace="aws_instance.web[0]"
Useful when you suspect a resource is in a bad state and want it rebuilt.
Drift
Drift is when the real world differs from what Terraform thinks. Someone clicked in the console; an auto-scaling policy changed something; a provider patched a field.
Running terraform plan will usually show drift: "this resource has been modified outside Terraform; Terraform will update it back". You then decide: accept the change (update the config), or override it (re-apply).
The goal is zero drift. Every change to managed resources goes through Terraform. Chapter 9 covers drift detection in CI.
Locking
If two people run terraform apply at the same time, they can corrupt state: each thinks they know the current state, both write, the later write wins, and half of one person's changes vanish.
Local state has no locking. Remote state does (when configured), via DynamoDB for S3 backend or equivalent mechanisms for others.
When locking is enabled, terraform apply sets a lock, does its work, releases the lock. If someone else tries to apply while you hold the lock:
Error: Error acquiring the state lock
Lock Info:
ID: e3b0c442...
Path: project/terraform.tfstate
Operation: OperationTypeApply
Who: ada@example.com
Created: 2026-04-19 10:15:02
They get an error and wait. Chapter 7 configures this.
Corrupted State
Rarely, state gets corrupted. Causes: interrupted applies, manual edits, concurrent writes without locking, bugs.
Symptoms: Terraform errors on parse, resources that exist but Terraform says don't, plans that propose to recreate everything.
Recovery, in order of escalation:
- Check
terraform.tfstate.backup. That's the previous version; copy it over. - For remote state, most backends keep version history. Restore an older version.
- As a last resort, rebuild state from scratch by importing every resource. Slow, painful, avoidable with backups.
The best recovery is backup. Remote state backends (Chapter 7) give you versioning for free.
Sensitive Data in State
State contains every attribute of every resource. That includes initial passwords, connection strings, API keys, and similar.
Rules:
- Don't commit state to git.
- Use a backend that encrypts at rest (S3 with SSE-KMS, Azure Blob with encryption, etc.).
- Restrict access to the state bucket (IAM, not everyone on the team).
- For very sensitive attributes, consider fetching at runtime from Secrets Manager instead of storing in state.
When Terraform Thinks Something Exists That Doesn't
Sometimes you delete a resource in the cloud console (don't) and Terraform's state still references it. Next plan fails:
Error: reading S3 Bucket: NotFound
Options:
terraform apply -refresh-onlywill reconcile: Terraform updates state to reflect that the resource is gone.- Or
terraform state rmto drop the reference, thenapplyto recreate.
Both work. -refresh-only is the lighter touch.
A Mental Model
Think of state as an inventory notebook. Terraform writes into it every time it creates something. On every plan, it cross-checks the notebook against the cloud. When they disagree, Terraform proposes a change to make them match.
Most state commands are ways to edit the notebook: rename an entry, move an entry to a different page (module), add an entry for something that already exists, remove an entry for something you're handing off.
You rarely edit the notebook. When you do, you do it with terraform state commands, not by hand.
Common Pitfalls
Editing terraform.tfstate directly. Don't. Use terraform state commands.
Committing state to git. See above. Sensitive. Conflict-prone.
No remote state for team work. Race conditions. Lost changes. Move to S3 backend immediately.
Not backing up state. Remote state helps. Still, export state periodically for disaster recovery (terraform state pull > backup.tfstate).
Relying on state rm for routine changes. If you state rm a lot, your config and your intent are misaligned. Refactor instead.
Ignoring drift. Drift compounds. Every week of uncorrected drift is more config edits to catch up. Detect and fix promptly.
Next Steps
Continue to 06-modules.md to compose reusable chunks of infrastructure.