Drift Detection and Import
The idea in one sentence
Section titled “The idea in one sentence”Configuration drift happens when reality changes outside Terraform, and import is how you bring a real resource that already exists into Terraform’s state without recreating it.
Detecting drift
Section titled “Detecting drift”State is only a recorded snapshot of the real world at the moment of the last apply. Nothing stops someone from opening the AWS Console and changing a resource directly — bumping an EC2 instance’s tag, resizing an RDS instance, or opening a security group port by hand. Terraform has no way to know that happened until it looks again.
resource "aws_instance" "web" { ami = "ami-0abcdef1234567890" instance_type = "t3.micro"
tags = { Name = "web-server" }}Suppose a teammate edits that instance’s Name tag in the console to "web-server-old", outside of any Terraform run. The next time anyone runs terraform plan, Terraform refreshes its view of the real resource, notices the tag no longer matches the configuration, and shows a diff — it proposes changing the tag back to "web-server", because as far as Terraform is concerned the configuration is the source of truth and the console edit is drift to be corrected.
terraform plan
# ~ resource "aws_instance" "web" {# ~ tags = {# ~ "Name" = "web-server-old" -> "web-server"# }# }That is the normal, expected way plan surfaces drift — as a proposed change that will overwrite reality with the configuration the next time you apply. Sometimes you want the opposite: you want Terraform to just update its own records to match reality, without touching any actual infrastructure. That is what -refresh-only is for:
terraform apply -refresh-onlyThis mode refreshes state from the real AWS resources and shows you what changed, then asks you to confirm writing those changes into terraform.tfstate — but it never modifies AWS itself. It is the right tool when the drift was intentional and you have decided the console change should win, or when you need to update your mental model of what state currently holds before deciding whether to fix the configuration or accept reality.
Real -refresh-only output, proven with no cloud credentials at all — a local_file resource edited out-of-band (its content changed by a plain shell command, not by Terraform), then refreshed:
$ echo "listen=9090" > generated/app.conf$ terraform plan -refresh-only
Note: Objects have changed outside of Terraform
Terraform detected the following changes made outside of Terraform since thelast "terraform apply" which may have affected this plan:
# local_file.app_config has been deleted - resource "local_file" "app_config" { - content = <<-EOT listen=8080 EOT -> null - id = "5d54098470458257525a27f6f0b6de89e278569d" -> null }
This is a refresh-only plan, so Terraform will not take any actions to undothese. If you were expecting these changes then you can apply this plan torecord the updated values in the Terraform state without changing any remoteobjects.Notice the local_file provider reports this as the resource being deleted, not modified — its id is a content hash, so editing the file’s bytes out-of-band means the object at the recorded id genuinely no longer exists as far as the provider’s read logic is concerned, even though a file still exists at that path. This is a real, provider-specific nuance worth knowing when reading -refresh-only output: “deleted” does not always mean “gone from disk,” it can mean “the specific version this resource represents no longer matches.”
-detailed-exitcode: turning drift into a script-friendly signal
Section titled “-detailed-exitcode: turning drift into a script-friendly signal”terraform plan -detailed-exitcode is what CI pipelines use to distinguish “nothing to do” from “there’s a plan to review” from “something actually broke,” instead of parsing plan output text. Three real exit codes, proven:
$ terraform plan -detailed-exitcodeTerraform has compared your real infrastructure against your configurationand found no differences, so no changes are needed.$ echo $?0$ terraform plan -detailed-exitcodePlan: 1 to add, 0 to change, 0 to destroy.$ echo $?2$ terraform plan -detailed-exitcodeError: Reference to undeclared input variable
An input variable with the name "undefined_var" has not been declared.$ echo $?10 means “no changes, config matches reality,” 2 means “there is a real plan with changes” (not an error — CI should treat this as “needs review/apply,” not “failed”), and 1 means an actual error occurred, such as invalid configuration. A pipeline that only checks “exit code nonzero = fail” cannot tell a legitimate pending change (2) from a broken configuration (1) — treating them the same either blocks every ordinary plan or silently ignores real errors.
moved: renaming a resource without destroying and recreating it
Section titled “moved: renaming a resource without destroying and recreating it”Renaming a resource in your .tf file — local_file.config to local_file.app_config, or moving something into a module — looks to Terraform like “the old address is gone, there’s a new address” unless you tell it otherwise. Left alone, that reads as destroy-then-create. A moved block tells Terraform these are the same real object under a new address:
moved { from = local_file.config to = local_file.app_config}
resource "local_file" "app_config" { filename = "${path.module}/generated/app.conf" content = "listen=8080\n"}Real plan output proving no destroy/recreate happens, on a resource that already existed in state under the old name:
$ terraform planlocal_file.app_config: Refreshing state... [id=5d54098470458257525a27f6f0b6de89e278569d]
Terraform will perform the following actions:
# local_file.config has moved to local_file.app_config resource "local_file" "app_config" { id = "5d54098470458257525a27f6f0b6de89e278569d" # (10 unchanged attributes hidden) }
Plan: 0 to add, 0 to change, 0 to destroy.# local_file.config has moved to local_file.app_config with a Plan: 0 to add, 0 to change, 0 to destroy is the proof: the real object was never touched, only its address in state changed. moved blocks were introduced in Terraform 1.1 specifically to make refactors like this safe and reviewable. Like import and removed, a moved block is configuration — it ships in a pull request, applies identically everywhere, and can (and should) be deleted once every environment has applied it at least once.
Bringing existing resources under management with import
Section titled “Bringing existing resources under management with import”Drift assumes Terraform was already managing the resource. A different, related problem is a resource that exists in AWS but was never created by Terraform at all — maybe it predates your Terraform adoption, or someone clicked it into existence in the console during an outage. terraform import is the CLI command that associates that real resource with a resource address in your configuration:
terraform import aws_instance.web i-0123456789abcdef0Running that command only writes a state entry — it maps aws_instance.web to the real instance ID and pulls in all of that instance’s current attributes. It does not write the matching resource "aws_instance" "web" { ... } block for you. You still have to hand-write HCL that matches the real resource’s attributes closely enough that the next plan comes back clean. If your configuration disagrees with reality after importing, plan will show the same kind of diff drift produces, and you will need to adjust your HCL — not the real resource — until they line up.
The declarative import block
Section titled “The declarative import block”Terraform 1.5 introduced a second way to import, written directly into your .tf files as a first-class block instead of a one-off CLI invocation:
import { to = aws_instance.web id = "i-0123456789abcdef0"}This does the same underlying job as terraform import, but with an important difference in workflow. The CLI command runs immediately and mutates state the moment you invoke it — there is no preview, and nothing to review before it happens. The import block, by contrast, is just configuration: it shows up as a planned action the next time you run terraform plan, alongside any other changes, before anything actually happens.
terraform plan
# Terraform will perform the following actions:## # aws_instance.web will be imported# resource "aws_instance" "web" {# id = "i-0123456789abcdef0"# instance_type = "t3.micro"# ...# }That makes the import block the preferred approach wherever it is available: it is plannable, reviewable in a pull request like any other configuration change, and repeatable across environments, rather than being an immediate side effect that someone has to remember to run by hand on every machine that needs it. You still need to write the matching resource block yourself either way — the block only tells Terraform which real object to associate with which address. The AWS example above is terraform validate-clean HCL syntax — proving a real plan for aws_instance needs a real instance ID and AWS credentials this course does not have. What follows is the same mechanism proven end-to-end with no cloud credentials, using the built-in terraform_data resource, whose id you can set to anything at import time:
import { to = terraform_data.existing id = "my-existing-id-123"}
resource "terraform_data" "existing" { input = "v1"}Real plan output:
$ terraform planterraform_data.existing: Preparing import... [id=my-existing-id-123]terraform_data.existing: Refreshing state... [id=my-existing-id-123]
Terraform will perform the following actions:
# terraform_data.existing will be updated in-place # (imported from "my-existing-id-123") ~ resource "terraform_data" "existing" { id = "my-existing-id-123" + input = "v1" + output = (known after apply) }
Plan: 1 to import, 0 to add, 1 to change, 0 to destroy.Plan: 1 to import, ... 1 to change is the real, exact signal an import block produces — the 1 to change here is because the configuration’s input = "v1" did not previously exist on the object being imported (terraform_data has no real external “read,” so this specific example always shows a change; a real aws_instance import against a config that already matches the instance’s actual attributes shows 0 to change).
Reorganizing state without touching real infrastructure
Section titled “Reorganizing state without touching real infrastructure”Two more commands round out the state-editing toolkit, both of which change only Terraform’s bookkeeping and never touch AWS:
# Move a resource to a new address in state, e.g. after refactoring into a moduleterraform state mv aws_instance.web module.compute.aws_instance.web
# Stop tracking a resource in state, without destroying the real objectterraform state rm aws_instance.webterraform state mv is what you reach for when you restructure your configuration — say, moving a resource into a module — and do not want Terraform to see that as “destroy the old one, create a new one.” It updates the address in state to match the new location in your HCL, and the real AWS resource is never touched. Real output, renaming terraform_data.a to terraform_data.b after the .tf file itself was already edited:
$ terraform state mv terraform_data.a terraform_data.bMove "terraform_data.a" to "terraform_data.b"Successfully moved 1 object(s).$ terraform planNo changes. Your infrastructure matches the configuration.terraform state rm is the opposite direction of import: it deletes a resource’s entry from state so Terraform forgets about it entirely, but the real object in AWS keeps running exactly as it was. That is useful when you want to hand a resource off to a different Terraform configuration, or deliberately stop managing something without deleting it.
Both have the same drawback the CLI import command had: they happen immediately, on one machine, with nothing in the repository recording that they happened. state mv versus the moved block proven earlier is the same trade-off exactly: state mv is imperative and instant — you type the command once, on your own machine, and if a teammate does not run the identical command on theirs, their plan sees a destroy-then-create the next time they refactor the same way. moved is declarative and lives in the .tf file itself, so every clone of the repository gets the same rename automatically on its next plan, with no separate command anyone has to remember to run. The removed block is the declarative counterpart to terraform state rm, in the same way the import block is to terraform import:
# Stop managing this resource, but leave the real object running in AWSremoved { from = aws_instance.web
lifecycle { destroy = false }}Delete the resource block and add this alongside it. The lifecycle argument is required and is the whole point: destroy = false means “forget it, leave it alone,” while destroy = true means “forget it and delete the real object.” Real output proving the destroy = false path — a terraform_data.existing resource previously under management, removed from config with a removed block instead of a plain deletion:
$ terraform planPlan: 0 to add, 0 to change, 0 to destroy.
Warning: Some objects will no longer be managed by Terraform
If you apply this plan, Terraform will discard its tracking information forthe following objects, but it will not delete them: - terraform_data.existing
After applying this plan, Terraform will no longer manage these objects. Youwill need to import them into Terraform to manage them again.Plan: 0 to add, 0 to change, 0 to destroy alongside that warning is the proof: nothing was destroyed, Terraform simply stopped tracking the object, exactly as destroy = false promises. Because it is configuration, the change shows up in terraform plan, gets reviewed in a pull request, and applies identically on every machine and every CI run — instead of relying on each person to remember the right CLI incantation. Once the apply has gone through everywhere, the removed block has done its job and can be deleted.
flowchart LR console["Manual change in AWS Console"] --> drift["Resource now disagrees with config"] drift -->|terraform plan| diff["Diff shown: drift detected"] existing["Existing untracked AWS resource"] -->|terraform import or import block| state["Resource added to state"] state -->|next plan| clean["plan shows no diff, if config matches"]
Mental Model
Section titled “Mental Model”Every block in this lesson exists to give Terraform a way to change its bookkeeping about a resource without changing the resource itself — a rename (moved), an adoption (import), a release (removed), or a correction (-refresh-only). The common thread is: state is a claim about reality, and these are the tools for correcting the claim when it is the claim that is wrong, not the reality. -detailed-exitcode exists because “there is a plan” and “something is broken” are different facts a script needs to tell apart, and text-parsing a human-oriented plan output is the wrong way to do that.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
moved block over terraform state mv | Declarative, ships in the repo, applies identically on every clone (proven above: Plan: 0 to add, 0 to change, 0 to destroy) | An extra block to remember to delete once every environment has applied it |
import block over terraform import CLI | Plannable and reviewable before anything happens (proven above: real Plan: 1 to import, ... output) | Still requires hand-writing the matching resource block — neither form generates HCL for you |
removed { lifecycle { destroy = false } } over terraform state rm | Reviewable in a PR, applies identically everywhere (proven above: real warning + 0 to destroy) | One more block type to recognize during review — easy to misread as “this resource is being destroyed” if you don’t know the syntax |
-refresh-only before deciding what to do about drift | Updates your mental model and the recorded state without ever touching real infrastructure (proven above) | Does not fix anything by itself — it is a diagnostic step, not a remediation |
Common Misconceptions
Section titled “Common Misconceptions”- “Renaming a resource in .tf and rerunning apply is safe as long as the attributes stay the same.” Proven above: without a
movedblock, Terraform has no way to know the new address refers to the same object — it reads as destroy-then-create unless told otherwise. - “
terraform import(or the import block) writes the resource’s HCL for you.” Proven above: both forms only populate the state entry (or plan to). The matchingresourceblock is still hand-written; if it disagrees with the real object’s attributes, the nextplanshows a diff exactly like drift does. - “A plan’s nonzero exit code always means something failed.” Proven above:
-detailed-exitcodereturns2for a legitimate pending change, distinct from1for a genuine error — a CI script that treats all nonzero exits as failures cannot tell “review this plan” from “this is broken.” - “
-refresh-onlydrift always shows as a modified attribute.” Proven above with a reallocal_fileexample: content edited out-of-band showed as the resource “has been deleted,” because the provider’sidis a content hash and the specific version at that hash no longer exists — not every drift is a tidy in-place diff.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated drift/import/refactor code, check for:
- A resource renamed in
.tfwith no accompanyingmovedblock — proven above, this reads as destroy-then-create without one. terraform importorterraform state mv/rmgiven as a one-off CLI instruction in a runbook with no mention of also needing amoved/import/removedblock for teammates and CI to see the same effect automatically.- An
importblock or CLI import command with no adjacentresourceblock, or a comment implying the import “sets up the configuration” — proven above, only the state entry is populated; the HCL is still the author’s job. - A CI script or runbook checking
terraform plan’s exit code withif [ $? -ne 0 ]instead of-detailed-exitcodeand explicitly handling0/1/2— proven above, plain nonzero-exit checks cannot distinguish a real error from a legitimate pending plan. - A
removedblock missing thelifecycle { destroy = false }(or withdestroy = truewhere “leave it alone” was clearly intended) — thelifecycleargument is required and is the entire point of the block; getting it backwards deletes real infrastructure instead of releasing it.
🐛 Spot the bug
# Refactor: renamed the resource, forgot the moved blockresource "aws_instance" "app_server" {ami = "ami-0abcdef1234567890"instance_type = "t3.micro"}# (previously this block was named "web")Show the answer
Proven above with a real plan (local_file.config -> local_file.app_config): renaming a resource block with no moved { from = ... to = ... } alongside it does not read as a rename to Terraform at all — it reads as the old address disappearing and a brand-new address appearing, which plans as destroy the old object, create a new one. For aws_instance.web -> aws_instance.app_server, that is a real EC2 instance being terminated and a new one launched, with all the downtime, new instance ID, and possibly new IP address that implies — for a change that was, in the author’s intent, just a rename. The fix is a moved block added in the same commit as the rename, proven above to produce Plan: 0 to add, 0 to change, 0 to destroy with a has moved to note instead.