As the author of a shared Terraform module, how do you restructure its internal resource addresses without forcing every consumer into a destroy and create?
answer
- you do not own the consumer's state
- the block ships inside the module
- internal moves keep the interface stable
- chain moves for version-skippers
- drop them only at a major boundary
basics
~20 sShip moved blocks inside the module itself. They travel with the module version, so each consumer's next plan re-keys their own state entries automatically. Keep them for a documented deprecation window and drop them only at a major version boundary.
solid answer
~60 sThe constraint is that the state lives in the consumer's backend, not mine — I cannot run anything against it, so the fix has to be part of the module's configuration. `moved` blocks are exactly that: placed inside the module, they are evaluated in the consumer's plan, so when they upgrade, Terraform re-keys their existing state entries and proposes no infrastructure change. That turns a refactor that would have been a breaking release into a minor version bump. The discipline around it is versioning and lifetime: keep the blocks for a documented window, chain them if addresses move again across releases so a consumer skipping versions still lands correctly, and only drop them at a major version where the release notes say which intermediate version you must upgrade through. Where `moved` cannot help — a resource genuinely changing type, or a restructure that crosses state boundaries — it is a major version with a written import-and-removed procedure, and I would say so loudly rather than let consumers discover it in a plan.
code
hcl · 9 linesmoved {
from = aws_security_group.this
to = aws_security_group.main
}
moved {
from = aws_security_group_rule.ingress
to = module.rules.aws_security_group_rule.ingress
}go deeper
Know that a moved block placed inside a module runs in the consumer's plan, so renaming a resource inside a module does not force them into a destroy and create.
Explain that the module author cannot touch consumer state, so the fix must ship as configuration, and that chained moved blocks let a consumer skip versions and still land on the right address.
Show the release discipline: an upgrade test that applies the previous version and asserts a clean plan on the candidate, plus release notes stating what a correct plan looks like.
Own the contract with downstream teams — what counts as breaking, how long compatibility shims live, at which boundary they are cleared, and what written procedure covers the migrations moved blocks cannot express.
## The constraint that shapes everything A module author does not own the state. Every consumer has their own backend, their own workspaces, their own upgrade schedule, and their own production. You cannot run `terraform state mv` for them, you cannot see their addresses, and you find out about your mistakes when someone opens an issue titled "upgrading to 3.1 wants to destroy my database". So the only safe channel for a state change is the module's own configuration, shipped with the version. That is what makes `moved` blocks a module-author's tool rather than a refactoring convenience. ## moved blocks travel with the module Put the block inside the module, alongside the resources it refactors: ```hcl moved { from = aws_security_group.this to = aws_security_group.main } ``` When a consumer bumps the version and plans, Terraform evaluates that block against **their** state and re-keys the entry — expressed in their address space as `module.whatever.aws_security_group.this` becoming `module.whatever.aws_security_group.main`. Their plan shows the move and no other change. They did nothing but change a version constraint. The same applies to internal restructuring: splitting resources into a nested module, renaming a nested module call, or changing instance keys. Each is a `moved` block, and together they mean an internal reorganisation is invisible from outside. ## Versioning implications This reframes what "breaking" means. Address changes are internal implementation details — if `moved` blocks cover them, the module's *interface* (its variables and outputs) is unchanged, and a minor version is honest. Without the blocks, the same commit is a destructive major release that every consumer must schedule around. Be explicit in release notes either way: say that the release contains address moves, that the expected plan shows moves and no changes, and that a consumer seeing a destroy should stop and report it. That sentence is worth more than the code, because it tells a consumer what "correct" looks like. ## Chaining across releases Moves accumulate. If a resource moved A to B in 2.4 and B to C in 2.7, keeping both blocks means a consumer jumping from 2.3 straight to 2.7 still ends up at C. Deleting the older block breaks exactly the consumers who upgrade least often — which is the population least able to debug it. Assume someone will skip five versions. ## What moved cannot do - **Change a resource's type.** The endpoints must be compatible; a different type is a different schema and a genuinely different object. Migrating from one resource type to another is a real replacement, or a create-plus-`import` with a `removed` block for the old entry — and either way it is a major version with a written procedure. - **Cross state boundaries.** If the restructure means part of the module now belongs in a different root module and a different state, no block can express that. The consumer has to run the migration: `import` on one side, `removed { lifecycle { destroy = false } }` on the other, in that order. - **Undo a real behavioural change.** If the new structure genuinely produces a different object, the plan will say so and it should. ## The lifetime tradeoff The cost of keeping `moved` blocks is clutter: a mature module can accumulate dozens, and a reader has to distinguish live resources from historical archaeology. The cost of dropping them is silent destruction for a lagging consumer. The resolution is a policy, not a judgment call each time: keep every `moved` block until the next major version, remove them all at that boundary, and document in the release notes that upgrading from an older major requires passing through the final release of the previous one first. That gives consumers a rule they can follow without reading your git history. A useful supporting habit is a test consumer — a small configuration in CI that applies the previous released version, upgrades to the candidate, and asserts the resulting plan has no changes. It is the only mechanical check that a refactor is actually non-destructive, because your own repository's state does not exercise the upgrade path at all. ## The judgment being tested An interviewer asking this is checking whether you think about downstream state at all. The junior answer refactors the module and ships it. The mature answer treats other people's state as production you cannot see, and asks what their plan will say before asking whether the code is tidier.
- Should a release that only moves internal addresses be a major version?Not if `moved` blocks cover every change, because the module's interface — variables and outputs — is untouched and the consumer's plan shows no infrastructure change. Say so in the release notes, including what a correct plan looks like, so anyone seeing a destroy knows to stop and report it rather than approve.
- How would you mechanically verify that a module refactor is non-destructive before release?Run an upgrade test in CI: apply the last released version against a throwaway environment, switch to the candidate, and assert the resulting plan reports no changes. Your own repository never exercises the upgrade path, so without that test the first person to discover a missing `moved` block is a consumer in production.
- A restructure means part of the module's resources now belong in a different root module and state. Can `moved` help?No — it operates within a single state. That migration has to be performed by each consumer: an `import` block in the receiving configuration, verified clean, then a `removed` block with `destroy = false` in the source. It is a major version, and the procedure belongs in the release notes rather than in an issue thread afterwards.
- What is the cost of never deleting old `moved` blocks?Readability. A mature module accumulates dozens, and a reader must separate live resources from historical archaeology. The usual resolution is policy rather than taste: carry them until the next major version, clear them all at that boundary, and document that upgrades from older versions must pass through the final release of the previous major.
saying these in an interview costs you the question
- Assumes consumers will run terraform state mv themselves
- Deletes old moved blocks as soon as the author's own state is fine
- Thinks a moved block can change a resource's type
- Calls an address-only refactor breaking, or a type change non-breaking
- Ships a refactor with no upgrade test and no release note