Skip to main content
Terraform · lesson 3 of 17
Terraform beginner Lesson 3 of 17

Terraform State (tfstate, remote state, locking)

What terraform.tfstate holds, what happens when it is lost or a resource is renamed, and how state mv, state rm and moved blocks fix it, with real output.

By SkillByExample · Updated

terraform state tfstate

Terraform’s state file is its memory: a record of which real object belongs to which resource block. Plans compare against it, and losing or misaligning it is behind most Terraform surprises. The examples use Terraform 1.16.5 and hashicorp/random 3.9.1 with this config:

terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

resource "random_pet" "app" {
  prefix = "app"
}

resource "random_id" "bucket_suffix" {
  byte_length = 4
}

What’s in the state file

After terraform init and terraform apply:

$ terraform state list
random_id.bucket_suffix
random_pet.app
$ terraform state show -no-color random_id.bucket_suffix
# random_id.bucket_suffix:
resource "random_id" "bucket_suffix" {
    b64_std     = "qiAaCg=="
    b64_url     = "qiAaCg"
    byte_length = 4
    dec         = "2854230538"
    hex         = "aa201a0a"
    id          = "qiAaCg"
}
$ cat terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.16.5",
  "serial": 3,
  "lineage": "ccd8bfc4-50c9-3f43-7cf0-a1e549a7596d",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "random_id",
      "name": "bucket_suffix",
      "provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "b64_std": "qiAaCg==",
            "b64_url": "qiAaCg",
            "byte_length": 4,
            "dec": "2854230538",
            "hex": "aa201a0a",
            "id": "qiAaCg",
            "keepers": null,
            "prefix": null
          },
          "sensitive_attributes": [],
          "identity_schema_version": 0
        }
      ]
    },
    {
      "mode": "managed",
      "type": "random_pet",
      "name": "app",
      "provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "id": "app-proper-scorpion",
            "keepers": null,
            "length": 2,
            "prefix": "app",
            "separator": "-"
          },
          "sensitive_attributes": [],
          "identity_schema_version": 0
        }
      ]
    }
  ],
  "check_results": null
}

Your two lines of random_id config turned into six attributes in state show (the JSON also records the unset keepers and prefix as null). Every value the provider reported back gets stored, including ones you never set, like hex and dec. The lineage is a unique ID for this state’s history, and serial goes up on every write. Terraform uses both to refuse an older copy of the state overwriting a newer one. Use state list and state show to read state. Don’t open the JSON in an editor.

Losing the state file

$ mv terraform.tfstate ../saved.tfstate
$ terraform plan -no-color | grep -E "^  #|Plan:"
  # random_id.bucket_suffix will be created
  # random_pet.app will be created
Plan: 2 to add, 0 to change, 0 to destroy.
$ mv ../saved.tfstate terraform.tfstate

Without state, Terraform has no record of creating anything, so it proposes creating everything again. With random values that just means new values. With cloud resources, it means a second VPC next to the first one, or a “bucket already exists” error halfway through an apply, while the originals keep running with nothing managing them. Moving the file back restores the mapping.

Renaming a resource block

Rename resource "random_pet" "app" to resource "random_pet" "web" and plan:

$ terraform plan -no-color
random_pet.app: Refreshing state... [id=app-proper-scorpion]
random_id.bucket_suffix: Refreshing state... [id=qiAaCg]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  - destroy

