Why state exists
Imagine you wrote HCL with a single S3 bucket and ran apply. The bucket was created with the name my-bucket-12345. You run apply again. How does Terraform know that bucket is the same bucket?
- From HCL? No, HCL only describes the desired state.
- From the AWS API? No, AWS has millions of buckets, and Terraform has no way to know which one is yours.
The answer is the state file. After the first apply, Terraform stored this in terraform.tfstate:
"Resource aws_s3_bucket.demo maps to the real bucket named my-bucket-12345, with this ARN, created at this time."
Without that record, Terraform would try to create everything from scratch on every run and fail with "bucket already exists."
Where state lives
By default, terraform.tfstate sits in the same directory as your HCL. This is the local backend.
my-project/
├── main.tf
├── terraform.tfstate # ← here it is
└── terraform.tfstate.backup # copy from the previous apply
In real projects, state is moved to a remote backend: S3, Terraform Cloud, and so on. This lets a team share a single state without conflicts. See tf-init-backends.
What is inside
The file is JSON. You can inspect it by hand, but do not edit it directly:
{"version": 4,
"terraform_version": "1.9.8",
"serial": 5,
"lineage": "abc-123-...",
"resources": [
{"mode": "managed",
"type": "aws_s3_bucket",
"name": "demo",
"instances": [
{ "attributes": {"id": "my-bucket-12345",
"bucket": "my-bucket-12345",
"arn": "arn:aws:s3:::my-bucket-12345",
"region": "us-east-1",
"tags": { "Owner": "student" }}
}
]
}
]
}
Key fields:
serialis a counter. Every apply increments it by 1. Used to verify that state is current.lineageis the unique UUID of this state. It protects against accidentally swapping the file with another.resourcesis an array of all managed resources: type, name, attributes.
Commands for working with state
Do not touch the file by hand. Use the CLI:
# List all resources in state
terraform state list
# Show attributes of one resource
terraform state show aws_s3_bucket.demo
# Full state as JSON (for scripts)
terraform show -json
Dangerous commands (use only in exceptional cases):
# Remove a resource from state (NOT from the cloud)
terraform state rm aws_s3_bucket.demo
# Rename a resource in state
terraform state mv aws_s3_bucket.demo aws_s3_bucket.renamed
# Import an existing resource into state (the resource must already be described in HCL)
terraform import aws_s3_bucket.demo my-existing-bucket
These commands modify terraform.tfstate directly. Back up the file before running them.
Drift: when state and reality diverge
State is a snapshot of what Terraform believes. The actual cloud infrastructure can change underneath it.
Examples of drift:
- Someone deleted a bucket manually through the AWS Console.
- Auto Scaling changed
desired_capacityon its own. - An ALB Auto Scaling target was enabled.
On the next plan, Terraform reads state and compares it with HCL. When reality differs from state, plan -refresh=true (the default) first updates state from the API, then shows the diff.
This is expected behavior, but it requires attention. See tf-resource-lifecycle for ignore_changes, which handles attributes that change outside Terraform.
Secrets in state
This is critical to understand: state contains all attributes, including sensitive ones.
- A database password (
aws_db_instance.password) is in state. - Tokens, keys, and secret values are in state.
sensitive = truemasks values only in CLI output. In the JSON file they appear as plain text.
Because of this:
- Local state in git is not allowed. Never. Even in a private repository, it will eventually leak.
- Local state on a shared server's disk is also a bad idea. Any user with sudo can read it.
- A remote backend with encryption is required for production. S3 with SSE-KMS, Terraform Cloud with server-side encryption, and similar options.
Locking: protection against concurrent apply
If two people run apply on the same project at the same time, state can become corrupted. Locking solves this:
- The local backend has no lock. This is a risk even on a single machine (two terminals open at once).
- S3 backend with DynamoDB for locking is the standard. One apply holds a record in DynamoDB; the second waits.
- Terraform Cloud handles locking automatically.
When a concurrent apply is attempted, you will see:
Error: Error acquiring the state lock
Lock Info:
ID: abc-123
Path: s3://my-bucket/terraform.tfstate
Operation: OperationTypeApply
Who: user@host
Created: 2026-05-20 14:00:00 +0000 UTC
If you know the other process is dead, run terraform force-unlock <lock-id>.
Common pitfalls
-
Do not edit state by hand. The JSON looks simple, but Terraform validates internal invariants (lineage, serial, hashes). Break those and you will spend a long time recovering.
-
Do not commit state to git. Never. Even local state. Add to
.gitignore:terraform.tfstate
terraform.tfstate.backup
.terraform/
.terraform.lock.hcl # ← this one, on the other hand, should be committed
-
State reflects a single point in time. Between applies it does not watch the cloud. To get fresh data, run
terraform refreshorterraform plan(which also does a refresh). -
state rmdoes not delete the resource in the cloud. It only removes it from state. The resource continues to exist outside Terraform's control. Useful during migrations, dangerous when done by mistake. -
state mvis required when renaming. If you change"demo"to"main"in HCL without runningstate mv, Terraform will recreate the resource. Afterstate mv, it only updates the record without touching the cloud. -
Lineage protects against file substitution, but it can be broken. If you accidentally overwrote state with a copy from another project, the lineage will not match and Terraform will refuse to run. That is a protection, not a bug.