State

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every Terraform and OpenTofu configuration is paired with a record of what it has built: which real object each resource instance in the code stands for, what that object looked like the last time the tool saw it, and a few facts that are needed to destroy it safely later. That record is the state. It is the least visible part of the tool and the one that causes the most outages, because almost every serious incident in a Terraform project, such as a destroyed database, a duplicated resource, a stuck lock or a leaked password, is in the end a problem with state.

This page describes state as documented for Terraform 1.17 (released 2026-10-21) and OpenTofu 1.13 (latest patch 1.13.2). It explains why state exists, what the file contains, why it must be treated as a secret, how locking protects it, how to read and change it with the state family of commands and with the configuration-driven alternatives, how backups work, and how to detect and reconcile drift. It follows The Dependency Graph and the Plan and CLI Essentials in the reading path, and it leads into Backends and Remote State, where the places that hold state are described in detail.

The examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). All names, identifiers and values are placeholders. No state shown on this page comes from a real account, and the JSON documents are abbreviated and marked as illustrative, because the exact content of a state file depends on the provider versions in use.

Why Terraform needs state

Terraform could in theory ask the cloud provider on every run what exists and compare it with the code. It does not, for reasons that the official documentation spells out in its page on the purpose of state. There are four of them.

Purpose What it means in practice

Mapping to the real world

The configuration says aws_s3_bucket.covers; the provider knows an object called something like bookshelf-covers-example. The state is the only place that stores the binding between the two. Early prototypes of Terraform tried to find objects through provider tags and failed, because not every resource type and not every cloud supports tags, so Terraform keeps its own structure.

Metadata

Alongside the binding, state keeps the dependencies that were in force when the object was last applied, the provider configuration that manages it, and some provider-internal data. This is what lets Terraform destroy an object whose resource block has already been deleted: the configuration no longer says in which order things must go, so the order is read from the state.

Performance

State caches the attributes of every managed object. For a small infrastructure Terraform refreshes all of them on every plan. For a very large one, refreshing every object is slow and hits API rate limits, so people use -refresh=false or -target, and the cached state is then treated as the record of truth.

Syncing a team

With a local file, everyone must have the latest state before running anything. With remote state and a locking backend, everyone starts from the latest snapshot and nobody writes at the same time.

The official docs state the central rule in one sentence: the primary purpose of state is to store bindings between objects in a remote system and resource instances declared in your configuration. Terraform expects a one-to-one mapping. If a remote object were bound to two resource instances, the mapping would be ambiguous and Terraform "may behave unexpectedly". Terraform guarantees one-to-one when it creates objects itself, but when you add or remove bindings by other means (terraform import, an import block, terraform state rm, terraform state mv), the guarantee becomes your responsibility. Importing the same bucket into two resource addresses is the textbook way to break it.

State is also the reason a configuration is not enough to describe a deployment. Two copies of the same code with different state files manage two different sets of objects, and the same state file used with two different codebases is a recipe for destroying things. That is why the section on Workspaces, Environments and Project Structure spends so much time on how many states a project should have.

Terraform core, a provider, the remote API and the state file, showing that core reads and writes state while the provider talks to the API

Where state lives

By default Terraform keeps each workspace’s state in a local file named terraform.tfstate, and the previous snapshot in terraform.tfstate.backup, both in the working directory. That needs no configuration, but the official docs list the two reasons it does not scale: it makes collaboration hard, and the state is lost if the file is lost. The recommendation is to store state in HCP Terraform or in a remote backend, and the docs warn against version control or any storage that lacks locking and secure access control, because that risks data loss and the exposure of secrets stored in the state.

Three different files in a working directory have confusingly similar names.

File What it is

terraform.tfstate

The state of the selected workspace when the local backend is used (path configurable with the path argument of the local backend).

terraform.tfstate.backup

The previous snapshot, written by the local backend when it saves a new one.

.terraform/terraform.tfstate

Not infrastructure state. It records the backend configuration of this working directory, created by terraform init. It can contain the authentication parameters you passed with -backend-config, so it must not be committed.

With a remote backend the state lives in the backend: an S3 object, a Google Cloud Storage object, an Azure blob, a row in PostgreSQL, a Kubernetes secret, or an HCP Terraform workspace. Terraform holds it in memory while it works and does not leave a copy on disk, apart from the backups that the terraform state commands write (see Backups and recovery). The catalogue of backends, partial configuration and migration with terraform init -migrate-state are the subject of Backends and Remote State.

The state file format

State is a JSON document. The official docs are firm on two points: do not edit it by hand, because the terraform state commands exist to modify it safely, and do not build software on the raw format, because the format "is subject to change in new Terraform versions" even though new versions are generally backward compatible with earlier snapshots. For tools that need to read state, Terraform provides stable JSON views: terraform output -json for root module outputs and terraform show -json for the full latest snapshot. The raw file is nevertheless worth understanding, because you will read it during incidents.

The top level of a state file has seven fields. This list comes from the state file encoder in the Terraform source code (internal/states/statefile, tag v1.17.0), because the format is not a documented interface.

