Terraform Plan Wants to Recreate Your Production Database? Read This First
The plan says -/+ next to your RDS instance and one attribute ends in "forces replacement". Why it happens, how to undo it without data loss, and the CI gate that stops it reaching apply.
Key takeaways
- When terraform plan shows -/+ on a database, Terraform intends to destroy it and create a new empty one.
- Find the attribute marked forces replacement.
- Usually it's a changed immutable setting, a renamed resource address, or a provider upgrade.
- Revert the attribute or add a moved block, never apply, then add prevent_destroy and a CI check that fails on deletes.
On this page
Don't apply. A -/+ next to a database in terraform plan means "destroy and then create replacement": Terraform will delete your database and create a new, empty one with the same configuration. Somewhere in that resource's diff, one attribute is annotated # forces replacement, and that attribute is the whole story.
The good news is that this is almost always fixable without touching the database. Everything below applies to Terraform 1.x and OpenTofu, with AWS provider examples current as of October 2026. Here's how to find the cause and make the plan boring again.
Find the attribute that forces replacement
In a long plan the marker is easy to miss. Save the plan and list every resource Terraform intends to delete:
$ terraform plan -out=tfplan
$ terraform show -json tfplan \
| jq -r '.resource_changes[]
| select(.change.actions | index("delete"))
| "\(.address) \(.change.actions | join(","))"'
aws_db_instance.orders delete,create
Then look at that resource in the human-readable plan and search for forces replacement. The attribute it sits on tells you which of the causes below you're dealing with.
The four usual causes
| Cause | What the plan shows | Fix |
|---|---|---|
| Changed an immutable attribute | # forces replacement on e.g. identifier, storage_encrypted, kms_key_id, availability_zone | Revert, or plan a real migration |
| Renamed or moved the resource | One address destroyed, another created | moved block |
| Attribute drifted outside Terraform | Replacement on a field someone changed in the console | Revert in console, or ignore_changes |
| Provider upgrade changed behavior | Replacement after only a version bump | Read the changelog, pin, adjust config |
Immutable attributes. Some settings can't be changed on a live RDS instance, so the provider replaces the instance instead. Encrypting an unencrypted database is the classic case: setting storage_encrypted = true on an existing instance forces replacement. The real path for that change is snapshot, copy the snapshot with encryption, restore, and cut over, planned as a migration, not an apply.
Renames and refactors. Moving a resource into a module or renaming it changes its address. Terraform sees the old address as removed and the new one as added. A moved block tells Terraform it's the same object:
# main.tf
moved {
from = aws_db_instance.orders
to = module.database.aws_db_instance.this
}
Run the plan again; the delete should become a no-op or an in-place update. For larger reshuffles, our guide to Terraform state migration covers state mv and cross-state moves.
Drift. If someone changed the database in the console, Terraform plans to put it back, and for some attributes "back" means replace. Decide which side is right. If the console change should stay, update the code to match. If a field is managed elsewhere on purpose, lifecycle { ignore_changes = [...] } tells Terraform to stop proposing updates to it. Our Terraform drift guide walks through deciding which side wins.
If someone already applied it
Then the old instance is being deleted, and what you can recover depends on two settings in your configuration. With skip_final_snapshot = false and a final_snapshot_identifier, RDS takes a final snapshot before deletion, and you restore from it. If delete_automated_backups was set to false, the automated backups are retained and you can restore to a point in time within the retention period. Restore to a new instance, verify the data, then point the application at it.
Check both settings today, before you need them. A skip_final_snapshot = true added to make a test environment easier to tear down has a way of being copied into production modules. AWS also documents that support may be able to recover an unintentionally deleted instance for up to six days, so open a case immediately.
Make it impossible to apply by accident
Once the plan is clean, add guardrails so the next one can't slip through:
resource "aws_db_instance" "orders" {
# ...
deletion_protection = true
lifecycle {
prevent_destroy = true
}
}
prevent_destroy makes Terraform reject any plan that would destroy the resource. Know its limit: Terraform's documentation says it doesn't stop destruction if the resource's configuration is removed entirely. deletion_protection covers that gap at the AWS level, because the API refuses to delete a protected instance.
Then gate CI. Fail the pipeline when a plan deletes anything stateful, and require an explicit, reviewed override:
# ci/check-plan.sh: fail on any planned delete of a database or bucket
$ terraform show -json tfplan | jq -e '
[.resource_changes[]
| select(.change.actions | index("delete"))
| select(.type | test("aws_db_instance|aws_rds_cluster|aws_s3_bucket"))]
| length == 0' > /dev/null || { echo "Plan deletes stateful resources"; exit 1; }
Questions people ask
What does "forces replacement" mean in a Terraform plan?
It means the attribute changed can't be updated on the existing object, so Terraform will destroy the resource and create a new one, shown as -/+. For a database, that means a new, empty instance unless it's restored from a snapshot. Find the attribute marked # forces replacement and revert it or plan a real migration.
How do I rename a Terraform resource without destroying it?
Add a moved block with the old and new addresses, then run terraform plan. Terraform updates the state to the new address instead of destroying and recreating the object. For older Terraform versions, terraform state mv does the same in state directly.
Does prevent_destroy stop all deletions?
No. prevent_destroy = true makes Terraform reject plans that destroy the resource while the setting is in your configuration. If someone deletes the resource block itself, the protection goes with it. Use the provider's deletion protection, such as deletion_protection on RDS, as a second layer.
The decision, concretely
- Plan shows
-/+on a database right now? Don't apply. Findforces replacementand revert that change. - You renamed or moved the resource into a module? Add a
movedblock. - The change is intentional, like enabling encryption? Plan a snapshot-restore migration with a cutover window.
- No guardrails today? Add
prevent_destroy,deletion_protectionand the CI gate before the next refactor.
The call we'd make
Treat any -/+ on a stateful resource as a stop-the-line event in review. Nearly every case we've seen came from a refactor or a version bump, not an intentional change, and a moved block or a revert fixed it in minutes. The guardrails cost ten lines of code and turn a potential data-loss incident into a failed CI job.
Sources
More from Infrastructure
Explore more articles in this category
How to Stop Anyone From Deleting Your Production Database
GitLab lost six hours of data to one command on the wrong host. Linear lost five hours to one TRUNCATE CASCADE in a reviewed migration. The six guardrails that make that kind of mistake hard to make and cheap to recover from.
Kubelet Won't Start After Upgrading to Kubernetes 1.35? It's cgroup v1
Since Kubernetes 1.35 the kubelet refuses to start on cgroup v1 nodes by default. How to check every node in one command, the safe migration, and the temporary override with an expiry date.
AWS EU-SOUTH-2 Packet Loss: The Single-AZ Lesson Nobody Learned
A single Availability Zone in AWS's Spain region degraded for roughly 2.5 hours, and most "multi-AZ" architectures would have failed anyway.
You might have missed
Evergreen posts worth revisiting.