Initializing Enclave...

How to Fix Terraform State List Showing Resource But Plan Wants to Create It Again

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10–20 mins


TL;DR

  • What broke: Terraform's state backend has a record of the resource, but the logical address in your .tf config no longer matches the address stored in state — so the plan engine treats it as a brand-new resource and queues a + create.
  • How to fix it: Use terraform state mv to rename the state address to match the current config address, or add a moved {} block in HCL, then re-run plan to confirm a clean diff.
  • Shortcut: Use our Client-Side Sandbox above to paste your terraform state list output and your .tf resource block — it will auto-generate the exact state mv command or moved {} block you need.

The Incident (What Does the Error Mean?)

You run terraform state list and see:

aws_security_group.app_sg
aws_instance.web[0]
module.vpc.aws_subnet.private["us-east-1a"]

Then you run terraform plan and get:

  # aws_instance.web_server will be created
  + resource "aws_instance" "web_server" {
      + ami           = "ami-0c55b159cbfafe1f0"
      + instance_type = "t3.medium"
      ...
    }

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

The resource in state is aws_instance.web[0]. The resource in your config is aws_instance.web_server. Terraform does not fuzzy-match. It performs exact address lookups. No match → it schedules a create. The old address (web[0]) will simultaneously be scheduled for destroy if it's no longer referenced in config, giving you a destroy + create cycle instead of a no-op.


The Attack Vector / Blast Radius

This is not a benign drift warning. The blast radius is significant:

  1. Duplicate resource creation: If the + create runs before the orphaned state is cleaned, you now have two instances, two security groups, two RDS clusters — with associated cost and security exposure.
  2. Destroy of live infrastructure: If the old address (web[0]) is no longer in config, Terraform will queue a - destroy on the real, running resource that state still points to. Running terraform apply without catching this destroys production.
  3. State lock contention in CI/CD: Automated pipelines that run apply on merge will execute this plan without a human reviewing the diff, compounding the damage.
  4. Root causes that trigger this:
    • Renaming a resource block (webweb_server)
    • Removing count or for_each and switching to a singleton
    • Moving a resource into or out of a module
    • Switching Terraform workspaces without re-importing
    • Running terraform init against a different backend (state file is empty/different)
    • Manual state file edits that corrupted the address index

How to Fix It (The Solution)

Step 1: Confirm the Address Mismatch

# See exact addresses in state
terraform state list

# Inspect the specific state object
terraform state show 'aws_instance.web[0]'

# See what plan intends to create
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions[] == "create")'

Once you confirm aws_instance.web[0] (state) vs aws_instance.web_server (config), proceed.


Basic Fix: terraform state mv

terraform state mv 'aws_instance.web[0]' 'aws_instance.web_server'

This rewrites the state address in-place. No infrastructure is touched. Re-run terraform plan — it should now show No changes. Infrastructure is up-to-date.

For module moves:

terraform state mv 'aws_subnet.private' 'module.vpc.aws_subnet.private["us-east-1a"]'

Enterprise Best Practice: moved {} Block (Terraform ≥ 1.1)

The moved {} block is the auditable, version-controlled, peer-reviewable approach. It belongs in your .tf files and is tracked in Git.

- # Old config — resource was named "web" with count
- resource "aws_instance" "web" {
-   count         = 1
-   ami           = "ami-0c55b159cbfafe1f0"
-   instance_type = "t3.medium"
- }

+ # New config — renamed to web_server, no count
+ resource "aws_instance" "web_server" {
+   ami           = "ami-0c55b159cbfafe1f0"
+   instance_type = "t3.medium"
+ }
+
+ # Reconciliation block — tells Terraform about the rename
+ moved {
+   from = aws_instance.web[0]
+   to   = aws_instance.web_server
+ }

After terraform apply succeeds with no infrastructure changes, remove the moved {} block in a follow-up commit. Leaving it indefinitely causes plan warnings.


Edge Case: Wrong Workspace

# Check current workspace
terraform workspace show

# List all workspaces
terraform workspace list

# Switch to the correct one
terraform workspace select production

# Re-run plan
terraform plan

Each workspace has an isolated state file. If you created the resource in production but your shell is on default, state list on default will be empty and plan will want to create everything.


Edge Case: Backend Mismatch / Re-init Against Wrong Backend

- # terraform.tf — accidentally pointing to wrong bucket/prefix
- terraform {
-   backend "s3" {
-     bucket = "my-tf-state-dev"
-     key    = "prod/terraform.tfstate"
-     region = "us-east-1"
-   }
- }

+ # terraform.tf — correct bucket for this environment
+ terraform {
+   backend "s3" {
+     bucket = "my-tf-state-prod"
+     key    = "prod/terraform.tfstate"
+     region = "us-east-1"
+   }
+ }

After fixing the backend block: terraform init -reconfigure, then re-run terraform state list and terraform plan.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Enforce moved {} Blocks via OPA/Conftest

Add a policy that fails the pipeline if a resource address in the plan's resource_changes array has action ["create"] AND a matching resource ID already exists in the prior state snapshot:

# policy/no_surprise_creates.rego
package terraform.analysis

deny[msg] {
  rc := input.resource_changes[_]
  rc.change.actions == ["create"]
  rc.change.before != null  # before is populated = resource exists somewhere
  msg := sprintf("Resource %v is being created but prior state is non-null. Use 'moved {}' or 'state mv'.", [rc.address])
}

2. Run terraform plan -detailed-exitcode in CI

# .github/workflows/terraform.yml (excerpt)
- name: Terraform Plan
  run: |
    terraform plan -detailed-exitcode -out=tfplan
    # Exit code 0 = no changes, 1 = error, 2 = changes present
    # Fail pipeline if unexpected creates are detected
    terraform show -json tfplan | \
      jq -e '[.resource_changes[] | select(.change.actions[] == "create")] | length == 0' \
      || (echo "UNEXPECTED CREATE ACTIONS IN PLAN — check state address alignment" && exit 1)

3. Checkov Rule for State Drift

checkov -d . --check CKV_TF_1  # Enforces module versioning, reduces drift surface

Pair with Atlantis or Terraform Cloud run triggers so every PR generates a plan and blocks merge if the plan contains unreviewed create actions on already-tracked resource types.

4. Protect State Files

  • Enable state locking (DynamoDB for S3 backend, built-in for TFC/TFE).
  • Enable versioning on your S3 state bucket — gives you a rollback point if state mv goes wrong.
  • Never edit .tfstate files manually. Use terraform state subcommands exclusively.
- # No locking configured
- terraform {
-   backend "s3" {
-     bucket = "my-tf-state"
-     key    = "prod/terraform.tfstate"
-     region = "us-east-1"
-   }
- }

+ # Locking + encryption enforced
+ terraform {
+   backend "s3" {
+     bucket         = "my-tf-state"
+     key            = "prod/terraform.tfstate"
+     region         = "us-east-1"
+     dynamodb_table = "terraform-state-lock"
+     encrypt        = true
+   }
+ }

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →