Drift Detection and Import
The idea in one sentence
Section titled “The idea in one sentence”Configuration drift is what happens when reality diverges from your Terraform config without Terraform’s knowledge, and terraform import (or the declarative import block) is how you bring an already-existing resource under Terraform’s management in the first place.
Configuration drift
Section titled “Configuration drift”Drift happens whenever someone or something changes a resource outside of Terraform. A common case on Google Cloud: an engineer opens the Console under time pressure and edits a Compute Engine instance’s labels directly, or resizes its machine type, without touching the Terraform configuration that supposedly manages that instance. Terraform’s recorded state now disagrees with the real object.
Nothing alerts you to this automatically. The next time someone runs:
terraform planTerraform refreshes its view of the real resource, compares it against both the configuration and the last recorded state, and surfaces the difference as a plan — typically an “in-place update” that would revert the manual change back to what the configuration says, since Terraform assumes the configuration is the source of truth.
Sometimes you want the opposite: acknowledge the manual change and just bring Terraform’s state back in sync with reality, without touching any actual infrastructure. That is what -refresh-only mode is for:
terraform apply -refresh-onlyThis updates only the state file’s recorded attributes to match what is really running in GCP right now. It does not create, update, or destroy any real resource — it is purely a reconciliation of Terraform’s memory with the world.
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. For google_compute_instance.web renamed to google_compute_instance.app_server, the same mechanism means the real instance keeps its disk, its internal IP, and its uptime — no moved block means a real VM gets deleted and a brand-new one created in its place, with all the downtime that implies.
Bringing existing resources under management with terraform import
Section titled “Bringing existing resources under management with terraform import”Plenty of GCP resources get created before Terraform ever exists for a project — a Compute Engine instance someone clicked through in the Console, a Cloud Storage bucket created by an early script. The CLI terraform import command associates one of those existing, real resources with a resource address already written in your configuration:
terraform import google_compute_instance.web projects/my-project-id/zones/us-central1-a/instances/web-vmThis is easy to get wrong in one important way: terraform import only populates the state entry. It does not write any HCL for you. You still have to hand-write a resource "google_compute_instance" "web" { ... } block yourself, with attributes close enough to the real object that the next plan does not show a huge diff. Get the config wrong and your very next apply might try to “fix” the resource to match a config that never actually described it.
The declarative import block
Section titled “The declarative import block”Terraform 1.5 introduced a second way to import that most teams now prefer for anything worth reviewing: the import block, written directly in a .tf file alongside your other configuration:
import { to = google_compute_instance.web id = "projects/my-project-id/zones/us-central1-a/instances/web-vm"}The advantage over the CLI command is entirely about review. A CLI terraform import run is an immediate side effect — it writes to state the moment you run it, with no preview step. An import block, by contrast, is just more configuration: it shows up in the output of terraform plan as a planned import, exactly like a planned create or update, so a teammate can review it in a pull request before anyone runs apply. Terraform can even generate a starting resource block for you from the real object’s attributes using terraform plan -generate-config-out=generated.tf, which you then review and clean up rather than writing from scratch. The GCP example above is terraform validate-clean HCL syntax — proving a real plan for google_compute_instance needs a real instance ID and a GCP project 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 google_compute_instance import against a config that already matches the instance’s actual attributes shows 0 to change).
Reorganizing state without touching infrastructure
Section titled “Reorganizing state without touching infrastructure”Two more terraform state subcommands round out this lesson, and both are the mirror image of each other:
# Rename or move a resource's address in state, e.g. after refactoring into a moduleterraform state mv google_compute_instance.web module.app.google_compute_instance.web
# Stop tracking a resource in state, without destroying the real objectterraform state rm google_compute_instance.webterraform state mv changes only the address Terraform uses to refer to a resource in state — useful when you restructure a configuration into modules and do not want Terraform to think it needs to destroy the old address and create a new one. 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 direct opposite of import: it deletes a resource’s entry from state so Terraform no longer manages it, but it has zero effect on the real GCP object, which keeps running exactly as it was, now simply unmanaged.
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 declarative version: the removed block
Section titled “The declarative version: the removed block”terraform state rm carries the same drawback the plain terraform import command does — it is an immediate, unreviewed CLI side effect with no plan step in front of it. Terraform 1.7 added the declarative counterpart, the removed block, which is to state rm what the import block is to terraform import:
removed { from = google_compute_instance.web
lifecycle { destroy = false }}Delete the resource block from your configuration, add this removed block in its place, and the next terraform plan shows the removal for review before anything happens. The lifecycle argument is the part that matters: destroy = false tells Terraform to forget the resource while leaving the real instance running, exactly like state rm. The default is destroy = true, which forgets the resource and destroys it — so omitting that lifecycle block silently turns a hand-off into a deletion. Once the apply succeeds you can delete the removed block as well; it has done its job. 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.
flowchart LR manual["Instance label edited in Console"] --> plan["terraform plan"] plan -->|"diff shown"| drift["Drift detected"] existing["Existing untracked GCP resource"] --> importBlock["import block or terraform import"] importBlock -->|"state entry created"| state["terraform.tfstate"]
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), and -generate-config-out can draft the HCL for you | Still requires reviewing (and usually cleaning up) the generated resource block — neither form is a substitute for understanding the resource |
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.google_projectcarries this even further: itsdeletion_policyargument defaults to"PREVENT"since provider 6.0, so an unintended destroy on a mis-refactoredgoogle_projectresource does not even succeed as a recreate — theapplyfails outright with “Cannot destroy project as deletion_policy is set to PREVENT.” - “
terraform import(or the import block) writes the resource’s HCL for you.” Theimportblock alone does not — it only populates the state entry.-generate-config-outgets closer (it drafts aresourceblock from the real object’s attributes), but the result is explicitly meant to be reviewed and cleaned up, not applied blindly. - “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; for agoogle_projectresource specifically, itsdeletion_policy = "PREVENT"default (provider 6.0+) turns that into a hardapplyfailure instead of a quiet recreate. 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;-generate-config-outdrafts HCL but it is still the author’s job to review it. - 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 "google_compute_instance" "app_server" {name = "app-server-vm"machine_type = "e2-medium"zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" }}}# (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 google_compute_instance.web -> google_compute_instance.app_server, that is a real Compute Engine instance being deleted and a new one created, with all the downtime, new instance ID, and possibly new internal/external IP 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.