Field Meaning

version

The state format version. It is hard-coded to 4 by Terraform 1.17. The number is the file format, not the Terraform version.

terraform_version

The version of Terraform that wrote the snapshot. Terraform uses it to decide whether the installed version can read the state.

serial

A counter that increases every time a new snapshot of the same state is written. It orders snapshots of one state.

lineage

A unique identifier created when the state is first created. It identifies this state across all its snapshots, so two files with different lineages are unrelated states.

outputs

The root module output values, with their types and a sensitive flag.

resources

One entry per resource (and data source) with its mode, type, name, provider, optional module and each, and a list of instances carrying the attributes.

check_results

The last known results of check blocks and other checkable objects (resources with conditions, variables with validations), with object_kind, config_addr, status and, per object, object_addr, status and any failure_messages. This is what lets Terraform report the previous status of a check.

Each entry in resources[].instances[] has its own fields: index_key (for count and for_each), status, deposed (a key present when a replaced object is being kept until its replacement is created, as happens with create_before_destroy), schema_version, attributes, sensitive_attributes, identity_schema_version and identity (the resource identity used by identity-based import), private (opaque provider data), dependencies and create_before_destroy.

The following document is illustrative. It is abbreviated from what the encoder defines for a covers bucket and a generated database password, with values replaced by placeholders. A real file contains every attribute of every resource, and it contains the real value of every sensitive attribute, which is why this page shows <redacted> instead.

{
  "version": 4,
  "terraform_version": "1.17.0",
  "serial": 12,
  "lineage": "<uuid-generated-when-the-state-was-first-created>",
  "outputs": {
    "covers_bucket_name": {
      "value": "bookshelf-covers-example",
      "type": "string"
    },
    "db_password": {
      "value": "<redacted>",
      "type": "string",
      "sensitive": true
    }
  },
  "resources": [
    {
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "covers",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "bucket": "bookshelf-covers-example",
            "id": "bookshelf-covers-example"
          },
          "sensitive_attributes": [],
          "identity_schema_version": 0,
          "identity": {
            "bucket": "bookshelf-covers-example"
          },
          "private": "<opaque>",
          "dependencies": []
        }
      ]
    },
    {
      "module": "module.catalog_db",
      "mode": "managed",
      "type": "random_password",
      "name": "admin",
      "provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
      "instances": [
        {
          "schema_version": 3,
          "attributes": {
            "result": "<redacted>",
            "length": 24
          },
          "sensitive_attributes": [
            [
              { "type": "get_attr", "value": "result" }
            ]
          ]
        }
      ]
    }
  ],
  "check_results": [
    {
      "object_kind": "var",
      "config_addr": "var.environment",
      "status": "pass",
      "objects": [
        { "object_addr": "var.environment", "status": "pass" }
      ]
    }
  ]
}

Four properties of this file matter in practice.

  • serial and lineage are the integrity checks. terraform state push refuses to overwrite a state whose lineage differs, and refuses to overwrite a state whose serial is higher than the one being pushed. Both protections exist so that an old copy never replaces a newer one by accident. They can be bypassed with -force, which is why -force on a push is one of the most dangerous flags in the tool.

  • Attributes are stored in plain text. The sensitive_attributes list only records which paths are sensitive so that Terraform can hide them in its output. The values are still in the file.

  • The state does not store your variable values or your configuration. It stores outputs, resource attributes and metadata. A terraform.tfvars secret reaches the state only if it flows into a resource attribute or an output.

  • The version field belongs to the format. Older formats exist, and Terraform upgrades the snapshot in memory when it reads one. terraform state pull documents that it "upgrades the local copy to the latest state file version that is compatible with locally-installed Terraform" before printing it, so it cannot be used to find out which Terraform version wrote the remote state.

For tools, use the supported views instead of parsing the file. terraform show -json prints a values representation (root_module with resources and child_modules, plus outputs) together with a format_version, and the guidance is that consumers should ignore unrecognized properties and reject an unsupported major format_version. The details of those formats belong to Machine-readable Output and Integrations.

Sensitive data in state

State holds the attributes of every managed object, so it holds every secret that is an attribute of one: the initial password of a database, the private key of a TLS certificate, an API token returned by a provider. The official sensitive-data guide puts it plainly: Terraform state and plan files contain detailed information about your infrastructure, and with local state the file is plain text that includes any secret values defined in your configuration. OpenTofu’s page on sensitive data in state says the same: "treat the state itself as sensitive data."

Two things the sensitive argument does not do are worth repeating, because they are the most common misconception.

  • Marking a variable or output sensitive = true hides it from CLI output and from the HCP Terraform UI. It does not keep it out of the state file or the plan file.

  • A random_password marked sensitive in the plan is still stored in the clear in the state.

What you can do about it falls into four groups, which the official guide orders by how much they protect.

Approach What it protects Available from

sensitive on variables and outputs

CLI output and the HCP Terraform UI. The value is still stored in state and plan.

Terraform 0.15

Ephemeral values: ephemeral variables and child-module outputs, ephemeral resources

Terraform omits them from state and plan files entirely.

