Terraform 1.16: Import Blocks in Modules, JSON Output, and Action Failure Modes Explained

Terraform 1.16: Import Blocks in Modules, JSON Output, and Action Failure Modes Explained

HashiCorp shipped Terraform 1.16.0 on August 26, 2026, and buried in the changelog is a fix for one of the most-requested Terraform limitations of the last few years: import blocks can finally target resources declared inside a module, not just the root module. If you've ever had to temporarily flatten a module's resources into root just to run an import, or hand-write terraform import commands with module-address syntax you had to look up every time, this release removes that entire workaround. This walks through what actually changed and which of the smaller additions are worth adopting right away.

Why import-inside-modules mattered enough to hold up adoption

Terraform's declarative import block (stable since 1.5) replaced the old imperative terraform import <address> <id> CLI workflow, but it had a gap: the to address had to resolve to a resource in the root module. Real infrastructure is almost never organized that way โ€” teams wrap resources in reusable modules specifically so they're not duplicating root-level blocks across environments. Bringing an existing, unmanaged Azure storage account or AWS security group under management meant either:

  • Writing the resource directly in root first, importing it there, then manually cutting it into the module and hoping terraform state mv didn't drift, or
  • Falling back to the legacy imperative terraform import module.foo.aws_instance.bar i-0123456789abcdef0 command, which works but isn't reviewable in a plan the way a declarative block is.

Terraform 1.16 removes the restriction. import blocks can now target a to address inside any module, and the plan output shows exactly what will be imported before you touch apply โ€” same review workflow you already use for everything else.

Using import blocks inside a module

Say you have a module that provisions an S3 bucket, and a bucket already exists in AWS from before your team adopted Terraform:

1# modules/storage/main.tf
2resource "aws_s3_bucket" "data" {
3  bucket = var.bucket_name
4}
 1# root main.tf
 2module "storage" {
 3  source      = "./modules/storage"
 4  bucket_name = "ck-prod-analytics-exports"
 5}
 6
 7import {
 8  to = module.storage.aws_s3_bucket.data
 9  id = "ck-prod-analytics-exports"
10}

Run a plan the same way you would for any other change:

1terraform plan

Terraform resolves the module instance, matches the resource address, and reports an import action alongside any config drift it detects between the real bucket and what your module declares (tags, versioning, lifecycle rules โ€” anything the resource block sets). Review that diff carefully; unlike a fresh apply, an import diff tells you what Terraform will silently change to match your config the moment you approve it, not just what it will create. Once the plan looks right:

1terraform apply

After the apply succeeds, delete the import block โ€” it's a one-time instruction, not a persistent part of your config, and leaving it in will cause an error on the next plan once the resource is already in state.

It also works for for_each module instances

If your module is instantiated with for_each, target a specific instance the same way you'd address it anywhere else in Terraform:

1import {
2  to = module.storage["analytics"].aws_s3_bucket.data
3  id = "ck-prod-analytics-exports"
4}

This is the case that most needed fixing โ€” teams using for_each over a map of environments or regions previously had no clean declarative path to import a single instance without duplicating the whole module definition at root.

Update your install before relying on this for multiple instances at once. Terraform 1.16.1, released September 2, 2026 (a few days after 1.16.0), fixes a real bug in exactly this scenario: import blocks were silently ignored when multiple imports targeted different for_each/count instances of the same resource config in one plan. A single for_each import like the one above works fine on 1.16.0, but if you're bulk-importing several instances of a for_each'd module in one apply, upgrade to 1.16.1 or later first โ€” otherwise some of your import blocks may be dropped without an error telling you.

Two smaller additions worth adopting immediately

Machine-readable output for state show and workspace list. Both commands now accept -json:

1terraform state show -json aws_s3_bucket.data
2terraform workspace list -json

If you've ever piped terraform state show output through grep/awk to extract a single attribute for a CI script, stop โ€” -json | jq '.values.tags' is faster and doesn't break the next time HashiCorp reformats human-readable output.

terraform graph -format=mermaid. The dependency graph command has always existed, but historically needed Graphviz's dot installed locally to render anything readable. As of 1.16, it can emit Mermaid syntax directly:

1terraform graph -format=mermaid > graph.mmd

Paste the output straight into a Markdown file, a GitHub PR description, or a Confluence page with Mermaid rendering enabled โ€” no extra tooling, no PNG to keep in sync.

Action on_failure modes: halt, taint, continue

If you're using Terraform's newer action blocks (Stacks-era resource lifecycle actions), triggers now support an explicit on_failure setting:

 1action "aws_lambda_invoke" "warm_cache" {
 2  config {
 3    function_name = aws_lambda_function.cache_warmer.function_name
 4  }
 5}
 6
 7resource "aws_dynamodb_table" "cache" {
 8  # ...
 9  lifecycle {
10    action_trigger {
11      events  = [after_create]
12      actions = [action.aws_lambda_invoke.warm_cache]
13    }
14  }
15}

