Resolve Drift After Importing Infrastructure into CDKTF
The import succeeded, the state entry exists, and the first plan proposes eleven changes to a resource you have not touched. This is the normal outcome, not a mistake — a provider importer records every attribute it can read from the live object, while your Python declares only the handful you typed, and every attribute in the gap between those two sets shows up as a proposed change. The work now is triage: deciding, attribute by attribute, which side is wrong. This guide belongs to importing existing infrastructure into CDKTF within CDKTF workflows and Terraform synthesis, and it is specifically about the post-import plan; the routine practice of watching for drift on infrastructure you already own is covered in detecting and remediating state drift in Python IaC.
Context
Nothing about a fresh import is symmetric. On one side sits a live object with two hundred attributes, most of them set by the provider's own defaults or by the cloud API at creation time. On the other sits a construct with eight arguments. Terraform compares them and reports the difference as work to do, which is technically accurate and practically useless until you know which differences represent a real disagreement.
Four classes account for essentially every line of a post-import diff, and they call for four different responses.
Provider defaults the live object never set. The provider's schema declares a default for an optional argument; the object was created years ago by a console click that never set it. Neither side is wrong, but the plan wants to write the default onto the object. These are the most common entries and usually the least dangerous.
Computed attributes. Values the API assigns — ARNs, IDs, creation timestamps, generated DNS names. They belong to the provider, and if one of them appears as (known after apply) on an imported resource, something upstream in your configuration is being replaced and dragging this attribute with it.
Out-of-band changes. Somebody adjusted the object through the console or a script after the last refresh, so state and reality disagree independently of your code. Terraform reports these under a separate heading — Note: Objects have changed outside of Terraform — and they are the only class where the code may be entirely correct.
Genuine construct mistakes. You typed the wrong instance class, listed subnets in a different order, or modelled a bucket's versioning as an inline argument when the provider moved it into a separate resource three major versions ago. These are the entries worth finding quickly, because they are the ones that can propose a replacement.
Prerequisites
- The resource already attached to a synthesized address, with a state entry — the adoption steps themselves are the parent topic's material.
terraform1.5 or newer andjq, since the whole triage runs off the plan's JSON form rather than its terminal rendering.prevent_destroyalready set on anything stateful. Read a post-import plan with the safety on, not off.- Read access to the cloud API with the same credentials the provider uses, so you can check the live object yourself when the plan and your memory disagree.
# CLI: capture the plan once, then work from the file rather than re-planning repeatedly
cdktf synth
terraform -chdir=cdktf.out/stacks/prod-data init -input=false
terraform -chdir=cdktf.out/stacks/prod-data plan -out=post-import.tfplan -detailed-exitcode
# exit 2 means changes are pending — expected on the first plan after an import
Implementation
Step 1 — Reduce the plan to a list of disagreeing attributes
The human-readable plan is designed for reviewing changes you intended. For triage you want the opposite: a flat list of address.attribute, with the before value and the after value side by side, sorted so that replacements come first. That list is derivable from terraform show -json, whose resource_changes array carries before, after, after_unknown, and replace_paths for every resource.
# CLI: the ten-second version — which resources are changing and how badly
terraform -chdir=cdktf.out/stacks/prod-data show -json post-import.tfplan \
| jq -r '.resource_changes[]
| select(.change.actions != ["no-op"])
| "\(.change.actions | join("+"))\t\(.address)"' \
| sort
For anything beyond a couple of resources, do the attribute-level comparison in Python. It is a small amount of code and it turns a wall of terminal output into something you can sort, count, and check into the repository as a triage record.
# plan_diff.py — flatten a saved plan into one row per disagreeing attribute
# CLI: terraform -chdir=cdktf.out/stacks/prod-data show -json post-import.tfplan | python3 plan_diff.py
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Optional
@dataclass(frozen=True)
class AttributeDiff:
address: str
attribute: str
before: Any
after: Any
computed: bool # provider will decide this value at apply time
forces_replace: bool # this attribute appears in replace_paths
def _replace_attrs(change: Dict[str, Any]) -> List[str]:
return [str(path[0]) for path in change.get("replace_paths", []) or [] if path]
def walk(plan: Dict[str, Any]) -> Iterator[AttributeDiff]:
for rc in plan.get("resource_changes", []):
change = rc["change"]
if change["actions"] == ["no-op"] or change["actions"] == ["read"]:
continue
before: Dict[str, Any] = change.get("before") or {}
after: Dict[str, Any] = change.get("after") or {}
unknown: Dict[str, Any] = change.get("after_unknown") or {}
replaced = set(_replace_attrs(change))
for key in sorted(set(before) | set(after) | set(unknown)):
old, new = before.get(key), after.get(key)
computed = bool(unknown.get(key))
if old == new and not computed:
continue
yield AttributeDiff(rc["address"], key, old, new, computed, key in replaced)
def main() -> int:
plan: Dict[str, Any] = json.load(sys.stdin)
rows: List[AttributeDiff] = sorted(
walk(plan), key=lambda d: (not d.forces_replace, d.address, d.attribute)
)
for d in rows:
flag = "REPLACE" if d.forces_replace else ("COMPUTED" if d.computed else "update")
print(f"{flag:9s} {d.address}.{d.attribute}: {d.before!r} -> {d.after!r}")
print(f"\n{len(rows)} attribute(s) disagree", file=sys.stderr)
# State implication: this reads a saved plan file only. It never contacts the cloud
# and never writes state, so it is safe to run against a production plan.
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run it and read the REPLACE rows first. On an imported production resource, a single replacement row outranks fifty update rows — it means an identity-defining argument in your construct does not match the object, and applying would destroy something that already exists.
Step 2 — Classify every row before changing anything
Two extra plans separate the classes cheaply, and running them before editing any Python saves a round of guessing.
# CLI: is the disagreement between config and state, or between state and the cloud?
terraform -chdir=cdktf.out/stacks/prod-data plan -refresh=false -out=noref.tfplan
# ^ config vs state only: everything here is your code or a provider default
terraform -chdir=cdktf.out/stacks/prod-data plan -refresh-only -detailed-exitcode
# ^ state vs live object only: anything here changed outside Terraform
A row that appears in the -refresh=false plan but not in the refresh-only plan is a code-versus-default question. A row that shows up only in the refresh-only plan is an out-of-band change and has nothing to do with your import. Rows in both are usually a mistake in the construct that also happens to touch an attribute somebody edited by hand.
From there the tells are specific. before is null or "" and after is a plausible-looking value the provider documents as a default: a provider default. computed is true and the row is on an attribute you never set: leave it alone unless something is being replaced. before holds a real value and after holds a different real value that matches nothing in your Python: a console change. before and after are both meaningful and after is what your construct says: a construct mistake, and the question becomes which of the two is right.
# classify.py — turn the flattened rows into a triage record you can review
# CLI: python3 classify.py post-import.json > triage.tsv
from __future__ import annotations
import json
import sys
from enum import Enum
from typing import Any, Dict, List
from plan_diff import AttributeDiff, walk
class DiffClass(str, Enum):
PROVIDER_DEFAULT = "provider-default"
COMPUTED = "computed"
OUT_OF_BAND = "out-of-band"
CONSTRUCT_MISTAKE = "construct-mistake"
def classify(d: AttributeDiff, external: set[str]) -> DiffClass:
if d.computed:
return DiffClass.COMPUTED
if f"{d.address}.{d.attribute}" in external:
return DiffClass.OUT_OF_BAND
if d.before in (None, "", [], {}):
return DiffClass.PROVIDER_DEFAULT
return DiffClass.CONSTRUCT_MISTAKE
def main(path: str) -> int:
plan: Dict[str, Any] = json.loads(open(path).read())
external: set[str] = set() # fill from the refresh-only plan, same flattening
rows: List[AttributeDiff] = list(walk(plan))
for d in rows:
print(f"{classify(d, external).value}\t{d.address}\t{d.attribute}\t{d.before!r}\t{d.after!r}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1]))
The classification is a judgement, and writing it down is the point. A triage file checked in beside the stack tells the next reviewer why root_block_device.encrypted was accepted as a default and why instance_type was treated as a typo, six months after everyone has forgotten.
Step 3 — Choose a direction for each row: code, reality, or suppression
There are exactly three moves, and the third is the expensive one.
Change the code to match reality whenever the live object is correct. This is the default and it is the only move that leaves the repository describing what actually exists. Copy the value out of terraform state show, add the argument to the construct, re-plan. Most provider-default rows and every construct mistake end here.
Change reality to match the code when the live object is the one that is wrong — a console change nobody authorised, a security group opened for a debugging session in 2023, an instance someone resized by hand. Applying the plan is the remediation in that case, and it is a real change to production, so it belongs in its own change with its own review rather than being smuggled through as part of an adoption.
Suppress the row with ignore_changes only when the attribute has a legitimate external owner: an autoscaler writing desired_count, a deployment pipeline rewriting a container image tag, a service that rotates its own secret. CDKTF expresses this through the same lifecycle object that carries prevent_destroy.
# service.py — suppression is a statement about ownership, not a way to quiet a plan
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod-data plan -detailed-exitcode
from constructs import Construct
from cdktf import TerraformResourceLifecycle
from cdktf_cdktf_provider_aws.ecs_service import EcsService
api: EcsService = EcsService(
scope,
"api",
name="acme-prod-api",
cluster=platform_cluster.arn,
task_definition=task_def.arn,
desired_count=6,
lifecycle=TerraformResourceLifecycle(
# Owned by the application autoscaling policy, which writes this every few minutes.
ignore_changes=["desired_count"],
prevent_destroy=True,
),
)
# State implication: Terraform keeps refreshing desired_count into state but stops
# proposing to change it — the value in the code is now decorative, not enforced.
The cost is worth stating plainly, because ignore_changes is the first tool most people reach for and the one that quietly ends the adoption. From the moment it is added, that attribute is unmanaged: the code says one thing, the object may say another, and nothing will ever tell you. It is granular per attribute but not per value, so ignoring tags ignores every tag including the ones your policy checks depend on. It does not stop a replacement triggered by a different attribute, so it provides no safety. And it is invisible in the plan — an empty plan on a resource with four ignored attributes looks exactly like an empty plan on a resource with none. The blanket form that ignores everything is worse still: it converts the resource into an entry that exists in state and describes nothing.
Where you do use it, name the external owner in a comment on the same line. That comment is what lets a future reader distinguish a deliberate ownership boundary from an abandoned diff.
Step 4 — Iterate to zero, then prove it stays at zero
Fix one class at a time and re-plan between rounds. Provider defaults first, because they are numerous and mechanical and clearing them shortens every subsequent plan. Construct mistakes second, since fixing one often removes a whole block of dependent rows. Out-of-band changes last, deliberately, as their own applied change.
# CLI: the loop, run until the exit code is 0 rather than until the output looks short
cdktf synth
terraform -chdir=cdktf.out/stacks/prod-data plan -out=post-import.tfplan -detailed-exitcode
terraform -chdir=cdktf.out/stacks/prod-data show -json post-import.tfplan | python3 plan_diff.py
A dozen rounds on a complicated resource is normal. What is not normal is a row that reappears after you fix it — that means the value your code writes is not the value the API stores, which happens with normalised strings such as IAM policy documents, ordered lists the API returns in a different order, and numbers the provider stores as strings. Match the API's rendering, not your preferred one.
Verification
Zero once is luck. Zero on the second consecutive plan, with a refresh in between, is an adoption.
# CLI: the durable check — refresh, plan, then plan again
terraform -chdir=cdktf.out/stacks/prod-data apply -refresh-only -auto-approve
terraform -chdir=cdktf.out/stacks/prod-data plan -detailed-exitcode # expect 0
terraform -chdir=cdktf.out/stacks/prod-data plan -detailed-exitcode # expect 0 again
The second plan matters because a class of diffs only shows up once state has absorbed the first refresh — attributes the provider normalises on write, and values that were (known after apply) in the first round. If the second plan is non-empty, the resource is oscillating rather than converged, and applying it would produce a change that immediately re-drifts.
Then make the check permanent. A scheduled plan against the adopted stack turns "we finished the import" into a claim that keeps being tested, and a non-zero exit is either new drift or someone's uncommitted change.
# tests/test_zero_diff.py — assert the adopted stack has nothing pending
# CLI: pytest tests/test_zero_diff.py -q
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import List
STACK_DIR = Path("cdktf.out/stacks/prod-data")
def plan_exit_code(extra: List[str]) -> int:
proc = subprocess.run(
["terraform", f"-chdir={STACK_DIR}", "plan", "-detailed-exitcode", "-input=false", *extra],
capture_output=True, text=True,
)
assert proc.returncode != 1, proc.stderr
return proc.returncode
def test_adopted_stack_has_no_pending_changes() -> None:
# State implication: -refresh-only never writes here because we do not apply it.
assert plan_exit_code(["-refresh-only"]) == 0, "the live objects moved outside Terraform"
assert plan_exit_code([]) == 0, "configuration and state disagree"
Record the count of ignored attributes as a number alongside that test. An adoption that reaches zero diff by ignoring thirty attributes is not the same result as one that reaches zero by describing them, and only the count makes the difference visible in a review.
Gotchas & Edge Cases
tags versus tags_all. With default_tags configured on the AWS provider, an imported resource shows a diff on tags_all that no edit to tags will clear, because tags_all is the merged set. Set the tag on the provider or on the resource, never both, and read the diff on tags alone when deciding what your construct should declare.
Empty string is not null. A provider that stores "" for an unset description and a construct that omits the argument entirely produce a perpetual "" -> null row on some resource types. The fix is to set the argument to the empty string explicitly, which reads oddly and is nevertheless correct.
Sets that look like lists. Security group CIDR blocks, subnet ID lists, and availability zones are frequently sets in the provider schema and lists in your Python. If the plan shows a diff whose before and after contain the same elements in a different order, you are looking at a rendering artefact of the flattening in step one, not a real disagreement — check the raw JSON before editing anything.
Sub-resources the provider split out. Versioning, encryption, lifecycle rules, and ACLs on an S3 bucket moved out of aws_s3_bucket into their own resource types. After importing the bucket, the live settings appear nowhere in the bucket's own attributes, and the diff you are chasing is actually a missing second construct plus a second import.
Replacement hiding behind an update. A plan that reports ~ update in-place for nine attributes and one line marked # forces replacement is a replacement plan. Read the action list from the JSON, not the visual density of the output.
Ignored attributes and imports interact badly. ignore_changes has no effect during creation, so a resource that was ignored into a zero-diff state and is later recreated in a fresh environment will come up with whatever your code says — which is the value you stopped maintaining the day you added the suppression.
Provider upgrades reopen closed diffs. A minor provider release that adds a default or changes a normalisation will resurrect rows you already resolved. This is a good argument for pinning, and for re-running the zero-diff test as part of any provider bump rather than after it merges.
Operational Notes
Give the triage a time box per resource. Most adopted resources reach zero within an hour; a resource still fighting after half a day is telling you something structural — usually that it is modelled at the wrong granularity, or that it belongs to a controller which will keep rewriting it. Recognising that early is cheaper than round eighteen of the plan loop.
Keep the triage record in the repository next to the stack, not in a ticket. The value of the record is that it survives, and the questions it answers — why an attribute is ignored, why a default was accepted, which console change was reverted and when — are asked by people reading the code rather than the change log.
Separate the two applies. Adoption changes that only add arguments to constructs should produce an empty plan and therefore need no apply at all. The changes that push reality back toward the code do need one, and they are ordinary production changes: reviewed, scheduled, and announced. Merging both into one branch means the review cannot tell which is which.
Treat a rising count of ignored attributes as a defect metric. It only ever grows by hand, one suppression at a time, and each one is a small piece of infrastructure that has stopped being described by the code. Reviewing that list quarterly, with the goal of removing entries rather than adding them, is what keeps an adopted estate genuinely managed instead of nominally managed.
Finally, when the same class of diff appears across dozens of resources — the same provider default, the same missing tag block — fix it once in a shared construct rather than fifty times in fifty stacks. A repeated post-import diff is usually a missing abstraction wearing a disguise.
FAQ
Is a non-empty plan straight after an import a sign that the import failed?
No. The importer records what it can read, and your construct declares what you typed; the gap between them is expected and is exactly the work this guide describes. An import has failed only when the plan proposes to create the object you just imported, or to replace it.
How do I tell a provider default from a real change somebody made?
Run the plan twice, once with -refresh=false and once with -refresh-only. Rows that appear without a refresh are your code against the provider's defaults; rows that appear only in the refresh-only plan came from outside Terraform. Anything in both is usually a construct mistake overlapping an edited attribute.
Should I ever apply a post-import plan that proposes updates?
Yes, but only after classifying every row and only as a deliberate change. If the updates are your code pushing correct values onto an object that drifted, that is legitimate remediation. If they are your code overwriting values you simply failed to declare, applying is destructive and the fix belongs in the code.
Is ignore_changes ever the right answer?
When the attribute genuinely has another owner — an autoscaler, a deployment pipeline, a rotation job — it is the correct expression of that boundary. Everywhere else it is a deferral that removes the attribute from management without removing it from the plan's appearance of health.
Why does an attribute I already fixed keep coming back in the plan?
Because the value the provider stores is not byte-identical to the value you wrote: a JSON policy document reordered by the API, a list the service returns sorted differently, a number stored as a string. Copy the value out of terraform state show verbatim rather than retyping it from the console.
Can I automate the classification completely?
Partly. The mechanical parts — computed versus not, present in the refresh-only plan versus not, forces replacement versus not — are pure functions over the plan JSON and belong in a script. Deciding whether the live object or the code is right is a judgement about intent, and no amount of JSON contains that.
Related
- Importing Existing Infrastructure into CDKTF — the parent topic: addresses, import mechanisms, and sequencing an estate.
- Detecting and Remediating State Drift in Python IaC — the ongoing drift discipline that takes over once the adoption is finished.
- Adopt Terraform Modules into a CDKTF Python Stack — when the resources you are adopting already belong to an HCL module.
- Validating Synthesized Terraform with CDKTF — where the zero-diff assertion lives once it becomes a permanent check.