
Terraform 1.16 lets modules import into your state, and the merge rule is silent
Terraform 1.16 allows import blocks inside modules, which finally makes brownfield adoption a module's own business instead of a root-module chore. It also means a module you consume can declare an import into your state, and a parent module's import quietly overrides a child's with nothing in the plan to say so. Here is the syntax, the merge rule, what the engineer who wrote the pull request said about it in review, and what I changed in our module review because of it.
Series: Infrastructure-as-code (4 articles)
- 1Terraform 1.16 lets modules import into your state, and the merge rule is silent This article
The problem: a plan that wanted to replace a production database
We were migrating a client off click-ops onto Terraform with Terragrunt on top. Two years of hand-built AWS account, names invented per resource, secrets in
.env files, moving to modular IaC with three environments that actually matched.Their database had been created early by a community module. We were swapping it for our own wrapper so the master password could stop arriving as an environment variable at apply time. Routine refactor, reviewed and approved.
Then
terraform plan came back:1
2
3
4
5
6
7
8
9
10
11
12
13
14
Terraform will perform the following actions:
# module.db.aws_db_instance.this will be destroyed
# (because module.db.aws_db_instance.this is not in configuration)
- resource "aws_db_instance" "this" {
- identifier = "prod-db" -> null
}
# module.rds.aws_db_instance.this will be created
+ resource "aws_db_instance" "this" {
+ identifier = "prod-db"
}
Plan: 1 to add, 0 to change, 1 to destroy.
Nobody typed
destroy. Terraform does not track a resource by what it is, it tracks it by address, and a changed module path is a changed address. A changed address reads as "delete this one, create that one". The plan was correct. It was also one apply away from taking the production database with it.The fix is one command:
1
2
3
terraform state mv \
module.db.aws_db_instance.this \
module.rds.aws_db_instance.this
That is the class of problem config-driven
import was supposed to retire. Until 1.16, it could only retire half of it.Why the import block could not help
import blocks landed as the reviewable alternative to the imperative terraform import command. You declare intent in HCL, the plan shows the adoption, a reviewer reads it in the pull request instead of trusting somebody's shell history.The catch was scope. Write one anywhere except the root module and the parser stopped you:
1
2
An import block was detected in "module.rds".
Import blocks are only allowed in the root module.
That check lived in
internal/configs/config_build.go and it was absolute.Real infrastructure does not sit in the root module. On a Terragrunt layout, practically every resource is behind at least one module boundary, so adopting an existing resource left two options and both were worse than they should have been.
The first was to write the address from the root, for a resource the root does not otherwise mention:
1
2
3
4
5
# root, describing something two layers down
import {
to = module.rds.aws_db_instance.this
id = "prod-db"
}
That works and it reviews fine. It also means every module's adoption business piles up in one root file, far from the resource block it targets.
The second was to skip the block entirely and run
terraform import by hand. No plan, no diff, no pull request. That is how most brownfield adoption actually happened, and it is the one that hurts, because an operation that permanently changes what Terraform believes it owns should not be a thing one person does in a terminal at the end of a long day.What 1.16 changed
Terraform 1.16 went GA on 26 August 2026. The changelog line is six words:
1
import: `import` blocks inside modules are now supported. (#38352)
The check is gone. A module can now carry its own adoption logic, in the same directory as the resource it targets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# modules/rds/main.tf
variable "existing_db_id" {
type = string
default = null
}
resource "aws_db_instance" "this" {
identifier = var.identifier
}
import {
to = aws_db_instance.this
id = var.existing_db_id
}
The address in
to is now module-local. For brownfield work this is the right shape: a module that knows how to adopt an existing resource ships that knowledge with itself, visible in the same diff as the resource block.Tip
The same release fixed two related bugs worth knowing about:
import blocks now correctly respect provider local names (#38338), and Terraform returns the correct error when an import target exists in state with no corresponding configuration (#38782).The merge rule nobody will read
Here is the part I would put in a review checklist rather than a release summary. From the merged implementation:
1
2
3
4
5
// If we already have an import statement for this ConfigResource, it
// must have come from a parent module, because duplicate import
// blocks in the same module result in an error.
// The import block in the parent module overrides the block in the
// child module.
Two rules, and they are not symmetric:
| Situation | Terraform's behaviour |
|---|---|
| Two import blocks, same module, same target | error at parse time, loudly |
| Parent and child both import the same target | parent overrides child, silently |
| A consumed module declares an import you never read | applies as written |
The precedence itself is defensible. It matches how
moved blocks already behave, and root-wins is the sane choice if you have to pick one. What bothers me is the silence. An override changes which real-world resource gets adopted into your state, and no line of plan output says an override happened."But it is my module, so who cares?"
If you author every module you consume, this section does not apply to you and you can skip it.
Most teams do not. Our own rule is reuse before build, sourced from a curated internal registry plus vetted upstream modules rather than whatever the public internet is offering that week. Every one of those is a module somebody else authors and versions, and version bumps are the change most likely to get a rubber stamp in review.
The engineer who wrote the pull request raised this before anyone else did, in the review thread on #38352:
though ... never mind overriding an import block, to be honest I am not very comfortable with this feature at all. the only use-cases I can actually come up with are solved by data sources, not importing resources into your state from nested modules. ... If I owned everything, I could write the import statement in the root module, and if I don't own everything ... I don't want imports I didn't write.
And the mitigation they asked for:
wearing my infra hat, I'd prefer the ability to control this behavior (ie, block nested-module imports/fail/etc) from the root module (as a security stance), but I guess a sentinel policy could cover that.
That switch is not in 1.16. There is no root-level flag to refuse nested-module imports. If you want a hard guarantee, you write the policy check.
Warning
This is not a vulnerability, and nothing here happens without you running
apply on a plan you can read. It is a shift in where you have to look. The reviewer's job used to end at the root module's import blocks.What I changed in module review
Four things, in the order I actually apply them:
Pin module versions and never float on
main. This was already the rule; 1.16 raises the cost of breaking it.1
2
3
4
module "rds" {
source = "terraform.example.com/namespace/rds/aws"
version = "~> 3.2"
}
Diff module upgrades for new
import blocks specifically, not just for changed resource arguments:1
git diff v3.1.0..v3.2.0 -- ':*.tf' | grep -n -A5 '^\+import'
Read the plan for adoption lines on any release that bumps a module, not only on releases that change infrastructure. An import shows up as a resource entering state, which is easy to skim past on a busy plan.
1
terraform plan -json | jq -r 'select(.type=="resource_drift" or .change.action=="import")'
And if you need the hard guarantee, write it as policy. There is no built-in switch, so this is OPA, Sentinel, or a CI grep. A CI grep is not elegant, but it is honest about what it does.
Honest trade-offs
I would still take this feature over not having it. Brownfield adoption is work I do for a living, and the root-module workaround was genuinely annoying at scale.
What I gave up is a boundary that used to be free. Before 1.16, "no module can touch my state without my root module saying so" was true by construction. Now it is true only if I enforce it, and enforcement costs a policy check that I have to maintain.
The PR author's own read is worth sitting with: the use cases they could think of are better served by data sources. A
data block reads an existing resource without adopting it, which is the right tool when you need to reference something you do not intend to manage. If your reason for reaching for import-in-module is "I want to look up an existing VPC", write a data source instead and keep the boundary.Also in 1.16, briefly
terraform graph -format=mermaid (#38719) prints a dependency graph that pastes straight into a pull request or a docs page, no converter and no Graphviz install:1
terraform graph -format=mermaid > docs/graph.md
terraform console -scope=<module address> (#31861) evaluates expressions inside a module's scope, which replaces the temporary-output-then-apply-then-delete dance:1
terraform console -scope=module.rds
The
store block on terraform_data (#38298) holds ephemeral and sensitive values across plan and apply, continuing the same direction as ephemeral values and write-only attributes: fewer reasons for a secret to park in state.And
-json arrived on state show (#23940) and workspace list (#38397), which is the difference between scripting against Terraform and parsing its human output with regular expressions.One callback.
lifecycle { destroy = false } (#38784) reached GA with its behaviour unchanged from beta, and so did its changelog description: "prevent a resource from being destroyed". It does not prevent the resource from being destroyed. It removes the resource from state instead of destroying it, and the resource keeps running and keeps billing. In HashiCorp's own end-to-end test:1
2
3
4
$ terraform destroy --auto-approve
...
will no longer be managed by Terraform
Destroy complete! Resources: 0 destroyed.
prevent_destroy fails loudly. destroy = false succeeds quietly. Only one of those is a safety feature.Summary
import blocks inside modules is the right fix for a real problem, and if you run brownfield migrations it will save you the root-module address gymnastics that used to come with them.It also moves the declaration one layer further from the person whose job is to read it, and the parent-over-child precedence resolves without a word in the plan. An import you review is a feature. An import you inherit is part of your supply chain, and it deserves the same scrutiny as any other thing you pull from a registry.
Pin your versions, diff your module bumps for
import, and if you need the guarantee, write the policy. There is no flag for it.Series: Infrastructure-as-code (4 articles)
- 1Terraform 1.16 lets modules import into your state, and the merge rule is silent This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article