Skip to content

Drift Detection and Import

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.

State is only a recorded snapshot of the real world at the moment of the last apply. Nothing stops someone from opening the Azure Portal and changing a resource directly — bumping a virtual machine’s tag, resizing a storage account’s tier, or opening a network security group rule by hand. Terraform has no way to know that happened until it looks again.

resource "azurerm_linux_virtual_machine" "web" {
name = "web-vm"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_B2s"
admin_username = "azureuser"
network_interface_ids = [
azurerm_network_interface.web.id,
]
tags = {
environment = "production"
}
}

Suppose a teammate edits that VM’s environment tag in the Azure Portal to "staging", 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 "production", because as far as Terraform is concerned the configuration is the source of truth and the portal edit is drift to be corrected.

Terminal window
terraform plan
# ~ resource "azurerm_linux_virtual_machine" "web" {
# ~ tags = {
# ~ "environment" = "staging" -> "production"
# }
# }

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:

Terminal window
terraform apply -refresh-only

This mode refreshes state from the real Azure resources and shows you what changed, then asks you to confirm writing those changes into terraform.tfstate — but it never modifies Azure itself. It is the right tool when the drift was intentional and you have decided the portal 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 the
last "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 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 any remote
objects.

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-exitcode
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
$ echo $?
0
$ terraform plan -detailed-exitcode
Plan: 1 to add, 0 to change, 0 to destroy.
$ echo $?
2
$ terraform plan -detailed-exitcode
Error: Reference to undeclared input variable
An input variable with the name "undefined_var" has not been declared.
$ echo $?
1

0 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 plan
local_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 azurerm_linux_virtual_machine.web renamed to azurerm_linux_virtual_machine.app_server, the same mechanism means the real VM keeps its disk, its private IP, and its uptime — no moved block means a real VM gets deallocated and a brand-new one provisioned in its place.

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 Azure but was never created by Terraform at all — maybe it predates your Terraform adoption, or someone clicked it into existence in the portal during an outage. terraform import is the CLI command that associates that real resource with a resource address in your configuration:

Terminal window
terraform import azurerm_linux_virtual_machine.web /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-resources/providers/Microsoft.Compute/virtualMachines/web-vm

Running that command only writes a state entry — it maps azurerm_linux_virtual_machine.web to the real resource ID and pulls in all of that VM’s current attributes. It does not write the matching resource "azurerm_linux_virtual_machine" "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.

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 = azurerm_linux_virtual_machine.web
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-resources/providers/Microsoft.Compute/virtualMachines/web-vm"
}

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.

Terminal window
terraform plan
# Terraform will perform the following actions:
#
# # azurerm_linux_virtual_machine.web will be imported
# resource "azurerm_linux_virtual_machine" "web" {
# id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-resources/providers/Microsoft.Compute/virtualMachines/web-vm"
# size = "Standard_B2s"
# ...
# }

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 azurerm example above is terraform validate-clean HCL syntax — proving a real plan for azurerm_linux_virtual_machine needs a real resource ID and an Azure subscription 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 plan
terraform_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 azurerm_linux_virtual_machine import against a config that already matches the VM’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 Azure:

Terminal window
# Move a resource to a new address in state, e.g. after refactoring into a module
terraform state mv azurerm_linux_virtual_machine.web module.compute.azurerm_linux_virtual_machine.web
# Stop tracking a resource in state, without destroying the real object
terraform state rm azurerm_linux_virtual_machine.web

terraform 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 Azure 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.b
Move "terraform_data.a" to "terraform_data.b"
Successfully moved 1 object(s).
$ terraform plan
No 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 Azure 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 Azure
removed {
from = azurerm_linux_virtual_machine.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 plan
Plan: 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 for
the following objects, but it will not delete them:
- terraform_data.existing
After applying this plan, Terraform will no longer manage these objects. You
will 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
  portal["Manual change in Azure Portal"] --> drift["Resource now disagrees with config"]
  drift -->|terraform plan| diff["Diff shown: drift detected"]
  existing["Existing untracked Azure resource"] -->|terraform import or import block| state["Resource added to state"]
  state -->|next plan| clean["plan shows no diff, if config matches"]
A manual portal edit is caught as drift by plan, while an existing untracked resource is brought in via import

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.

ChoiceBenefitCost
moved block over terraform state mvDeclarative, 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 CLIPlannable 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 rmReviewable 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 driftUpdates 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
  • “Renaming a resource in .tf and rerunning apply is safe as long as the attributes stay the same.” Proven above: without a moved block, Terraform has no way to know the new address refers to the same object — it reads as destroy-then-create unless told otherwise. For a VM, that is real deallocation and re-provisioning, not a harmless bookkeeping change.
  • “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 matching resource block is still hand-written; if it disagrees with the real object’s attributes, the next plan shows a diff exactly like drift does.
  • “A plan’s nonzero exit code always means something failed.” Proven above: -detailed-exitcode returns 2 for a legitimate pending change, distinct from 1 for a genuine error — a CI script that treats all nonzero exits as failures cannot tell “review this plan” from “this is broken.”
  • “-refresh-only drift always shows as a modified attribute.” Proven above with a real local_file example: content edited out-of-band showed as the resource “has been deleted,” because the provider’s id is a content hash and the specific version at that hash no longer exists — not every drift is a tidy in-place diff.

When reviewing generated drift/import/refactor code, check for:

  • A resource renamed in .tf with no accompanying moved block — proven above, this reads as destroy-then-create without one.
  • terraform import or terraform state mv/rm given as a one-off CLI instruction in a runbook with no mention of also needing a moved/import/removed block for teammates and CI to see the same effect automatically.
  • An import block or CLI import command with no adjacent resource block, 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 with if [ $? -ne 0 ] instead of -detailed-exitcode and explicitly handling 0/1/2 — proven above, plain nonzero-exit checks cannot distinguish a real error from a legitimate pending plan.
  • A removed block missing the lifecycle { destroy = false } (or with destroy = true where “leave it alone” was clearly intended) — the lifecycle argument 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 block
resource "azurerm_linux_virtual_machine" "app_server" {
name = "app-server-vm"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_B2s"
admin_username = "azureuser"
}
# (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 azurerm_linux_virtual_machine.web -> azurerm_linux_virtual_machine.app_server, that is a real VM being deallocated and deleted, and a new one provisioned in its place, with all the downtime, a new VM resource ID, and possibly a new private 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.

A teammate changes a tag on a virtual machine directly in the Azure Portal. What happens the next time someone runs terraform plan
Why does editing the file's content out-of-band show up as "has been deleted" rather than "has been modified"?
$ echo "listen=9090" > generated/app.conf
$ terraform plan -refresh-only
...
  # local_file.app_config has been deleted
  - resource "local_file" "app_config" {
      - content = <<-EOT
            listen=8080
        EOT -> null
What does exit code 2 mean here, and how should a CI script treat it differently from exit code 1?
$ terraform plan -detailed-exitcode
Plan: 1 to add, 0 to change, 0 to destroy.
$ echo $?
2
What real HCL block produced this exact output when a resource was renamed?
$ terraform plan
local_file.app_config: Refreshing state...
  # 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.
Why is the declarative import block often preferred over running the terraform import CLI command
What real HCL produced this output after terraform_data.existing was removed from the resource blocks?
$ terraform plan
Plan: 0 to add, 0 to change, 0 to destroy.

Warning: Some objects will no longer be managed by Terraform
...
 - terraform_data.existing