Skip to main content
BlogDevOps

Terraform 1.16: What's New and How to Upgrade Safely

Terraform 1.16 adds module import blocks, a terraform_data store block, destroy = false and Mermaid graphs. See what changed and what to test first.

Matthews Wong

Software Engineer

October 202611 min read
Terraform 1.16: What's New and How to Upgrade Safely

Key Takeaway

Terraform 1.16, released on 26 August 2026, adds import blocks inside modules, a terraform_data store block for ephemeral and sensitive values, lifecycle destroy = false, action failure modes and Mermaid graph output. Its upgrade notes list one item, bastion_host_key in provisioners. Pin the minor version, run representative plans, and treat destroy = false as stop managing, not protect.

I opened the Terraform 1.16.0 release page expecting a quiet minor release and counted six new features, fourteen enhancements and eleven bug fixes. Most of the interesting lines were one sentence long, and two of them sounded like safety features when I first read them. One of those impressions turned out to be wrong.

This post reads the v1.16.0 release notes, the 1.16 changelog and the current HashiCorp documentation pages for each feature. Every PR number comes from the release notes. I did not run 1.16 against a real state file, so where the docs are silent I say so instead of filling the gap.

What 1.16 contains, counted from the release notes

The release notes for v1.16.0 are dated 26 August 2026 and split into new features, enhancements, bug fixes, one general note and one upgrade note. The upgrade guide itself describes 1.16 as a minor release in the stable 1.0 series that honours the 1.0 compatibility promises, and sends you to the changelog for detail.

