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:

  1. Gitignore it. Every .gitignore for a Terraform repo should include:

    terraform.tfstate
    terraform.tfstate.backup
    *.tfstate.lock.info
    .terraform/
    
  2. Don't share it over email or Slack. Treat it like a password dump.

  3. 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:

  1. Check terraform.tfstate.backup. That's the previous version; copy it over.
  2. For remote state, most backends keep version history. Restore an older version.
  3. 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-only will reconcile: Terraform updates state to reflect that the resource is gone.
  • Or terraform state rm to drop the reference, then apply to 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.