Terraform Basics #5 State Explained: The tfstate File Structure and How plan Computes Changes

5 min read

In #1 we watched destroy retrace and remove exactly what it had created, and left a question hanging: “how does Terraform remember what it made?” The answer is the terraform.tfstate file that appeared in the directory back then. State is the very center of how Terraform works, and most real-world Terraform incidents revolve around this file. In this post we open the file to see its structure, look at what plan actually compares against what, and draw the line between the times you should manipulate state and the things you must never touch.

Opening the tfstate file #

In a directory where you have applied a single bucket, open terraform.tfstate and you find JSON. Trimmed down to the important parts, it looks like this.

terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.15.8",
  "serial": 3,
  "resources": [
    {
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "hello",
      "instances": [
        {
          "attributes": {
            "bucket": "my-terraform-hello-20260822",
            "arn": "arn:aws:s3:::my-terraform-hello-20260822",
            "region": "ap-northeast-2"
          }
        }
      ]
    }
  ]
}

The structure is simple: a list of the resources Terraform manages, plus the last-known attribute values of each. type and name form the resource address you learned in #2, and attributes is where the values you fetched with reference syntax come from. When you reference aws_s3_bucket.hello.arn, Terraform does not ask AWS — it looks in this file first.

A security problem follows from this. The attributes store every attribute in plaintext. The master password you set when creating an RDS instance, and the value of the variable you masked with sensitive in #3 — inside state, they are all right there. Sensitive only masks screen output. This is why state files must never be committed to Git, and putting the following in .gitignore is the standard.

.gitignore
.terraform/
*.tfstate
*.tfstate.*

There is one exception to watch for. .terraform.lock.hcl, created by init, is the file that pins provider versions — it has “lock” in its name, but it has nothing to do with state and is a file you should commit.

plan is a three-way comparison #

It is easy to assume plan compares only code and state, but it actually compares three things.

Plan comparison
code (.tf)  vs  state (.tfstate)  vs  real infrastructure (AWS API)

When you run plan, Terraform first queries AWS for the current shape of the resources in state and updates the state (refresh), then compares against the code to compute what to do. You can verify this with an experiment. Delete a Terraform-created bucket by hand in the console, then run plan: Terraform notices during the refresh step that the bucket is gone and proposes “1 to add.” The code says it must exist, so Terraform intends to create it again.

Conversely, change a tag by hand in the console and plan proposes a change that reverts it to the code’s value. This mismatch — changes made outside the code diverging from the code — is called drift. It is the reason for the principle that resources managed by Terraform should only be changed through Terraform; how teams handle drift at scale is a topic for the operations series.

State manipulation commands: never edit the file directly #

When you do need to adjust state, use the dedicated commands, not a text editor. Breaking the JSON or desynchronizing the serial by hand-editing the file is the worst kind of incident.

Check
terraform state list                    # list of managed resource addresses
terraform state show aws_s3_bucket.hello  # all attributes of one resource

These two are read-only and safe — your first tools for debugging. The next two modify state, so use them with care.

  • terraform state mv: changes a resource’s address. If you rename aws_s3_bucket.hello to aws_s3_bucket.logs in code only, Terraform will try to destroy hello and create logs from scratch — a rename becomes a destruction. Run terraform state mv 'aws_s3_bucket.hello' 'aws_s3_bucket.logs' to update the address on the state side, and the rename happens without recreation. Recent versions also offer a declarative alternative, the moved block written in code; for team work, this is the recommended route because it shows up in review.
  • terraform state rm: removes a resource from state only. The real resource stays; it just leaves Terraform’s management — used in handover situations like “this resource is now managed by other code.” Run rm by mistake and the next apply tries to create the same resource again and fails with a name conflict, or worse, creates a duplicate — so be absolutely clear about your goal before using it.

What happens when you lose state #

If terraform.tfstate is deleted, Terraform believes it manages nothing. The infrastructure is alive and well in AWS, but plan proposes creating everything from scratch, and applying that produces name-conflict errors or duplicate resources. The recovery path is terraform import, reconnecting existing resources to state one by one — considerable labor if you have dozens of resources. The detailed procedure belongs to the operations series, but the conclusion at the basics stage is simple: state is a file you must not lose, and the local disk of a single laptop is not a fit place to keep it. The answer to this problem — remote state — comes in #9.

Recap #

What we covered in this post:

  • State is JSON holding the list of resources Terraform manages and their last-known attribute values, and it is the source of the values that reference syntax reads
  • Every attribute is stored in plaintext, so state is never committed. However, .terraform.lock.hcl is a version-pinning file, not state, and should be committed
  • plan is a three-way comparison of code, state, and real infrastructure. The mismatch created by out-of-code changes is drift, and the principle is that managed resources change only through Terraform
  • State adjustments happen through commands, not file edits. Renames use state mv or a moved block; removing from management uses state rm
  • Lose the state and Terraform tries to create everything again. A local disk is unfit for keeping state, and the answer is remote state in #9

In the next post (#6 Dependencies, count, and for_each) we cover the syntax for when resources multiply: how Terraform decides creation order, and how to create resources repeatedly with count and for_each.

X