SectionItemsWhat I would look at first
New features6Import blocks in modules (#38352), terraform_data store block (#38298), action on_failure modes (#38722), plus provider-facing changes and a Linux s390x binary (#38384).
Enhancements14lifecycle destroy = false (#38784), Mermaid output for terraform graph (#38719), -json for state show and workspace list, before_destroy and after_destroy action events (#38668).
Bug fixes11Import blocks now respect provider local names (#38338) and give the correct error for a target that is in state but has no configuration (#38782).
Upgrade notes1Provisioners now apply bastion_host_key correctly, so verify provisioner configuration before upgrading (#38318).

One more line sits under Notes: init now raises errors for incompatible -upgrade and -lockfile=readonly flags earlier (#38561). If a CI job passes both, it will now fail at init rather than later, which is the kind of change that looks like a regression until you read the line.

Import blocks can now live inside modules

The 1.16 notes say import blocks are supported inside modules (#38352). The practical gain is ownership: a module that wraps a resource people often adopt, such as a bucket or a DNS zone, can carry its own adoption logic instead of asking every caller to add a root-level import.

# modules/data-bucket/imports.tf
# The module that owns the resource now also owns the import.
variable "existing_bucket_name" {
  type    = string
  default = null # null = a fresh environment, nothing to adopt
}

resource "aws_s3_bucket" "this" {
  bucket = coalesce(var.existing_bucket_name, "acme-data-new")
}

# Documented shape: "to" must match a resource block's address,
# and "id" must be known during the plan.
import {
  for_each = var.existing_bucket_name == null ? toset([]) : toset(["adopt"])
  to       = aws_s3_bucket.this
  id       = var.existing_bucket_name
}

# Wrong (before 1.16): put this import block in the root module and
# address it as module.data_bucket.aws_s3_bucket.this, so every caller
# of the module has to know about the adoption.
# Right (1.16): leave the root module alone and let the module carry it.

The import documentation lists the arguments: to, which must match the address of an existing resource block, id, which must be known during the plan, identity as an alternative to id, plus for_each and provider as meta-arguments. It does not mention a module restriction or the 1.16 change at all, so the release notes are the only source for the new behaviour. The example above is my own, built from the documented argument rules.

The same release fixes two import edge cases. Import blocks respect provider local names (#38338), and an import target that exists in state but has no configuration now returns the correct error (#38782). Importing still establishes state ownership, so read the resulting plan before applying: existing infrastructure may differ from the configuration you wrote.

terraform_data gets a store block for ephemeral values

terraform_data now has a store block that holds ephemeral and sensitive values across plan and apply (#38298). The documentation describes four settings: input, which is write-only and accepts any type including ephemeral values, replace, sensitive and version.

# Follows the documented example: an ephemeral value is written into
# a store block and read back through a sensitive attribute.
ephemeral "random_password" "db" {
  length = 24
}

resource "terraform_data" "db_password" {
  store {
    input     = ephemeral.random_password.db.result
    sensitive = true # lands in store.sensitive_output, not store.output
    version   = 1    # bump to accept a new input; otherwise it is ignored
  }
}

The mechanics matter more than the headline. The stored input is copied to store.output, or to store.sensitive_output when sensitive is true, and the two are mutually exclusive. If version is set, changes to input do not reach the output unless version changes too. That makes version the explicit knob for deciding when a new value is accepted.

A sensitive attribute is still a value in state. The documentation says the input is copied to an output attribute in state, so a store block moves a secret from your configuration into the state file, marked sensitive. That is only as safe as your backend encryption and who can read state, and I would not put a long-lived credential there to save a data source.

Before you adopt store for anything secret, read your state file as a stranger would: run terraform state pull and search for the attribute name. What you find is what your backend and every person with state access can see.

lifecycle destroy = false: not the guard it sounds like

The release notes describe the enhancement as lifecycle blocks supporting destroy = false to prevent destruction (#38784). The current lifecycle documentation reads differently: set destroy to false to remove a resource from state without destroying the actual infrastructure resource. Those are opposite instincts, and the docs describe the second one.

MechanismWhat happens in stateWhat happens to the real object
lifecycle destroy = falseResource is removed from stateLeft running and unmanaged
lifecycle prevent_destroy = trueStays in state; a plan that would destroy it failsNot destroyed, but the plan errors
Deleting the resource blockResource is removed from stateDestroyed, even with prevent_destroy set, according to the lifecycle docs
removed block, destroy defaultResource is removed from stateDestroyed unless destroy is set to false
# Syntax from the lifecycle documentation. The page gives no example,
# so check this on a throwaway state before you trust it on a database.
resource "aws_db_instance" "legacy" {
  # ...
  lifecycle {
    destroy = false # stop tracking it, leave the instance running
  }
}

# Not the same as the guard that refuses to plan a destroy at all:
resource "aws_db_instance" "guarded" {
  # ...
  lifecycle {
    prevent_destroy = true # plan fails if it would destroy this
  }
}

The lifecycle page does not say how destroy = false interacts with terraform destroy, and it gives no example, so the snippet above follows the documented syntax only. The removed block page confirms the same semantics in its own spot: destroy set to false removes the resource from state without destroying it, which it recommends when you hand a resource to another tool or team. Treat destroy = false as a hand-off switch and keep prevent_destroy for protection.

Actions: failure modes, destroy events and a caller symbol

Action triggers gain an on_failure setting with three values (#38722). The invoke-actions page documents all three, and the default is halt.

on_failureEffect on the applyResource tainted
halt (default)Stops the apply and reports an errorNo
continueLogs the error as a warning and carries onNo
taintStops the apply and reports an errorYes, replaced on the next apply
# From the invoke-actions documentation: a notification that must not
# block the apply, so on_failure is continue instead of the default halt.
resource "aws_lambda_function" "example" {
  # ... resource arguments ...

  lifecycle {
    action_trigger {
      events     = [after_create]
      actions    = [action.aws_lambda_invoke.notify]
      on_failure = continue
    }
  }
}

action "aws_lambda_invoke" "notify" {
  config {
    function_name = "my_function"
    payload       = caller.output # one action, many calling resources
  }
}

# Run the action by hand, with no resource change:
#   terraform apply -invoke=action.aws_lambda_invoke.notify

Also in the notes: actions gain before_destroy and after_destroy events, and action configurations can read a caller symbol for the calling resource (both #38668). The invoke-actions page documents only after_create and after_update, so for the destroy events and the exact caller attributes I rely on the release notes alone. Actions also now respect all resource dependencies, and -invoke can be combined with -target to pick the calling resource instance (#38845).

Smaller changes that fix real annoyances

terraform graph can emit Mermaid with -format=mermaid (#38719), and state show and workspace list accept -json (#23940, #38397). terraform console takes -scope with a module address (#31861), and a pre-built Linux s390x binary exists (#38384).

# A pull-request-friendly dependency graph. The flag is from the
# 1.16.0 release notes (#38719); the graph docs page still describes DOT.
terraform graph -format=mermaid > graph.mmd

# Machine-readable output where scripts used to scrape text.
terraform state show -json aws_s3_bucket.this
terraform workspace list -json

# Evaluate an expression inside one module instead of the root.
terraform console -scope=module.data_bucket

A note on documentation lag: the graph command page I read still describes DOT output rendered with Graphviz and lists no -format option. The flag comes from the release notes, so run terraform graph -help on your own binary before you wire it into CI. For a pull request, Mermaid pays off because Markdown renderers can draw it inline, though a large estate will need scoping to stay readable.

Where this can bite you

The sharpest edges are not in the headline features. These are the ones I would put in front of a team before upgrading:

  • destroy = false removes a resource from state and leaves the real object unmanaged, so a drift report will stop mentioning it.
  • A store block copies its input into state, so a sensitive value is protected only by your backend and access controls.
  • bastion_host_key is now applied correctly by provisioners, so a configuration that silently ignored it may behave differently (#38318).
  • init now fails earlier when -upgrade and -lockfile=readonly are combined (#38561), which can break a pipeline that only worked by accident.

Do not read destroy = false as a safety net. If you want Terraform to refuse a destroy plan, that is prevent_destroy. Using the wrong one on a database turns a guard you thought you had into a resource Terraform has forgotten.

None of this is a reason to skip 1.16. The release honours the 1.0 compatibility promises, and the single upgrade note is narrow. It is a reason to test the four items above on a copy of your real configuration, not on a hello-world module.

What to do on Monday

A short upgrade path that matches what the release notes actually change:

  1. Search your code for bastion_host_key and re-test any provisioner that uses a bastion host.
  2. Check CI scripts for init calls that pass both -upgrade and -lockfile=readonly.
  3. Pin the minor version in required_version and in your CI image so laptops and pipelines agree.
  4. Run a representative plan on your largest root module and expect no changes; investigate any that appear.
  5. Search for lifecycle blocks you plan to write with destroy = false and confirm each one is a deliberate hand-off.
# versions.tf - pin the minor so CI and laptops agree
terraform {
  required_version = ">= 1.16.0, < 1.17.0"
}

# CI: on a representative root module, a plan that must be clean
terraform version
terraform init -upgrade
terraform plan -detailed-exitcode -out=tfplan   # exit 0 clean, 2 changes
terraform graph -plan=tfplan -format=mermaid > plan-graph.mmd

# Provisioners with a bastion: grep before the upgrade, not after
grep -rn "bastion_host_key" --include=*.tf .

Adopt the new features one at a time afterwards. Module-level import blocks and Mermaid graphs are low risk. Treat store and destroy = false as changes that need review in a pull request.

Terraform 1.16 is a useful minor release whose two riskiest features are named like safety features. Read the documentation page, not the one-line summary, before using destroy = false or store, pin the version, and let a representative plan tell you whether the upgrade is quiet.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is new in Terraform 1.16?

Terraform 1.16.0, released on 26 August 2026, lists six new features and fourteen enhancements. The notable ones are import blocks inside modules, a store block on terraform_data, lifecycle destroy = false, on_failure modes for action triggers and Mermaid output from terraform graph. It also adds -json to state show and workspace list.

Are there breaking changes in Terraform 1.16?

The release notes carry one upgrade note: provisioners now apply bastion_host_key correctly, so verify provisioner configurations before upgrading. The upgrade guide describes 1.16 as a minor release that honours the 1.0 compatibility promises. Also note that init now fails earlier when -upgrade and -lockfile=readonly are combined.

Does lifecycle destroy = false protect a resource from being destroyed?

No, according to the lifecycle documentation it removes the resource from state without destroying the real infrastructure object. To make Terraform refuse a plan that would destroy a resource, use prevent_destroy = true. Test destroy = false on a throwaway state before using it on anything important.

Is the terraform_data store block safe for secrets?

The store block accepts ephemeral input and copies it to an output attribute in state, or to sensitive_output when sensitive is true. That means the value ends up in the state file, marked sensitive. Its safety depends on your backend encryption and on who can read the state.

How do I generate a Mermaid graph in Terraform 1.16?

Run terraform graph -format=mermaid, a flag listed in the 1.16.0 release notes. The graph command documentation page still describes DOT output, so confirm the flag with terraform graph -help on your binary. Mermaid output can be pasted into Markdown tools that render diagrams.