Terraform 1.10

Write-only arguments on managed resources (password_wo style arguments, defined by each provider)

The argument is sent to the provider and never persisted in state or plan.

Terraform 1.11

Secure storage of the state itself: remote backend with encryption at rest and access control, OpenTofu state encryption

Protects the file as a whole, but anyone who can read the decrypted state sees every value that is stored in it.

Backend-specific; OpenTofu 1.7 for encryption

The preferred order is therefore: keep the secret out of the state if the provider supports an ephemeral resource or a write-only argument, otherwise treat the state as the secret it is. The full treatment of ephemeral and write-only values, with the provider arguments that exist today, is in Sensitive Data, Ephemeral Values and Write-only Arguments.

Rules that follow from this, and that apply to the Bookshelf project:

  • Never commit terraform.tfstate or a state backup. Add *.tfstate, *.tfstate.* and .terraform/ to .gitignore.

  • Use a remote backend with encryption at rest, versioning and access control, and restrict read access to the state as tightly as write access. Reading a state is enough to obtain every secret in it.

  • Treat a saved plan file (-out=plan.tfplan) the same way: it contains a copy of the state at the time of the plan and the proposed new values.

  • Do not store state as a CI artifact. Jobs that archive terraform.tfstate leak it to anyone who can download artifacts.

  • Be careful with terraform state pull and terraform show: their output goes to a terminal or a log, and sensitive attributes are shown by state pull in the clear because it prints the raw document.

  • Prefer short-lived credentials (OIDC from CI) so that the state does not hold long-lived keys for provider configurations. Provider configuration does not itself enter the state, but credentials passed as resource arguments do.

OpenTofu state and plan encryption

OpenTofu 1.7 added optional encryption of state and plan files at rest, for local storage and for any backend. It is configured in an encryption block inside terraform, by choosing a key provider, an encryption method (currently AES-GCM) and attaching the method to state, plan or both. The passphrase below is supplied through the TF_ENCRYPTION environment variable instead of the code, so that it never reaches version control.

terraform {
  required_version = ">= 1.13"

  encryption {
    # The key provider and method are declared in the TF_ENCRYPTION
    # environment variable (see below). The code only says that state and plan
    # must be encrypted and that nothing may be read or written in clear text.
    state {
      enforced = true
    }
    plan {
      enforced = true
    }
  }
}
# Illustrative. The passphrase comes from a secrets manager, not from this file.
export TF_ENCRYPTION=$(cat <<EOF
key_provider "pbkdf2" "bookshelf" {
  passphrase = "$BOOKSHELF_STATE_PASSPHRASE"
}
method "aes_gcm" "bookshelf" {
  keys = key_provider.pbkdf2.bookshelf
}
state {
  method = method.aes_gcm.bookshelf
}
plan {
  method = method.aes_gcm.bookshelf
}
EOF
)
tofu init

The OpenTofu documentation lists the cautions that apply before turning this on: once encryption is enabled the files are unrecoverable without the key, so keep a backup of the key and test the recovery plan first; encryption protects data at rest but does not protect against data loss or against a replay of an older state; and enabling it on an existing project needs an unencrypted fallback method so OpenTofu can read the old clear-text state. Key providers include PBKDF2, AWS KMS, GCP KMS, Azure Key Vault, OpenBao and an external program. Terraform has no equivalent: the protection of Terraform state is the responsibility of the backend. The comparison is continued in Backends and Remote State and OpenTofu.

Locking

If the backend supports it, Terraform locks the state for every operation that could write it, so that two runs cannot modify the same state at once. The lock is taken automatically, silently, and released when the operation ends. If acquiring the lock fails, Terraform stops. If it takes longer than expected, Terraform prints a status message.

Option or command Effect

-lock=false

Do not lock for this command. Available on most commands that read or write state. The docs state they "do not recommend it", and for state mv and state replace-provider they call it dangerous if others might run commands against the same workspace.

-lock-timeout=DURATION

Retry acquiring the lock for the given time (for example 30s or 5m) before returning an error. This is what CI jobs should pass, so that a short overlap between two pipelines waits instead of failing.

terraform force-unlock LOCK_ID

Remove a lock manually. Takes the lock ID that Terraform prints when it cannot acquire or release a lock. -force skips the confirmation prompt. It does not modify infrastructure.

The lock ID acts as a nonce: the command refuses to unlock anything other than the lock whose ID you supply, which prevents unlocking the wrong lock by mistake. The documentation of the state locking page has a blunt warning about the command: be very careful, because unlocking while someone else holds the lock can produce multiple writers, and it should be used only to remove your own lock in the case where automatic unlocking failed. How the lock behaves "is dependent on the backend being used", and for local state files another process cannot unlock them.

The following console session is illustrative; the exact wording differs between versions and backends.

$ terraform apply
Acquiring state lock. This may take a few moments...
Error: Error acquiring the state lock

  Lock Info:
    ID:        6b1f0a2c-0000-0000-0000-000000000000
    Operation: OperationTypeApply
    Who:       ci-runner@build-17
    Created:   2026-10-23 09:12:41 +0000 UTC

