The State File
Terraform is declarative: you describe the infrastructure you want, and Terraform figures out the API calls to make it real. To do that, it needs to remember what it already created — and that memory lives in the state file. The state file is the single source of truth that maps the resources in your configuration to the actual objects running in your cloud provider. Understand it well, because corrupting it or leaking it can be far more painful than a broken .tf file.
What the state file is
When you run terraform apply, Terraform records the result in a file named terraform.tfstate (by default, in your working directory). It is a plain JSON document that stores, for every managed resource:
- The mapping from a configuration address (for example
aws_s3_bucket.assets) to a real-world ID (the actual bucket name). - A full snapshot of every resource attribute as it existed after the last apply.
- Resource dependencies, so Terraform can destroy and create things in the correct order.
- Metadata such as the state format version, the Terraform version, and a monotonically increasing
serialnumber.
This mapping is why Terraform can answer the question “what changed?” Without it, Terraform would have no way to tell the difference between a resource it created and one that simply happens to exist.
This applies equally to OpenTofu, the open-source fork of Terraform — it uses the same state format and the same tofu.tfstate semantics, so everything below is directly portable.
A look inside
Consider a minimal configuration:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "assets" {
bucket = "devcraftly-prod-assets"
tags = {
Environment = "production"
}
}
After terraform apply, the resulting state contains a structure like this (trimmed for clarity):
{
"version": 4,
"terraform_version": "1.9.5",
"serial": 3,
"lineage": "8f2b1c4e-7a90-4d11-9c3a-2e5b6f0a1d8c",
"resources": [
{
"mode": "managed",
"type": "aws_s3_bucket",
"name": "assets",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"attributes": {
"id": "devcraftly-prod-assets",
"arn": "arn:aws:s3:::devcraftly-prod-assets",
"bucket": "devcraftly-prod-assets",
"tags": { "Environment": "production" }
}
}
]
}
]
}
The serial increments on every change, and lineage is a unique ID for this state’s history — both are used to detect divergence when state is shared. You rarely read this file directly; instead, use the CLI to inspect it:
terraform show
terraform state list
Output:
aws_s3_bucket.assets
State can contain secrets in plaintext
This is the part developers underestimate. Terraform stores the full attribute set of every resource — including values that are sensitive. An RDS instance writes its password, an aws_secretsmanager_secret_version stores its secret_string, and a TLS private key resource stores the key itself. All of it lands in the state file as plaintext JSON, even when you mark a variable as sensitive.
resource "aws_db_instance" "app" {
identifier = "devcraftly-app"
engine = "postgres"
instance_class = "db.t3.micro"
username = "appuser"
password = var.db_password
allocated_storage = 20
skip_final_snapshot = true
}
The password above will appear in clear text in terraform.tfstate.
Warning: marking an output or variable as
sensitiveonly hides it from CLI output. It does not encrypt or remove the value from the state file. Treat the entire state file as a secret.
Two rules you must never break
Never edit it by hand
The state file is precisely formatted and internally consistent — serial, lineage, dependency ordering, and checksums all have to line up. Hand-editing risks silent corruption that surfaces only on the next apply, where Terraform may try to recreate or destroy resources unexpectedly. If you need to change state, use the dedicated commands (terraform state mv, terraform state rm, terraform import) which mutate it safely and bump the serial for you.
Never commit it to git
Because state holds secrets and is environment-specific, it should never live in version control. Add it to .gitignore immediately:
cat >> .gitignore <<'EOF'
*.tfstate
*.tfstate.*
.terraform/
EOF
Committing state also breaks team workflows: two people applying from their own checkouts will produce conflicting serials and clobber each other’s changes. The real solution is remote state with locking, covered in the linked topics below.
Local vs remote state
| Aspect | Local state (default) | Remote state (backend) |
|---|---|---|
| Location | ./terraform.tfstate | S3, Azure Blob, GCS, Terraform Cloud |
| Sharing | Single machine only | Shared across the team |
| Locking | None | Available (e.g. DynamoDB, native) |
| Encryption at rest | Up to you | Backend-managed |
| Suitable for | Experiments, demos | Anything real |
Best Practices
- Treat the state file as highly sensitive data — assume it contains plaintext credentials, and restrict access accordingly.
- Use a remote backend with encryption at rest and state locking for any shared or production environment.
- Add
*.tfstate,*.tfstate.*, and.terraform/to.gitignorebefore your first apply. - Never hand-edit state; use
terraform statesubcommands andterraform importinstead. - Reduce the blast radius by splitting infrastructure into multiple smaller state files (per environment or component).
- Prefer pulling secrets at apply time from a secrets manager rather than storing long-lived values that end up frozen in state.
- Back up remote state by enabling object versioning on the backend bucket so you can recover from a bad apply.