Terraform will perform the following actions:

  # random_pet.app will be destroyed
  # (because random_pet.app is not in configuration)
  - resource "random_pet" "app" {
      - id        = "app-proper-scorpion" -> null
      - length    = 2 -> null
      - prefix    = "app" -> null
      - separator = "-" -> null
    }

  # random_pet.web will be created
  + resource "random_pet" "web" {
      + id        = (known after apply)
      + length    = 2
      + prefix    = "app"
      + separator = "-"
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The config is identical apart from the label, but state stores objects by address. The address random_pet.app disappeared and random_pet.web appeared, so Terraform plans a destroy and a create. On a database, a refactor that only renamed a block would delete the data.

The fix: a moved block

moved {
  from = random_pet.app
  to   = random_pet.web
}
$ terraform apply -auto-approve -no-color
random_id.bucket_suffix: Refreshing state... [id=qiAaCg]
random_pet.web: Refreshing state... [id=app-proper-scorpion]

Terraform will perform the following actions:

  # random_pet.app has moved to random_pet.web
    resource "random_pet" "web" {
        id        = "app-proper-scorpion"
        # (3 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list
random_id.bucket_suffix
random_pet.web

Same object, same id, new address. A moved block goes through code review and plans like any other change. That’s why it’s the preferred way to rename. Once every copy of the state has been applied with it, you can delete the block.

Remote state (configuration only)

Each person’s local terraform.tfstate is fine for learning. A team needs one shared state with locking, so two applies can’t interleave. This S3 backend block is shown as configuration only. It needs an AWS account, so there’s no output here.

terraform {
  backend "s3" {
    bucket       = "my-terraform-state-bucket"
    key          = "envs/prod/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

use_lockfile makes the S3 backend write its own lock object. That replaces the DynamoDB table older setups used. Remote state safety shows locking for real, using the local backend, which locks the same way.

Practice

1. Rename random_pet.web to random_pet.frontend again, this time with the terraform state mv command instead of a moved block.

Solution

Delete the moved block, rename the resource block in main.tf, then:

$ terraform state mv -no-color random_pet.web random_pet.frontend
Move "random_pet.web" to "random_pet.frontend"
Successfully moved 1 object(s).
$ terraform plan -no-color | tail -3

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

state mv changes the state immediately, outside any plan, so nobody reviews it. That’s fine when you’re working alone. In a team, a moved block goes through code review.

2. Make Terraform forget random_id.bucket_suffix without destroying it, then plan.

Solution
$ terraform state rm -no-color random_id.bucket_suffix
Removed random_id.bucket_suffix
Successfully removed 1 resource instance(s).
$ terraform plan -no-color | grep -E "^  #|Plan:"
  # random_id.bucket_suffix will be created
Plan: 1 to add, 0 to change, 0 to destroy.

state rm drops the record and leaves the real object alone. Because the block is still in the config, the next plan creates a new one. To stop managing a resource, delete its block too (or use a removed block). To bring the old object back under management, use terraform import.

3. List the directory and compare the serial in terraform.tfstate with the one in terraform.tfstate.backup.

Solution
$ ls
main.tf
terraform.tfstate
terraform.tfstate.1791385451.backup
terraform.tfstate.1791385453.backup
terraform.tfstate.backup
$ grep serial terraform.tfstate terraform.tfstate.backup
terraform.tfstate:  "serial": 6,
terraform.tfstate.backup:  "serial": 3,

Since the backup was taken, three writes happened: the moved apply, state mv and state rm. Each one bumped the serial. Each state subcommand also leaves a backup named by Unix timestamp, one for state mv and one for state rm here, so a mistaken state rm can be undone by copying its file back. Two commands in the same second share one timestamp, so the second overwrites the first’s backup.

Next: variables and outputs, which make one configuration serve many environments.

Frequently Asked Questions

Where is Terraform state stored by default?
In a JSON file called terraform.tfstate in the working directory, written by the default local backend. Teams switch to a remote backend such as S3, GCS, Azure Storage or HCP Terraform so everyone reads and locks the same state. The .terraform.lock.hcl file is not state; it only pins provider versions.
What happens if I delete terraform.tfstate?
Terraform forgets everything it created. The next plan proposes to create every resource again, which in a cloud account means duplicates or name collisions, while the originals keep running unmanaged. Restore the file from terraform.tfstate.backup or your backend's versioning, or bring the objects back with terraform import.
Why does renaming a resource block destroy and recreate it?
State maps each real object to a resource address such as random_pet.app. Renaming the block to random_pet.web makes the old address vanish from config, which Terraform reads as destroy, and the new address appear, which it reads as create. A moved block, or terraform state mv, tells Terraform the address changed so it keeps the existing object.
Should I edit terraform.tfstate by hand?
No. Use terraform state mv, terraform state rm, moved blocks, removed blocks and terraform import, which validate the change and keep a backup. Hand edits easily break the lineage and serial fields Terraform uses to detect conflicting writes.
Does Terraform state contain secrets?
Often, yes. It stores every attribute of every resource in plain JSON, including generated passwords and private keys, even ones marked sensitive in the config. Treat the state file like a credential: keep it out of git and store it in an encrypted, access-controlled backend.