# Confirm that the run which holds the lock has really died (cancelled job,
# crashed runner), then:
$ terraform force-unlock 6b1f0a2c-0000-0000-0000-000000000000

The safe procedure for a stuck lock is therefore: read the lock information, check with the person or the pipeline that holds it, confirm that its process is really gone, and only then force-unlock. After a crash, check the state before the next apply, because an interrupted apply may have created objects that the state does not yet record. The way to recover those objects is described in Recovering from a failed apply.

Not every backend locks. local locks the file locally; s3 locks with a lock file next to the state when use_lockfile = true, which became generally available in Terraform 1.11 and deprecated the DynamoDB arguments (dynamodb_table); gcs, azurerm, pg, kubernetes and HCP Terraform lock natively, while http depends on the server. The list per backend, with the arguments, is kept in one place: Backends and Remote State.

HCP Terraform goes further than a lock: it queues runs per workspace, so two runs for the same workspace are serialized, and it can detect an attempt to create a new plan while another is waiting for approval. See HCP Terraform and Terraform Enterprise.

Inspecting state

The terraform state command group has seven subcommands in the 1.17 documentation: identities, list, mv, pull, replace-provider, rm and show. (The command also has a push, documented on its own page; it is missing from the command index, but exists.) The read-only ones do not write a backup. The documentation recommends combining their output with Unix tools, since it was designed to be friendly to grep and awk.

Command What it does Notes

terraform state list [-id=ID] [address...]

Lists the resource instances in state, sorted by module depth and then alphabetically. Addresses filter the output.

-id shows only the instance whose id matches, which is how you find where in the configuration a known object is. -state=path is a legacy option for the local backend.

terraform state show [-json] ADDRESS

Shows the attributes of exactly one resource instance.

-json needs Terraform 1.16 or later. The command fails with exit code 1 when it cannot render the resource, since 1.15.

terraform state identities -json [-id=ID] [address...]

Lists the resource identities of instances in state, as JSON. Resources without an identity are not listed.

-json is required. Addresses may be resource or module addresses and an unknown address is an error. Present in the documentation since Terraform 1.12, together with resource identity.

terraform state pull

Downloads the state from its backend, upgrades it in memory to the latest format that the installed Terraform supports, and prints the raw document to stdout.

Prints secrets in the clear. Useful with jq, and as the starting point for manual repair.

Addresses use the resource addressing syntax: aws_s3_bucket.covers, aws_instance.web[0], module.catalog_api, module.catalog_api.module.network.aws_subnet.private["eu-west-1a"]. Brackets and quotes have a meaning in shells, so quote the address with single quotes in Unix shells; PowerShell and cmd.exe need different escaping, and the documentation of state show shows all three.

$ terraform state list
aws_s3_bucket.covers
module.catalog_db.aws_db_instance.this
module.catalog_db.random_password.admin
module.catalog_api.module.network.aws_subnet.private["eu-west-1a"]
module.catalog_api.module.network.aws_subnet.private["eu-west-1b"]

$ terraform state list module.catalog_db
module.catalog_db.aws_db_instance.this
module.catalog_db.random_password.admin

$ terraform state show 'aws_s3_bucket.covers'
# aws_s3_bucket.covers:
resource "aws_s3_bucket" "covers" {
    bucket = "bookshelf-covers-example"
    id     = "bookshelf-covers-example"
    # (attributes abbreviated)
}

$ terraform state identities -json aws_s3_bucket.covers
{
  "aws_s3_bucket.covers": { "bucket": "bookshelf-covers-example" }
}

Source: Terraform Docs — terraform state list; the sample output is illustrative and abbreviated, derived from the examples on the state show and state identities pages.

terraform show and terraform output are the other inspection commands. terraform show without arguments prints the whole current state in human-readable form, terraform show -json prints it for programs, and terraform output -json prints the root outputs. The distinction between state show and show is simple: state show takes one resource address; show prints all of it, or a saved plan.

Identities are worth a sentence of their own. A resource identity is a set of attributes that uniquely identifies a remote object in a provider’s terms, such as a bucket name and a region, and is stored beside the resource’s attributes in the state (identity_schema_version and identity in the file). It is what makes identity-based import blocks possible. state identities is a read-only way to see the values Terraform has recorded. Not all providers and resource types implement identities yet, and a resource that does not is simply left out of the output. The import side is covered in Import and Query.

Changing state

Changing state does not change infrastructure. It changes what Terraform believes about infrastructure, and that is exactly why it is dangerous: a wrong edit makes the next plan destroy or duplicate something real. The tool gives you five commands for it, plus two configuration-driven alternatives that are preferred whenever they apply.

Command Effect Typical use

terraform state mv [options] SOURCE DESTINATION

Re-binds the remote object tracked at SOURCE to DESTINATION. Source and destination must be the same kind of thing (instance to instance, module to module), and a resource can only move to an address of the same type.

Renaming a resource or moving it into or out of a module without destroying it.

terraform state rm [options] ADDRESS...

Removes the binding. The remote object continues to exist but Terraform no longer manages it.

