Terraform State Explained
What Terraform state is and why it exists: the map from your config to real resource IDs, what lives in the file, drift, sensitive data, and the state commands.
Terraform state is the part beginners skip and then get burned by. Your .tf files describe what you want, but they say nothing about what actually exists in the cloud right now - Terraform needs somewhere to remember that. That memory is the state file, and almost every confusing Terraform behavior (why a plan shows a change you did not make, why two people running apply corrupt each other, why moving a resource in code tries to destroy and recreate it) traces back to how state works. This guide builds the mental model, shows you what is inside the file, and covers the commands you use to inspect and repair it.
Why Terraform needs state at all
Your configuration is desired state: "I want an S3 bucket named my-logs and an EC2 instance of this size." But when Terraform creates that bucket, the cloud hands back a real identifier - an ARN, an instance ID like i-0abc123, a generated name. Your .tf file never mentions i-0abc123. So how does Terraform know, on the next run, that the aws_instance.web block in your code is that specific running instance and not a brand-new one to create?
That is the whole job of state. State is the map between the names in your configuration and the real resource IDs in the provider. When you write resource "aws_instance" "web", Terraform records in state: "the resource I call aws_instance.web has ID i-0abc123." On the next plan, it reads that mapping, asks the cloud about i-0abc123, compares the live attributes to your config, and decides what (if anything) to change.
Without state, Terraform would have no memory. Every apply would either try to recreate everything from scratch or have to interrogate the entire cloud account to guess which resources it owns. State is what makes Terraform incremental: it knows what it already built, so it only changes the delta. It is also where Terraform stores metadata it needs for dependency ordering and for performance, so it does not have to re-read every resource's full definition on every run.
What is inside terraform.tfstate
State is a JSON document. By default it lives in a file called terraform.tfstate in your working directory. You do not usually read it directly, but seeing its shape demystifies the whole thing:
{
"version": 4,
"terraform_version": "1.9.0",
"serial": 12,
"lineage": "a1b2c3d4-...",
"resources": [
{
"mode": "managed",
"type": "aws_instance",
"name": "web",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"attributes": {
"id": "i-0abc123",
"instance_type": "t3.micro",
"private_ip": "10.0.1.20",
"ami": "ami-0f00d"
}
}
]
}
]
}
The important parts:
- resources - one entry per resource Terraform manages, keyed by the
type.nameaddress from your config (aws_instance.web). Inside,attributesis a full snapshot of that resource's last-known values, including the realid. - serial - a counter that bumps on every change. Backends use it to detect stale writes.
- lineage - a unique ID for this state's history, so Terraform can tell two unrelated state files apart.
- version and terraform_version - the state format version and the CLI version that last wrote it.
The key thing to notice: state is not just a list of IDs, it is a cached snapshot of every attribute. That cache is what lets plan be fast, and it is also the source of drift, which we get to below.
Why you never hand-edit state
The state file is plain JSON, so it is tempting to open it and fix something by hand. Do not. The file is tightly coupled - serial, lineage, resource addresses, and attribute values all have to stay internally consistent, and the backend may be tracking versions and locks you cannot see from the editor. A hand-edit that looks harmless can corrupt the file, break the mapping, or make Terraform think a resource is gone (so it recreates a duplicate) or still exists (so it never cleans it up).
Instead, Terraform gives you commands that modify state safely, keeping the bookkeeping correct: terraform state mv, terraform state rm, terraform import, and friends. The rule is simple: change state only through Terraform, never in a text editor. If you find yourself wanting to hand-edit, there is almost always a state subcommand for exactly that operation.
Drift: when reality changes behind your back
Drift is when the real infrastructure stops matching what Terraform recorded in state. It happens whenever something changes a resource outside of Terraform: a teammate resizes an instance in the AWS console, an autoscaler adds capacity, someone patches a security group rule by hand, or the cloud provider changes a default.
Because state holds a cached snapshot of every attribute, Terraform can detect this. When you run terraform plan, Terraform refreshes - it queries the provider for the current, live state of each managed resource and compares three things: your configuration (what you want), the live reality (what actually exists), and the prior state (what Terraform last recorded). If the live value differs from what state expected, that is drift, and the plan shows it.
terraform plan
# ~ aws_instance.web
# instance_type: "t3.large" -> "t3.micro"
#
# Note: the instance is t3.large in reality because
# someone resized it in the console, but your config
# still says t3.micro, so plan proposes changing it back.
This is why a plan sometimes shows changes you did not make in code. Terraform is not being random - it noticed reality drifted from your declared config and is offering to reconcile it. You then decide: apply the plan to force reality back to your config, or update your config to accept the new reality. Either way, drift detection is a feature: it surfaces the manual changes that would otherwise silently accumulate until something breaks.
Sensitive data lives in state
Here is the consequence that catches teams off guard: state stores the actual attribute values, including secrets. If a resource has a generated database password, a private key, an access token, or an RDS master_password, that value is written into the state file in plaintext JSON. Marking a variable sensitive hides it from CLI output, but it is still sitting in state in the clear.
This changes how you must treat the file. The terraform.tfstate on your laptop or in a repo is potentially a bundle of every credential your infrastructure generated. Practical consequences:
- Never commit state to git. It leaks secrets, and it does not solve the team problem anyway (more on that below). Terraform's default
.gitignoreshould exclude*.tfstate. - Encrypt state at rest. Remote backends store state in a location you control with encryption and access policies, instead of a file passed around by hand.
- Lock down who can read it. Read access to state is effectively read access to your secrets, so it belongs behind the same access controls as the secrets themselves.
The short version: state is not a throwaway cache, it is sensitive data. Protect it like a credential store, because it partly is one.
The state commands you actually use
Terraform ships a state subcommand for inspecting and safely surgically editing the mapping. These are the ones that matter.
Inspect what Terraform is tracking:
terraform state list # every resource address in state
terraform state show aws_instance.web # full recorded attributes for one resource
state list is the fastest way to see what Terraform believes it manages; state show dumps the cached attributes for a single resource, which is invaluable when a plan is behaving oddly and you want to see exactly what state thinks is true.
Rename or move a resource without destroying it:
terraform state mv aws_instance.web aws_instance.api
When you rename a resource in your config (or move it into a module), Terraform sees the old address disappear and a new one appear, so by default it plans to destroy the old resource and create a new one - usually not what you want. state mv updates the mapping so the existing real resource is now tracked under the new address, and the next plan shows no changes. This is the tool for refactoring code without touching live infrastructure.
Stop managing a resource without deleting it:
terraform state rm aws_instance.web
state rm removes a resource from state only. Terraform forgets it exists and will no longer manage it, but the real resource keeps running. Use it when you want to hand a resource off to another configuration or stop Terraform from touching something, without destroying it.
Adopt an existing resource that Terraform did not create:
terraform import aws_instance.web i-0abc123
terraform import is the reverse of state rm: it takes a resource that already exists in the cloud and writes it into state under an address in your config, so Terraform starts managing it. You still have to write the matching resource block yourself (import populates state, not your .tf files). This is how you bring hand-created or legacy infrastructure under Terraform without recreating it. Newer Terraform also supports declarative import {} blocks, which do the same thing as part of a normal plan/apply.
What happened to terraform refresh
You may see older docs use terraform refresh as a standalone command that updates state to match live reality without changing anything. It still exists, but it is effectively deprecated for everyday use, because refresh is now folded into plan and apply. Every plan already refreshes by default - it reads live resources, updates its in-memory view, and shows you drift - so you rarely need to run refresh on its own.
Running a bare terraform refresh is also riskier than it looks: it writes drift straight into your state file with no plan to review first, which can quietly overwrite the recorded state. The modern equivalent, when you specifically want to see drift without applying anything, is:
terraform plan -refresh-only
This refreshes and reports what drifted, and lets you choose whether to accept those changes into state - all with a review step, which the old standalone refresh skipped.
Why local state does not work for a team
Everything so far assumed one terraform.tfstate file sitting on your machine. That works fine solo, and falls apart the moment a second person is involved. Three problems make local state a non-starter for teams:
- Nobody shares the mapping. State is on your laptop. Your teammate's Terraform has no idea what you already created, so they plan to create it all again, producing duplicates and conflicts.
- No locking, so concurrent applies corrupt state. If two people run
applyat once against the same infrastructure, they race - each writes state without knowing about the other, and the file ends up describing infrastructure that does not match reality. There is no lock to make one wait for the other. - Secrets and durability. Local state means secrets on laptops and a single file whose loss orphans every resource it tracked (Terraform would no longer know those resources are "yours").
The fix is a remote backend: state lives in shared, versioned, encrypted storage (S3 with DynamoDB locking, Terraform Cloud, Azure Blob, GCS, and others) that every team member and every CI run reads from and writes to. The backend adds locking so only one apply touches state at a time, keeps state off individual machines, and gives you encryption and version history for free. This is the standard setup for any real project - see Terraform Remote State and Backends for how to configure one and migrate your local state into it.
The shape of it
State is Terraform's memory: the map from the resource names in your config to the real IDs and attributes in the cloud. It is what makes plans incremental, it is what lets plan detect drift when reality changes outside Terraform, and because it caches every attribute in plaintext, it holds your secrets and must be protected like one. You inspect it with state list and state show, refactor safely with state mv, hand resources off with state rm, and adopt existing infrastructure with import - never with a text editor. Refresh is now just part of plan (plan -refresh-only when you want drift-only). And the instant more than one person is involved, local state stops working: you move to a remote backend for sharing, locking, and encryption. Get the state model right and Terraform stops surprising you.