Terraform 1.16 vs OpenTofu 1.13: Where the IaC Forks Now Diverge
Last Updated: September 23, 2026
Three years after the fork, the two tools still read the same .tf files, and that is exactly why the latest releases are dangerous to skim. The short answer to terraform 1.16 vs opentofu is this. Terraform 1.16 (GA 26 August 2026) moves ahead on module-level workflow: import blocks inside child modules, a store block on terraform_data that deliberately persists ephemeral values, and richer action triggers. OpenTofu 1.12 (GA 14 May 2026) and the 1.13 release candidate (rc1, 17 September 2026) move ahead on governance and safety: dynamic prevent_destroy, experimental built-in linting, and more context options for client-side state encryption. Both now ship a lifecycle { destroy = false } with the same name. Their documentation does not describe it the same way, so do not assume it behaves identically.
This matters now because the list of features only one tool has is getting longer, not shorter. A module written for one tool increasingly fails on the other in ways a quick plan will not reveal.
What this covers: the release-by-release timeline, how each new feature actually works, a feature-by-feature matrix, the compatibility risk for teams running both, a stay/switch/wait decision, and an upgrade checklist for the 1.13 RC.
Context and Background
HashiCorp relicensed Terraform from MPL-2.0 to the Business Source Licence (BUSL 1.1) in August 2023. The community fork that became OpenTofu stayed on MPL-2.0 and joined the Linux Foundation. For most of 2024 the two projects shipped almost the same language, and OpenTofu’s first real differentiators were client-side state encryption (1.7) and variables in module sources and backend configuration (1.8). If you want the mechanics of moving an estate across, our production OpenTofu migration tutorial walks through the state and lock-file steps. This post is about something else: what each version now does, and where the two stop being interchangeable.
The version numbers are deliberately misleading. Terraform and OpenTofu both use 1.x semantic versions, but the numbers are not aligned. OpenTofu 1.12 is not “Terraform 1.12 plus extras”, and Terraform 1.16 is not “four versions ahead”. Each project has its own changelog, its own compatibility promise and its own release cadence. Terraform has shipped a minor release roughly every four to five months recently: 1.14 on 19 November 2025, 1.15 on 29 April 2026 and 1.16 on 26 August 2026. OpenTofu has done something similar, with 1.12 on 14 May 2026 and 1.13 now at release-candidate stage. The 1.13 series is already scheduled for support until 1 August 2027, but as of today it is not GA. The changelog still marks 1.13.0 as “Unreleased”, and the docs site labels it “1.13.x (beta)”.
That distinction drives the rest of this article. Terraform 1.16 is on its third patch release (1.16.3 shipped 16 September). OpenTofu 1.13 is an RC. A fair production comparison today is Terraform 1.16 against OpenTofu 1.12, with the upcoming 1.13 treated as a near-term roadmap you can test but should not bet a change freeze on. For how both compare with non-HCL options, see our Terraform vs Pulumi vs Crossplane comparison. The primary sources for everything below are the Terraform v1.16.0 release notes and the OpenTofu v1.13 changelog.
Terraform 1.16 vs OpenTofu: The Divergence Map
Direct answer: Terraform 1.16 and OpenTofu 1.12/1.13 now converge on dynamic module sources, convert(), deprecation markers and a destroy = false lifecycle option. They diverge on import blocks in child modules and the terraform_data store block (Terraform only), and on state encryption, dynamic prevent_destroy, built-in linting and OCI registries (OpenTofu only).
The best way to see this is as a timeline in which features move in both directions. Early on, OpenTofu shipped things first and Terraform caught up later, sometimes about eighteen months later. In 2026 the flow has become two-way, and each side is now adding features in areas the other has not touched.

