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.
| Section | Items | What I would look at first |
|---|---|---|
| New features | 6 | Import blocks in modules (#38352), terraform_data store block (#38298), action on_failure modes (#38722), plus provider-facing changes and a Linux s390x binary (#38384). |
| Enhancements | 14 | lifecycle 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 fixes | 11 | Import 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 notes | 1 | Provisioners 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.
| Mechanism | What happens in state | What happens to the real object |
|---|---|---|
| lifecycle destroy = false | Resource is removed from state | Left running and unmanaged |
| lifecycle prevent_destroy = true | Stays in state; a plan that would destroy it fails | Not destroyed, but the plan errors |
| Deleting the resource block | Resource is removed from state | Destroyed, even with prevent_destroy set, according to the lifecycle docs |
| removed block, destroy default | Resource is removed from state | Destroyed 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_failure | Effect on the apply | Resource tainted |
|---|---|---|
| halt (default) | Stops the apply and reports an error | No |
| continue | Logs the error as a warning and carries on | No |
| taint | Stops the apply and reports an error | Yes, 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.notifyAlso 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_bucketA 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:
- Search your code for bastion_host_key and re-test any provisioner that uses a bastion host.
- Check CI scripts for init calls that pass both -upgrade and -lockfile=readonly.
- Pin the minor version in required_version and in your CI image so laptops and pipelines agree.
- Run a representative plan on your largest root module and expect no changes; investigate any that appear.
- 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.
Sources and further reading
- GitHub: Terraform v1.16.0 release notes
- GitHub: Terraform v1.16 CHANGELOG
- HashiCorp: Terraform upgrade guides
- HashiCorp: the import block reference
- HashiCorp: the terraform_data resource
- HashiCorp: the lifecycle meta-argument
- HashiCorp: the removed block reference
- HashiCorp: invoke actions and action_trigger
- HashiCorp: the terraform graph command