Handing an object over to another configuration, or abandoning management. A later plan will propose to create a new object for each forgotten instance.

terraform state replace-provider [options] FROM TO

Changes the provider address of all resources that use FROM, for example when moving to a mirror or fork, or from registry.terraform.io to another registry namespace.

Provider namespace changes and migrations between Terraform and OpenTofu registries.

terraform state pull

Reads the whole state, see above.

Reading, and the first step of a manual repair.

terraform state push [options] PATH

Uploads a local file as the state of the configured backend. PATH of - reads stdin.

Last-resort repair, restoration from a backup.

state mv

When a resource block is renamed in code, Terraform’s default interpretation is "delete the old object and create a new one at the new address". state mv overrides that by re-attaching the existing object to the new address first. The workflow is always the same: edit the code, run state mv, then run terraform plan and check that it proposes no changes.

# 1. The resource was renamed in code from cover_images to covers.
# 2. Move the binding. -dry-run reports what would move without changing anything.
terraform state mv -dry-run aws_s3_bucket.cover_images aws_s3_bucket.covers
terraform state mv         aws_s3_bucket.cover_images aws_s3_bucket.covers

# Move the resource into a module (the resource block now lives in module.covers).
terraform state mv aws_s3_bucket.covers module.covers.aws_s3_bucket.this

# Move one instance of a resource created with count.
terraform state mv 'aws_instance.worker[0]' 'aws_instance.job[0]'

# Move one instance of a resource created with for_each (Unix shells).
terraform state mv 'aws_instance.worker["reviews"]' 'aws_instance.job["reviews"]'

# Always verify: the plan must show no changes for the moved objects.
terraform plan

The documentation has one warning that matters for teams: if you use state mv for a refactor, you must "communicate carefully with your coworkers" so that nobody applies the configuration change between your code change and your state mv, since they would plan a destroy and a create. This coordination problem is the main reason moved blocks were introduced. A moved block is code, travels with the commit, and is applied by every user of the configuration on their next plan.

Options accepted by state mv include -dry-run, -lock=false, -lock-timeout=DURATION and, for the local backend, the legacy -state, -state-out, -backup and -backup-out. When a configuration uses a non-local backend, using -backup or -backup-out without -state is an error since Terraform 1.1, because those options operate on a local file only and were silently ignored before.

state rm

state rm forgets objects. It is useful when you want a different configuration, or a different tool, to own the object, or when you are removing something that was imported by mistake. It also has the sharpest edge in the family, the rule that after it a plan will try to create a new object for each instance that was forgotten. If the name of the object is unique in the remote system, that creation fails, which is the lucky case; the unlucky case is a second object created next to the first.

# What would be forgotten? Nothing is changed.
terraform state rm -dry-run 'module.legacy_cache'

# Forget one resource and everything inside one module.
terraform state rm 'aws_elasticache_cluster.sessions'
terraform state rm 'module.legacy_cache'

Since Terraform 1.7 the recommended way to do the same is a removed block with lifecycle { destroy = false }, which is planned and applied like any change and can therefore be reviewed. The docs for state rm themselves say that you can use removed blocks instead and that they let you review removals as part of the normal workflow. state rm remains for the cases in which editing configuration is not an option. See Refactoring: moved, removed and import.

state replace-provider

When a provider moves to another source address, existing resources in state still reference the old address and terraform init reports that the provider cannot be found for them. state replace-provider rewrites them. It always writes a backup first (backups cannot be disabled for it, "due to the destructive nature of this command") and asks for approval unless -auto-approve is given.

# Illustrative: move resources managed by hashicorp/aws to a private mirror namespace.
terraform state replace-provider hashicorp/aws registry.example.internal/acme/aws

The new source must also be reflected in required_providers and then in the lock file via terraform init. See Providers.

state pull and state push

pull and push are the two commands that touch the whole document, and they are the most dangerous in the family because they bypass every consistency check that normal operations enjoy. The official recommendation for push is that it be used "only when you must manually modify the remote state". The safe procedure is:

# 1. Make sure nobody else is working (use the locking and the team channel).
# 2. Download a copy and keep it safe, since it contains secrets.
terraform state pull > bookshelf-before.tfstate

# 3. Edit the copy. Increase "serial" by one: pushing a state whose serial is not
#    higher than the destination's is refused.
$EDITOR bookshelf-before.tfstate

# 4. Push it. The lineage and serial checks protect you; do NOT add -force.
terraform state push bookshelf-before.tfstate

# 5. Verify.
terraform plan

The checks that push performs are the differing lineage check and the higher remote serial check, described in the format section. -force disables both, "this is not recommended", and with them disabled the destination state is overwritten. Treat a push with -force as an emergency tool that is permitted only after a copy of the destination state has been saved. The same documentation notes that state files must be UTF-8 without a byte order mark, which matters in PowerShell, where redirecting output with > may produce another encoding; use Set-Content instead.

pull and push were popular for moving state to a remote backend in older tutorials. That is no longer how it is done: change the backend block and run terraform init -migrate-state, which copies the state, checks the lineage and leaves the local file as a record. This is described in Backends and Remote State.