Previously, a failed action's blast radius on the rest of the apply wasn't something you could configure per-trigger. Now you choose:

  • halt (default) โ€” stop the apply, same as today's behavior.
  • taint โ€” mark the associated resource tainted so it gets recreated on the next apply, but let the rest of the apply continue.
  • continue โ€” log the failure and proceed; use this only for genuinely non-critical side effects, like a cache-warming call that shouldn't block infrastructure changes if it times out.

Best practices for the upgrade

  • Read the upgrade note on bastion_host_key before you run terraform init -upgrade if any of your connection blocks use a provisioner with that setting โ€” 1.16 fixes a bug where it wasn't being applied correctly, which means behavior changes even though nothing in your config did.
  • Pin your version explicitly in required_version (or your CI's Terraform install step) before rolling this out to a shared state โ€” a teammate running 1.15 against state that used a 1.16-only feature like the terraform_data store block will get a hard version mismatch on their next plan.
  • Target 1.16.1, not 1.16.0, if you're on this release line at all. The patch (September 2, 2026) fixes the multi-instance for_each/count import bug above plus a handful of other issues (a CLI hang after a run-task failure, a panic when an import identity references a sensitive value, and a create_before_destroy ordering fix) โ€” there's no reason to stay on the initial 1.16.0 tag.
  • Always run terraform plan before terraform apply on an import block, and read the diff line by line. An import is the one operation where Terraform will change real infrastructure to match your config on the very first apply, with no prior state to compare against.
  • Delete import blocks after they succeed. Leaving them in config is a common source of confusing "resource already managed" errors weeks later when someone re-runs terraform init on a stale branch.

Common mistakes to avoid

  • Forgetting the resource must already exist in the module's declared form. Import doesn't infer configuration for you โ€” if the module's resource block doesn't already match the real infrastructure's shape (wrong resource type, missing required argument), the plan will show a large, unexpected diff instead of a clean import.
  • Importing into a for_each/count instance without confirming the index. module.storage["analytics"] and module.storage["prod-analytics"] are different addresses; a typo imports successfully into the wrong state address and leaves the real target still unmanaged.
  • Treating -json output as a stable public API without checking the version. HashiCorp has changed JSON output shapes across major versions before; pin your Terraform version in any CI script parsing this output, and re-check the schema on your next Terraform upgrade.

Troubleshooting

"Error: Cannot import non-existent remote object" โ€” the id in your import block doesn't match anything the provider can find. Double-check the exact ID format the resource type expects (ARNs, resource names, and composite IDs like <region>/<name> are all provider-specific).

"Error: resource address does not correspond to an object" โ€” the to address doesn't resolve to a real resource in your config after Terraform evaluates modules. This is usually a typo in the module instance key (for_each case above) or a resource type that doesn't match what's declared inside the module.

FAQ

Do I need to upgrade all my providers to use module import blocks? No โ€” this is a Terraform core feature (the CLI/engine), not a provider feature. Any provider that already supports declarative import (most current major-version providers do) works with module-targeted import blocks as soon as you're on Terraform 1.16.

Does this replace terraform state mv? No, they solve different problems. import brings a real, existing cloud resource under Terraform management for the first time. state mv moves an address that's already in Terraform state to a new address. You'll still use state mv when refactoring โ€” moving a resource from root into a new module, for example.

Can I use import blocks with OpenTofu instead of Terraform? OpenTofu maintains its own release cadence and has supported declarative import for a while, but check OpenTofu's own changelog for whether module-targeted import parity has landed โ€” don't assume feature parity by default just because the two share a common ancestor.

Is this safe to run against production state? Yes, with the same caution as any Terraform apply โ€” import blocks show up in terraform plan like any other change, so nothing touches real infrastructure until you review and approve the plan.

Key takeaways

FeatureWhat changedAction to take
Import blocks in modulesto can now target any module instance, including for_each instancesAdopt for any pending brownfield imports blocked on this limitation
state show -json / workspace list -jsonMachine-readable output addedMigrate CI scripts off text-parsing
terraform graph -format=mermaidNo Graphviz dependency needed for docsUse for architecture docs / PR descriptions
Action on_failurehalt / taint / continue per triggerReview existing action triggers and set explicitly rather than relying on the halt default
bastion_host_key fixProvisioner setting now applies correctlyReview connection blocks before upgrading
1.16.1 patch (Sep 2, 2026)Fixes multi-instance for_each/count import bug, plus other stability fixesUse 1.16.1+, skip the 1.16.0 tag

Further Reading