Figure 1: Release timeline from OpenTofu 1.8 to the OpenTofu 1.13 RC. Dotted edges mark where features converged and where they diverged.
Read the figure top to bottom. OpenTofu 1.8 (2024) allowed variables and locals in module sources and backend configuration, with limitations. Terraform matched the module half of that in 1.15 (April 2026), allowing variables and locals in module source and version attributes. OpenTofu 1.10 (2025) added deprecated markers on variables and outputs, and OCI registry support for providers and modules. Terraform added a deprecated attribute on variable and output blocks in 1.15, but it has no OCI registry equivalent. Terraform 1.14 introduced two things with no OpenTofu counterpart in the 1.12 or 1.13 RC changelogs: top-level action blocks and List Resources, queried with terraform query from *.tfquery.hcl files. Terraform 1.15 added a convert function, and the OpenTofu 1.13 RC adds its own convert (#4449).
Convergence is real, but it is shallow
The converged features are mostly language surface: a function name, an attribute on a variable, where an expression is allowed. These are the cheapest things to port and the easiest to verify, because a wrong one fails at validate. Even here the details differ. OpenTofu’s 1.8 support covered backend configuration as well as module sources, while Terraform 1.15’s changelog only covers module source and version. A root module that interpolates a variable inside backend "s3" {} is therefore still OpenTofu-only.
The 1.13 RC also picks up two small fixes Terraform 1.16 shipped: contains() now accepts null as its second argument, and merge() no longer fails on a single null object (#4043 in OpenTofu, #38792 in Terraform). This is the kind of silent parity that keeps shared modules working, and it is worth noting because it is easy to miss.
Divergence is structural
The divergent features sit deeper: in how modules are packaged, how state is written, and how plans are evaluated. import inside a child module changes what a module is, because it can now carry its own adoption logic. The store block changes what can legitimately end up in state. State encryption changes the on-disk format of state completely. Linting adds a new class of diagnostics to plan. None of these can be patched over with a shim. A module that uses them is written for one tool.
The practical thesis
The combined effect is that “portable HCL” now has a stricter meaning. It means the subset of features both tools support and that both document in the same way. That subset still covers most real-world code: resources, data sources, for_each, moved and removed blocks, root-level import, ephemeral resources and write-only attributes. OpenTofu added ephemeral values and write-only attributes in 1.11, after Terraform shipped them in the 1.10/1.11 line. But the subset is no longer “everything except state encryption”. Teams that publish shared modules to both communities need to decide explicitly which side of the line they are on.
How Each New Feature Actually Works
This section goes through the headline features one at a time, using each project’s own documentation. HCL examples follow the current documented syntax. Where an example is illustrative rather than copied from the docs, it says so.
Terraform 1.16: the store block on terraform_data
Ephemeral values exist so that secrets can flow through a run without being written to the plan file or state. The catch is that some workflows need a value to survive: a generated password that must stay stable across runs, for example. Terraform 1.16 adds a store nested block to the built-in terraform_data resource for exactly that case (#38298).
The terraform_data reference documents four arguments. input is optional and write-only, so it can accept an ephemeral value. sensitive, when true, routes the value to sensitive_output instead of output. version, when set, means changes to input are ignored unless version also changes. replace, when true, turns a change in the stored output into a resource replacement instead of an update. The key sentence in the docs is that the value supplied during apply “will be copied to either of the store block’s output or sensitive_output attributes and stored in the resource’s state.”
# Pattern from the Terraform 1.16 terraform_data reference
ephemeral "random_password" "db" {
length = 24
}
resource "terraform_data" "db_password" {
store {
input = ephemeral.random_password.db.result
sensitive = true
version = 1 # bump to rotate; otherwise the first value sticks
}
}
output "db_password" {
sensitive = true
value = terraform_data.db_password.store.sensitive_output
}

Figure 2: How a store block captures an ephemeral value. It is write-only during plan and copied into state during apply.
The sequence matters. At plan time the ephemeral resource opens and produces a value that exists only in memory, and the store input is write-only, so the plan carries no copy of the secret. At apply time the ephemeral resource is opened again. Its value is copied into sensitive_output, and that attribute is persisted in state like any other. The version argument is what gives you stability. Without it, a fresh random_password on every apply would keep changing the stored value.
The practical point is easy to get backwards. The store block is an explicit opt-in to persisting an ephemeral value, not a way to keep it out of state. It is useful and honest about what it does. But it means the secret’s protection now depends entirely on how your state is protected at rest: backend encryption, bucket policies, HCP Terraform access controls. That is the exact problem OpenTofu’s client-side state encryption was built to address, which is why this feature highlights the divergence instead of closing it. If your secrets already live in a dedicated store such as the one in our OpenBao secrets management tutorial, read them with an ephemeral resource at run time rather than copying them into state.
Terraform 1.16: import blocks inside child modules
Configuration-driven import arrived in Terraform 1.5, but the block could only live in the root module. You could target an address inside a module (to = module.app.aws_s3_bucket.logs), but you could not write the block inside the module. Terraform 1.16 lifts that restriction (#38352). Inside a module, the to address is module-local.
# modules/service/adopt.tf — illustrative, Terraform 1.16+
variable "adopt_bucket_id" {
type = string
default = null
}
import {
for_each = var.adopt_bucket_id == null ? [] : [var.adopt_bucket_id]
to = aws_s3_bucket.logs
id = each.value
}
This turns adoption into a module input. A greenfield caller passes nothing, so for_each collapses to an empty set. A brownfield caller passes the existing ID, and the module adopts it on the next plan. For platform teams, this is the most significant change in the release, because it lets a reusable module ship its own migration logic instead of documenting root-level import stanzas that every consumer has to copy.
On the OpenTofu side, the equivalent request (opentofu/opentofu#1914, “Import blocks inside child modules”) is still open with a pending-decision label, and nothing in the 1.12 or 1.13 RC changelogs adds it. On OpenTofu, a module containing an import block fails as soon as the configuration is loaded (for example at init or validate) with an “Invalid import configuration” error stating that “Import blocks are only allowed in the root module”. This is currently the sharpest portability break between the two tools.
lifecycle { destroy = false }: same name, differently documented
Both tools now accept destroy = false inside a resource’s lifecycle block: OpenTofu from 1.12, Terraform from 1.16 (#38784). Both removed blocks have long supported a nested lifecycle { destroy = false } for a one-off “forget, don’t destroy”. What is new is putting it on a live resource.
The Terraform 1.16 lifecycle reference describes it in one sentence: set it to false “to remove a resource from state without destroying the actual infrastructure resource.” The release notes put it more loosely, as preventing a resource from being destroyed. The docs say nothing more about replacement, the destroy command, or state persistence. The same page still says that lifecycle rules other than create_before_destroy are not recorded in state, and that lifecycle settings accept only literal values.
OpenTofu documents much more. When a resource with destroy = false is removed from configuration or requires replacement, OpenTofu plans a forget instead of a destroy. For replacement, it then creates the new instance. Under tofu destroy those resources are forgotten, and the command exits with a non-zero status to signal that not everything was removed. prevent_destroy has no effect once destroy = false is set. Crucially, the setting is persisted in state. OpenTofu keeps refusing to destroy the object until you set it back to true or remove the argument from the resource block. Deleting the whole resource block does not clear it; for single-instance resources, the docs say an explicit removed block with destroy = true overrides the stored setting. The argument accepts constant booleans only.
# Valid in Terraform 1.16+ and OpenTofu 1.12+ (syntax identical)
resource "aws_db_instance" "legacy" {
# ...
lifecycle {
destroy = false
}
}
So the syntax is portable, but the documented behaviour is not proven identical. Two cases need a test before you rely on them in a mixed estate. The first is deleting the resource block after destroy = false was applied: OpenTofu documents persistence, Terraform does not document it either way. The second is how terraform destroy reports forgotten resources in CI: OpenTofu documents a non-zero exit. For block deletion, the safest cross-tool pattern is still an explicit removed block with lifecycle { destroy = false }, which both tools document.
One more OpenTofu-specific note. Early 1.12 releases had a bug where tofu plan -out failed with invalid change action ForgetThenCreate when a destroy = false resource needed replacement (#4324). The fix shipped in 1.12.4 and is also listed in the 1.13 changelog. If you use saved plans, make sure you are on 1.12.4 or later and test the replacement path before relying on it.
OpenTofu 1.12: dynamic prevent_destroy
OpenTofu 1.12 lets prevent_destroy refer to input variables and other symbols in the same module. That makes the common pattern “protect in prod, allow teardown in ephemeral environments” a one-liner. Terraform’s lifecycle reference still requires literal values there, so a module using prevent_destroy = var.protect is OpenTofu-only.
# OpenTofu 1.12+ only
variable "protect" {
type = bool
default = true
}
resource "google_sql_database_instance" "main" {
# ...
lifecycle {
prevent_destroy = var.protect
}
}
OpenTofu 1.13 RC: experimental -lint
The 1.13 RC adds opt-in linting through a -lint flag on validate, plan, apply and refresh (#4310). The flag takes a comma-separated list of rule, group or global identifiers. You can exclude rules with a ! prefix, and an explicit inclusion always beats an exclusion. Four core rules ship: core:no-type-variable, core:count-instead-enabled (flags count = cond ? 1 : 0 where the 1.11 enabled meta-argument would do), core:unused-variable and core:unused-local.
tofu validate -lint='all,!core:unused-local'
According to the beta docs, rules run against the root module only. Findings are warnings, not errors. Linting is silently disabled for destroy operations. Unknown identifiers are ignored, which means a typo in the rule list fails open. It is labelled experimental and “may have breaking changes … even in minor releases”. Terraform has no built-in equivalent; teams use TFLint or policy tooling. The value here is not the four rules themselves. It is that OpenTofu has built a rule namespace into core, which future provider and configuration rules can plug into.
OpenTofu 1.13 RC: Symbol Libraries (experimental)
Symbol Libraries (#4052) let you define reusable functions and types in HCL-based libraries and load them into configuration, behind an experiment flag in the language block. The changelog says plainly that the design is still open for feedback and “should not be relied upon in production”. Treat it as a signal of direction: OpenTofu is willing to extend the language itself, which Terraform has so far avoided outside provider-defined functions.
OpenTofu 1.13 RC: state-encryption key provider options
OpenTofu’s client-side state and plan encryption uses key_provider, method, and state/plan blocks inside terraform { encryption { ... } }. The 1.13 RC adds context-binding options to three key providers. aws_kms gains encryption_context, a set of key-value string pairs passed on every GenerateDataKey and Decrypt call (#4298). gcp_kms gains optional additional_authenticated_data (#4287). openbao gains associated_data, a base64 value passed on each data-key generation and decryption (#4365).
# Illustrative — OpenTofu 1.13 RC; verify field shape against the 1.13 docs
terraform {
encryption {
key_provider "aws_kms" "main" {
kms_key_id = "alias/tofu-state"
region = "eu-west-1"
key_spec = "AES_256"
encryption_context = {
stack = "payments-prod"
}
}
method "aes_gcm" "main" {
keys = key_provider.aws_kms.main
}
state {
method = method.aes_gcm.main
enforced = true
}
}
}
Why it matters: encryption context and additional authenticated data bind a data key to a specific purpose. A state file from payments-prod then cannot be decrypted by a request that claims a different context, and in AWS the context also shows up in CloudTrail for audit. This makes state encryption auditable per stack, which is useful to regulated teams. It also adds one more thing you must never change casually. OpenTofu’s docs already warn that renaming key providers or methods after encryption breaks decryption, and that fallback blocks are the supported rollover path.
Feature-by-Feature Comparison Matrix
The table below uses Terraform 1.16 against OpenTofu 1.12 GA, with 1.13 RC items marked. “Portable” means the same HCL is accepted by both and documented with compatible meaning.
| Capability | Terraform 1.16 | OpenTofu 1.12 GA | OpenTofu 1.13 RC | Portable today? |
|---|---|---|---|---|
Variables/locals in module source/version |
Yes, since 1.15 | Yes, since 1.8, also backend config | Yes | Module sources yes; backend config no |
deprecated on variables/outputs |
Yes, since 1.15 | Yes, since 1.10 | Yes | Mostly |
convert() function |
Yes, since 1.15 | No | Yes | Only after 1.13 GA |
lifecycle { destroy = false } |
Yes, new | Yes, new (saved-plan replacement fix in 1.12.4) | Yes | Syntax yes; semantics test first |
Dynamic prevent_destroy |
No, literals only | Yes | Yes | No |
| Ephemeral values / write-only attributes | Yes | Yes, since 1.11 | Yes | Largely |
terraform_data store block |
Yes, new | No | No | No |
import blocks in child modules |
Yes, new | No, root only | No | No |
action blocks, terraform query |
Yes, since 1.14 | No | No | No |
| Client-side state/plan encryption | No, backend-side only | Yes | Yes, plus KMS context options | No |
| OCI registries for providers/modules | No | Yes, since 1.10 | Yes, per-repo credentials | No |
| Built-in linting | No | No | Experimental -lint |
No |
| WinRM provisioner connections | Supported | Deprecated | Removed | Only on Terraform |
| Official 32-bit builds | Not affected by these releases | Yes | Last series | Watch the 1.14 plan |
| Windows ARM64 | Yes, since 1.15 | No official | Yes, official | After 1.13 GA |
graph -format=mermaid |
Yes, new | No | No | CLI only, not HCL |
state show -json |
Yes, new | Yes, already present | Yes | CLI only, not HCL |
| Licence | BUSL 1.1 | MPL-2.0 | MPL-2.0 | n/a |
A few rows need a caveat. graph -format=mermaid, state show -json and Terraform 1.16’s workspace list -json are CLI conveniences, not language features. They affect scripts, not modules, and tofu state show already accepted -json, so on that point Terraform 1.16 is catching up. And “Largely” for ephemeral values reflects that both tools implement the concept, while provider support for ephemeral resources and write-only attributes varies by provider version, not by tool.
Compatibility Risk for Teams Running Both
Plenty of organisations run both: Terraform in the business unit tied to HCP Terraform, OpenTofu in the platform team, and a shared module registry between them. The new releases make four failure modes more likely.
First, the silent-accept problem. Some constructs fail loudly on the wrong tool. An import block in a child module fails at configuration load on OpenTofu, and a prevent_destroy = var.x fails at validate on Terraform. Those are the good cases. The dangerous case is destroy = false, which both tools accept but document differently. A module author who tested only one tool can ship a module whose “forget on removal” guarantee holds on one side and has not been verified on the other.
Second, state is no longer a shared format in practice. State written by OpenTofu with encryption enabled is unreadable to Terraform. That is by design, and it is the point of encryption. State written by Terraform 1.16 with a store block contains an attribute schema for terraform_data that OpenTofu’s built-in provider does not define. Nobody should be pointing both binaries at the same state file. But migration tooling that assumes “OpenTofu can read Terraform state” needs a caveat: resources using 1.16-only schema features need attention before a switch.
Third, lock-file and registry drift. OpenTofu 1.12’s tofu init writes the full set of zh: and h1: checksums for all supported platforms when it updates the dependency lock file, so expect extra h1: hashes in .terraform.lock.hcl as a one-off diff. Shared repos that alternate between tools can churn the lock file unless you pin one tool per root module.
Fourth, CI assumptions. OpenTofu documents a non-zero exit from tofu destroy when resources were forgotten instead of destroyed. An ephemeral-environment teardown pipeline that treats non-zero as failure will page someone the first time a destroy = false resource appears. The 1.13 RC -lint findings are warnings, so they will not fail a build unless your wrapper escalates them.
The mitigation is dull but effective. Pin the tool per root module with a .opentofu-version or .terraform-version file. Add a CI job that runs init and validate for every shared module on both binaries. And keep a written list of “one-tool-only” constructs that module reviewers check for. If your platform is moving towards Kubernetes-native control planes anyway, the calculus changes again; our piece on Crossplane composition functions for an internal developer platform covers when that is the better abstraction.
Stay, Switch or Wait: The Decision

Figure 3: A decision tree for teams on Terraform 1.15 or earlier. Commercial platform dependence and licensing come first, then feature needs, then legacy host constraints.
The tree starts with the question that decides most cases before any feature comparison: are you tied to HCP Terraform, Terraform Enterprise, Stacks or Sentinel? Several 1.16 changes are aimed squarely at that platform. Policy-plugin credentials now come from the cloud backend, plan and apply show policy evaluation summaries, and Stacks gains hostname inference and a caller symbol for actions. If your governance runs through HCP Terraform, the switching cost is a platform migration, not a binary swap. Stay on Terraform 1.16.
If you are not tied to the platform, licensing comes next. BUSL 1.1 restricts offering Terraform as a competing hosted service. For most end-user companies that is a legal review item rather than a blocker. For vendors, managed service providers and some public-sector buyers it is a hard line. If it is a blocker, you are choosing between OpenTofu versions, not tools.
Without a licence blocker, feature pull decides. Do you need client-side state encryption, for example because auditors object to secrets in state protected only by bucket ACLs? That points to OpenTofu. Do you need modules that carry their own import logic, or the store block? Those point to Terraform 1.16, because OpenTofu has neither. If neither pull is strong, either tool works. Choose on ecosystem factors such as registry, TACOS (Terraform Automation and Collaboration Software) support, and in-house skills.
Once you are heading to OpenTofu, the last branch is timing. If you still use WinRM provisioner connections or run on 32-bit hosts, go to 1.12 now. It warns about WinRM instead of failing, so you have a working release while you migrate those hosts to SSH. If you have no legacy constraints and can tolerate a short delay, wait for 1.13 GA. You get convert(), Windows ARM64 and the encryption-context options, without having to do a second upgrade soon after the first.
Decision matrix by team profile
| Team profile | Recommendation | Why |
|---|---|---|
| HCP Terraform / Stacks / Sentinel shop | Stay on Terraform 1.16 | Platform features dominate; binary swap is not the real cost |
| Platform team publishing brownfield-adoption modules | Terraform 1.16 | import in child modules has no OpenTofu equivalent yet |
| Regulated team, secrets in state are an audit finding | OpenTofu 1.12 now, 1.13 at GA | Client-side encryption; 1.13 adds KMS context binding |
| Vendor or MSP embedding IaC in a hosted product | OpenTofu | MPL-2.0 avoids the BUSL hosted-service question |
| Mixed Windows estate still on WinRM | Terraform, or OpenTofu 1.12 while migrating to SSH | WinRM is removed in the 1.13 RC |
| Air-gapped with an existing container registry | OpenTofu | OCI registry distribution for providers and modules |
| Small team, no strong pull | Either; pick one per repo | Portable subset still covers typical code |
Upgrade Checklist: OpenTofu 1.12 to the 1.13 RC
The 1.13 upgrade notes are short, but each item can cause a production diff. Run through them on the RC now so that GA is uneventful.

Figure 4: The OpenTofu 1.13 RC upgrade-risk path. Each check comes from the 1.13 changelog’s upgrade notes.
Start by pinning 1.12 everywhere, so the only change under test is the binary. Then work through the risks in order of how badly they fail.
WinRM connections fail outright. The winrm connection type is removed (#4012) because the upstream libraries are unmaintained. The changelog recommends OpenSSH on Windows. Search for type = "winrm" in connection blocks, including inside modules you consume.
base64gzip diffs are the subtle one. OpenTofu 1.13 uses a new optimised DEFLATE implementation, so base64gzip output is equivalent but not byte-equal to earlier releases. Any resource argument built from base64gzip (cloud-init user_data is the classic case) may show an update or even a replacement, depending on the provider. The data still decompresses to the same bytes, but the provider only sees a changed string. For immutable instances, that can mean a replacement you did not intend.
macOS runners must be on macOS 13 Ventura or later. Check self-hosted CI agents and developer laptops.
32-bit hosts (*_386, *_arm) still get official builds in 1.13, but it is the last series that will. The main-branch changelog already lists removal for 1.14. Start the 64-bit migration now.
SSH parser hardening may reject some malformed keys or wire formats that 1.12 accepted. Test provisioners that use unusual key formats.
Then run tofu plan with the RC against a non-production copy of each stack, and read every diff. An empty plan on 1.12 should stay empty on 1.13 unless one of the items above applies.
Checklist
- [ ] Pin
tofu1.12.x in CI and locally; record current plans as a baseline. - [ ] Replace every
connection { type = "winrm" }with SSH. - [ ] Grep for
base64gzip(and list which resources consume it; decide whether a one-time update is acceptable or needsignore_changes. - [ ] Confirm macOS runners are on 13+ and no 32-bit builds are in your pipelines.
- [ ] If you use
destroy = falsewith saved plans, confirm you are on 1.12.4+ and test the replacement path (the #4324 fix). - [ ] Trial
tofu validate -lint=alland triage the warnings; do not gate CI on it yet. - [ ] If you add
encryption_contextor AAD to a key provider, set up afallbackblock for rollover and test decryption of old state before rolling out. - [ ] Plan every stack with the RC; promote only at GA.
And for Terraform 1.15 to 1.16
Terraform’s upgrade is mostly additive, but one note stands out. bastion_host_key is now actually applied by provisioners (#38318). Before 1.16 a wrong value was silently ignored. After 1.16 it causes a connection failure. Verify every configured bastion host key before you upgrade. Also check whether any private-registry provider address containing an underscore in the namespace was previously worked around; 1.16 now accepts those addresses (#38894).
Trade-offs, Gotchas, and What Goes Wrong
The store block creates a false sense of safety. Because store.input is write-only and the resource marks its output sensitive, it is tempting to read it as “secret-safe”. It is not. The value ends up in state, and “sensitive” in Terraform only redacts CLI output. Anyone with read access to the state backend can read the value. Pair it with backend encryption and tight state access, or do not use it for secrets that have a proper home elsewhere.
version pinning in store can hide rotation failures. If you set version = 1 and forget about it, a rotated upstream secret never reaches state. That is the intended behaviour, and also an easy way to end up with an old credential that nobody notices. Treat version bumps as change-managed rotation events.
Module-level import blurs ownership. When a module adopts resources based on caller input, a wrong ID adopts the wrong object. The plan shows an import, but reviewers used to root-level imports may not look inside module diffs. Keep adoption inputs explicit and review imports at the module path.
destroy = false can orphan infrastructure. Forgetting is not deleting. Anything forgotten keeps running and billing, drops out of drift detection, and, per OpenTofu’s docs, re-adding the same address later triggers a create that may collide with the orphan. Tag forgotten resources before applying, and keep an inventory outside state.
Mixing dynamic prevent_destroy with destroy = false does nothing useful. OpenTofu documents that prevent_destroy has no effect once destroy = false is set. A module that exposes both as variables can produce combinations that look protective but are not.
Experimental means experimental. OpenTofu’s -lint output and Symbol Libraries syntax may change in minor releases. Do not build compliance gates on rule identifiers yet. And because unknown lint identifiers are ignored silently, a typo in your CI flag disables the check without any error.
Version-number anchoring. The most common mistake in cross-tool conversations is comparing version numbers. “OpenTofu is on 1.12 and Terraform is on 1.16, so OpenTofu is behind” is meaningless. Compare features against changelogs, never version digits.
Practical Recommendations
For most teams, the right move this quarter is not to switch tools, but to make the choice explicit per root module and per shared module. The portable subset still covers most real code. The problem is drifting into one-tool-only features without realising it.
If you are on Terraform, upgrade to 1.16.x after verifying bastion host keys, and adopt import in modules where you have real brownfield adoption work. Use the store block only for non-secret values, or for secrets whose state backend you would accept an auditor reading. If you are on OpenTofu, stay on 1.12 for production, test the 1.13 RC now against the checklist above, and move at GA. If you run both, add dual-binary validate to CI for every shared module, and publish a one-page “one-tool-only constructs” list for reviewers.
Short actionable list:
- [ ] Declare the tool per root module in a version file; ban cross-tool state access.
- [ ] Add
init+validateon both binaries for every shared module. - [ ] Flag
importin child modules,store, dynamicprevent_destroy, backend-config variables andencryption {}as one-tool-only in code review. - [ ] For block deletion, use
removed { lifecycle { destroy = false } }rather than relying on a persisted resource-leveldestroy = false. - [ ] Budget for the OpenTofu 1.13
base64gzipdiff and the WinRM removal before GA. - [ ] Re-evaluate at OpenTofu 1.13 GA and the next Terraform minor. Do not plan around unreleased features.
Frequently Asked Questions
Is OpenTofu 1.13 released?
Not as of 23 September 2026. OpenTofu published v1.13.0-beta1 on 27 August and v1.13.0-rc1 on 17 September 2026. The v1.13 changelog still lists 1.13.0 as “Unreleased”, and the documentation site labels it “1.13.x (beta)”. The latest GA release is OpenTofu 1.12, which went GA on 14 May 2026. The 1.13 series is already scheduled for support until 1 August 2027, so plan to test the RC now and upgrade production at GA rather than on a release candidate.
What are the main Terraform 1.16 new features?
Terraform 1.16.0 shipped on 26 August 2026. The main language changes are import blocks inside child modules, a store block on terraform_data that can capture ephemeral and sensitive values into state, lifecycle { destroy = false }, nested blocks as computed values for providers, and on_failure modes (halt, taint, continue) for action triggers. CLI additions include terraform state show -json, workspace list -json, graph -format=mermaid and console -scope. There is also a Linux s390x build. Patch releases have followed weekly, up to 1.16.3.
Does the Terraform store block keep ephemeral values out of state?
No, and that is the most common misreading. The store block’s input is write-only, so it accepts ephemeral values and the plan carries no copy of them. But during apply, the documented behaviour is that the value is copied into store.output or store.sensitive_output and stored in the resource’s state. It is an explicit way to persist an ephemeral value when you need it to be stable. Protect the state backend accordingly, or read secrets at run time with an ephemeral resource.
Is lifecycle destroy = false the same in Terraform and OpenTofu?
The syntax is identical and both tools describe the core idea the same way: remove the resource from state without destroying the real object. OpenTofu documents much more, including forget-then-create on replacement, a non-zero exit from tofu destroy, persistence of the setting in state, and that prevent_destroy is ignored when it is set. Terraform’s 1.16 docs do not describe those cases. Treat equivalence as unverified, and test deletion and replacement on each tool before relying on it.
Can OpenTofu use import blocks inside modules?
Not yet. OpenTofu still requires import blocks to live in the root module and returns “Import blocks are only allowed in the root module” otherwise. You can still target a module address from the root, for example to = module.app.aws_s3_bucket.logs. The feature request (opentofu/opentofu#1914) is open and marked pending-decision, and neither the 1.12 nor the 1.13 RC changelog adds it. Modules that rely on Terraform 1.16’s in-module imports are currently Terraform-only.
Should I migrate from Terraform to OpenTofu in 2026?
It depends on dependencies, not on features. If you rely on HCP Terraform, Stacks or Sentinel, the migration is a platform move, and Terraform 1.16 is the practical choice. If BUSL licensing is a blocker, or you need client-side state encryption or OCI registry distribution, OpenTofu is the better fit. Move to 1.12 now if you need it urgently, or wait for 1.13 GA otherwise. If neither applies, both tools handle typical code well. Pick one per repository and avoid mixing them.
Further Reading
- OpenTofu in production: a step-by-step Terraform migration tutorial
- Terraform vs Pulumi vs Crossplane: choosing an IaC tool in 2026
- OpenBao secrets management in production: a hands-on tutorial
- Crossplane composition functions for an internal developer platform
References
- Terraform v1.16.0 release notes — https://github.com/hashicorp/terraform/releases/tag/v1.16.0
- Terraform
lifecyclemeta-argument reference (v1.16.x) — https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle - Terraform
terraform_dataresource reference (v1.16.x) — https://developer.hashicorp.com/terraform/language/resources/terraform-data - OpenTofu v1.13.0-rc1 release — https://github.com/opentofu/opentofu/releases/tag/v1.13.0-rc1
- OpenTofu v1.13 CHANGELOG — https://github.com/opentofu/opentofu/blob/v1.13/CHANGELOG.md
- OpenTofu v1.12.0 announcement — https://opentofu.org/blog/opentofu-1-12-0/
- What’s new in OpenTofu 1.12 — https://opentofu.org/docs/v1.12/intro/whats-new/
- OpenTofu Resource Behavior (
destroy,prevent_destroy) — https://opentofu.org/docs/language/resources/behavior/ - OpenTofu Linting (v1.13 beta docs) — https://opentofu.org/docs/v1.13/language/linting/
- OpenTofu State and Plan Encryption — https://opentofu.org/docs/language/state/encryption/
- OpenTofu issue #1914, import blocks inside child modules — https://github.com/opentofu/opentofu/issues/1914
By Riju — about