The -backup flags

Every terraform state command that modifies state writes a backup, read-only commands write none, and "backups for state modification can not be disabled". With the local backend the backup path can be set with the legacy -backup option (and -backup-out for state mv); with a remote backend those options are only usable together with -state pointing at a local file. The local backend documentation mentions that -backup=- disables the creation of backup files altogether for its own legacy options, but the terraform state command reference says plainly that state-modifying commands always write one, so do not rely on turning it off. With remote state, backups are still written to local disk, so the timestamped backup files that appear next to the state after a state mv contain the same secrets as the state: delete them when they are no longer needed and never commit them.

Choosing between commands and blocks

Configuration-driven blocks have replaced most of what state mv, state rm and terraform import were used for. The rule of thumb is to prefer the block, because it is reviewed in the pull request, planned before it is applied, and applied identically by everyone.

Intent Preferred (code) Legacy (command)

Rename a resource or module, move it into a module, change count to for_each

moved block (Terraform 1.1, instance-level moves added later). Planned, reviewed, reusable by module consumers.

terraform state mv

Stop managing an object but keep it, or remove it from state and destroy it

removed block with lifecycle { destroy = false } or true (Terraform 1.7)

terraform state rm

Bring an existing object under management

import block (Terraform 1.5), by id or since 1.12 by identity, with optional -generate-config-out

terraform import

Change the provider source address

No block; terraform state replace-provider is the tool, after required_providers is updated.

terraform state replace-provider

Repair a damaged or inconsistent state

No block; state pull, edit, state push with care.

state pull / state push

The pair moved and removed is documented on Refactoring: moved, removed and import, and the import block and terraform query on Import and Query. What matters here is the model: all of them are ways to edit the bindings of the state, and they differ in whether the edit is declared in the configuration and planned or performed directly and immediately. A removed block with destroy = true is the only form of "remove from state" that also destroys the object, and OpenTofu supports the same block with the same meaning (see Terraform and OpenTofu differences).

The same state mv is also the technique for splitting a large state. The official refactoring guide recommends planning the split by volatility, by whether resources are stateful or stateless, and by team ownership, and documents two ways to migrate resources between state files: the pair state rm and import (which re-creates the binding in the destination), or state mv -state-out, which writes to a second local file. After the split, share values between the pieces with terraform_remote_state, a data source, or a store such as SSM or Key Vault. See Backends and Remote State.

Backups and recovery

Terraform protects its state in several overlapping ways. Understanding which one applies to your backend tells you what you can recover.

Mechanism What it gives you

terraform.tfstate.backup (local backend)

One file: the snapshot before the last write. It is overwritten on every save, so it protects against the last step only.

Backups from state commands

One file per state-modifying state command, kept on local disk even with remote state, because the docs state they cannot be disabled. Use them to undo the command.

Backend versioning

The strongest tool. S3 bucket versioning and Google Cloud Storage object versioning keep every previous version of the state object, which the documentation of both backends recommends enabling. Azure Blob Storage and HCP Terraform also keep earlier state versions; check the settings of your platform.

Plan files and CI artifacts

A saved plan contains the prior state. It is a source of recovery information and a source of secrets.

errored.tfstate

Terraform writes errored.tfstate when it cannot persist the final state after an apply. OpenTofu 1.13 also writes it on a Go runtime panic, as a partial state meant to aid recovery.

The recovery routine depends on what was lost.

  • The last state command was a mistake. Use the backup that the command wrote, and push it back with terraform state push (after checking the serial; the backup has a lower serial than the current state, so you may need to raise it). Run terraform plan to confirm.

  • The state object was overwritten or deleted in the backend. Restore the previous version through the backend’s versioning (S3 version restore, GCS object restore), then run terraform plan.

  • Someone applied from a stale copy. The plan shows objects as new that already exist. Do not apply: compare terraform state list with the backend’s history, restore, and re-plan.

  • There is no backup at all. Rebuild the state by importing every object, ideally with import blocks and -generate-config-out, and let terraform plan guide you until it is clean. It is slow and error-prone, which is why backend versioning is not optional for a team.

Recovering from a failed apply

An apply that dies halfway leaves a state that records what was done before the failure. Terraform saves state after every resource operation, so most of the work is recorded, and the plan after a failed apply will continue where it stopped. The cases that need attention:

  • The lock is still held. Use force-unlock after confirming the run is gone, as described above.

  • errored.tfstate exists. Terraform could not write the state to the backend, so it wrote the final state locally. Push it with terraform state push errored.tfstate, then delete it, because it holds secrets.

  • A resource was created but is missing from the state. If the provider reports a conflict on the next apply, import the object instead of deleting it.

  • A resource is tainted. Terraform marks an object that was created but failed provisioning, and replaces it on the next apply. In current versions prefer terraform apply -replace=ADDRESS to force a replacement deliberately.

Drift: when reality and state disagree

