Skip to content
Infrastructure as Code iac state 4 min read

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 serial number.

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 sensitive only 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

AspectLocal state (default)Remote state (backend)
Location./terraform.tfstateS3, Azure Blob, GCS, Terraform Cloud
SharingSingle machine onlyShared across the team
LockingNoneAvailable (e.g. DynamoDB, native)
Encryption at restUp to youBackend-managed
Suitable forExperiments, demosAnything 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 .gitignore before your first apply.
  • Never hand-edit state; use terraform state subcommands and terraform import instead.
  • 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.
Last updated June 14, 2026
Was this helpful?