Drift is any difference between what the state records and what actually exists, introduced outside Terraform: an engineer changes a security group in the console during an incident, a scaling policy resizes a group, another automation modifies a tag. Terraform’s normal plan already reconciles drift in one direction: before computing changes it refreshes the state, reading every managed object, and then plans to bring reality back to the configuration. Drift therefore shows up as a proposed change, and it is overwritten if you apply.

That is not always what you want. Sometimes the manual change was intentional and the right move is to update the code or to accept the new value into the state without touching the object. For that, Terraform offers the refresh-only planning mode.

Command Behavior

terraform plan -refresh-only

Creates a plan whose only goal is to update the state and the root module outputs to match changes made to remote objects outside Terraform. It shows the drift and changes nothing.

terraform apply -refresh-only

Shows the same plan and, when you approve it, writes the detected values to the state. No remote object is changed. This is the documented replacement for the deprecated terraform refresh command.

terraform plan -refresh=false

Skips the refresh. Faster, but external changes are ignored, so the plan may be incomplete or wrong. Cannot be combined with refresh-only mode.

terraform plan -minimal-refresh

New in Terraform 1.17: refresh only the resource instances that the plan proposes to change. It saves API calls but ignores external changes to everything else. Cannot be used with refresh-only mode or with -refresh=false.

terraform refresh

Deprecated. Effectively terraform apply -refresh-only -auto-approve, so it updates the state without a prompt.

The reason refresh was deprecated is explicit in the documentation: with misconfigured provider credentials, Terraform "may be misled into thinking that all of the managed objects have been deleted", and an automatic refresh would then remove every tracked object from the state without any prompt. apply -refresh-only has the same effect on the state but with a confirmation, so you can notice that the whole infrastructure appears to be gone. The mode was introduced in Terraform 0.15.4.

A drift review for the Bookshelf covers bucket looks like the following. The output is illustrative, abbreviated from the format Terraform prints for refresh-only plans.

$ terraform plan -refresh-only

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:

  # aws_s3_bucket.covers has changed
  ~ resource "aws_s3_bucket" "covers" {
      ~ tags = {
          + "owner" = "support-team"
        }
        # (attributes abbreviated)
    }

This is a refresh-only plan, so Terraform will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the Terraform state without changing your
configuration.

Source: Terraform Docs — terraform plan (refresh-only mode); the console text is illustrative and abbreviated, not a verbatim copy.

You then have three choices: accept it (terraform apply -refresh-only), codify it (add owner to the code, so that the plan is clean), or reverse it (a normal terraform apply). The same decision, made on a schedule, is the heart of drift detection: HCP Terraform offers health assessments that run a refresh-only check on a schedule, and a pipeline can simply run terraform plan -refresh-only -detailed-exitcode nightly and alert on exit code 2. Exit code 2 of -detailed-exitcode means a diff exists, as with a normal plan; see CI/CD and Automation and HCP Terraform and Terraform Enterprise.

To avoid drift that you do not want to see, use lifecycle { ignore_changes = [...] } for attributes that another system legitimately owns, which is described in Lifecycle, Validation and Checks. A check block with a scoped data source is a way to be warned when an external condition stops being true.

Three other sources of confusion are commonly mistaken for drift:

  • Provider upgrades that change attribute schemas show as changes in the plan. The state is upgraded to the new schema version and schema_version increases.

  • Computed attributes that change on each read (timestamps, rotating tokens) produce a perpetual diff. Fix them in the provider, or with ignore_changes.

  • Conflicting automation, where two tools manage the same object. The state records one writer’s view, and the next plan from the other writer changes it back. The fix is ownership, not a state command.

The life of state during a run

The following diagram puts the pieces together for one terraform apply with a remote, locking backend: where the lock is taken, where the refresh happens, and when the state is written.

sequenceDiagram participant U as Engineer or CI participant T as Terraform core participant B as Backend (state and lock) participant P as Provider participant A as Cloud API U->>T: terraform apply T->>B: Acquire lock B-->>T: Lock granted or error T->>B: Read latest state snapshot T->>P: Refresh each managed object P->>A: Read current attributes A-->>P: Current values P-->>T: Refreshed values (drift becomes visible) T->>T: Build plan from configuration and state T->>U: Show plan, ask approval U-->>T: Approve loop For each change in dependency order T->>P: Create, update or delete P->>A: API call A-->>P: Result P-->>T: New attributes T->>B: Write new snapshot (serial increases) end T->>B: Release lock

Source: Terraform Docs — State and State Locking; the sequence is a simplified original drawing.

The sequence makes several practical rules visible. A plan that is saved and applied later (-out and then apply plan.tfplan) is rejected if the state has changed since the plan was made, because the plan records the state it was based on (Terraform reports a stale plan); that is the reason saved plans are the safe way to separate review from execution in a pipeline. The write happens after each object, not at the end, which is why a failed apply leaves a consistent partial result. And read-only commands such as plan also take the lock (except with -lock=false), because they may refresh and, in refresh-only mode, write.

What changed recently

Version Change

Terraform 0.15.4

-refresh-only for plan and apply; terraform refresh becomes an alias for the unsafe form.

Terraform 1.1

moved blocks. terraform state mv returns exit code 1 when a resource cannot be found, and errors on -backup / -backup-out without -state for non-local backends.

Terraform 1.2

terraform state commands check the required_version of the configuration before proceeding.

Terraform 1.5

import blocks, check blocks, and checks run during import.

Terraform 1.7

removed blocks (the configuration-driven replacement for state rm). Ignore a remote version mismatch in force-unlock.

Terraform 1.10

Ephemeral resources, variables and outputs keep values out of state and plan. The S3 backend gains native state locking with use_lockfile, announced as the future replacement of DynamoDB locking.

Terraform 1.11

Write-only arguments. S3 native state locking becomes generally available and the DynamoDB arguments are deprecated.

Terraform 1.12

Identity-based import blocks and resource identity in state; terraform state identities.

Terraform 1.15

terraform state show fails with exit code 1 when it cannot render the resource instead of exiting quietly.

Terraform 1.16

terraform state show -json; a panic when state show was given an attribute path was fixed.

Terraform 1.17

-minimal-refresh planning option; terraform version -json gains a format_version.

Deprecated and legacy items to know when you read older material: terraform refresh (use -refresh-only); the -state, -state-out and -backup options of the local backend (documented as legacy features preserved for compatibility; use the path argument of the backend, and note that the state-flag deprecation is tracked in the version table of the CLI page, CLI Essentials); the S3 backend arguments dynamodb_table and related (deprecated in favor of use_lockfile); and the use of terraform state rm and terraform import where a removed or import block can do the job.

Terraform and OpenTofu differences

State is the area where the two tools are most compatible and, since the fork, most different in one respect: protection of the file.

Aspect Terraform 1.17 OpenTofu 1.13

State file format

JSON, version 4.

Same format and compatible with Terraform’s; a state written by one can be read by the other within the compatibility window, subject to version checks. The local backend writes pretty-printed JSON since 1.12.

state subcommands

identities, list, mv, pull, push, replace-provider, rm, show.

list, mv, pull, push, replace-provider, rm, show. There is no state identities page in the OpenTofu documentation.

state show -json

Prints the resource as JSON (1.16).

-json enables the machine-readable UI output, a different meaning; for data use tofu show -json.

Encryption of state and plan

None. Rely on the backend and HCP Terraform.

Built-in encryption block (1.7) with key providers and AES-GCM.

Locking

Backend-specific; force-unlock LOCK_ID [-force].

Same command (tofu force-unlock).

Refresh-only

plan and apply -refresh-only; refresh deprecated; -minimal-refresh (1.17).

-refresh-only; tofu refresh deprecated for the same reason. No -minimal-refresh documented.

Crash recovery

errored.tfstate when state cannot be saved.

Also writes errored.tfstate on a Go runtime panic since 1.13.

Removing from state

removed block (1.7) with lifecycle { destroy }.

removed block as well; destroy = false was added to the lifecycle block in OpenTofu 1.12.

Where the books are outdated or wrong

The state chapters of the books covered by this section remain conceptually correct: the file is JSON, serial and lineage exist, drift is the difference between state and reality. What is out of date:

  • state pull and state push to migrate to a remote backend. This was the published way for a long time. Today the procedure is to add or change the backend block and run terraform init -migrate-state.

  • terraform refresh as a way to detect drift. It is deprecated; use terraform plan -refresh-only and terraform apply -refresh-only, which show the drift and ask for approval.

  • state mv and state rm as the refactoring tools. They still work but moved and removed blocks, and import blocks for the reverse, are now the preferred, reviewable form.

  • DynamoDB tables for S3 locking. Replaced by use_lockfile = true; the DynamoDB arguments are deprecated.

  • Static CI secrets and archiving terraform.tfstate as a build artifact. State holds secrets; use a remote backend with encryption and locking, and OIDC rather than stored keys.

  • Secrets in state treated as unavoidable. Ephemeral resources (1.10) and write-only arguments (1.11) keep many of them out of state altogether.

  • No mention of identities, state show -json, -minimal-refresh or OpenTofu encryption, which were all added after those books went to print.

Pitfalls

  • Editing terraform.tfstate in an editor, or committing it to Git.

  • Running terraform state push -force, or pushing without raising serial and checking the lineage.

  • Forgetting that state rm makes the next plan create a duplicate, not just stop managing.

  • Using -lock=false in CI "to stop lock errors", which turns the problem into silent corruption. Use -lock-timeout.

  • Force-unlocking a lock that belongs to a running pipeline.

  • Using state mv in a team without telling anyone, instead of a moved block.

  • Treating sensitive = true as protection of the state file. It is not.

  • Leaving the backup files created by state commands lying around, or uploading them with the build.

  • Applying terraform refresh (or apply -refresh-only -auto-approve) with broken credentials.

  • Importing the same object into two resource addresses, or into two different states.

  • Parsing the raw state file in tools instead of using terraform show -json and terraform output -json.

  • Sharing one state file between environments, so that a mistake in staging can destroy production.

  • Enabling OpenTofu encryption without a tested copy of the key and an unencrypted backup.

References