OpenTofu 1.13: A 'Light' Release That Quietly Redraws the Plan-Time Boundary
OpenTofu 1.13.0 shipped September 30, 2026, and the project is upfront about its size: the team deliberately shortened the development window to align with the Go release cycle, extending the security-support window for the series to August 1, 2027 but making this "a relatively light release in terms of new features." Don't let "light" fool you. Under the feature list sits a coherent architectural push: OpenTofu is redrawing the boundary between what it knows at plan time versus what it discovers at apply, and giving module authors tools to move that boundary. Some of those tools will bite legacy configs in production. We ran the real 1.13.0 binary against 1.12.7 to measure exactly how.
This analysis covers the release for platform engineers running IaC pipelines: what shipped, what breaks, what the experiments signal about OpenTofu's roadmap, and a migration sequence that avoids the three traps we found. If you want the short version: upgrade the CLI everywhere at once, audit your provisioners for WinRM before anything else, and inventory every base64gzip call before your next apply.
Release at a glance — v1.13.0 (stable), published 2026-09-30, tag v1.13.0. Support series ends August 1, 2027. Follow-up patch v1.13.1 (October 1) fixes tofu show -json on plans containing ephemeral resources (PR #4623) and stale ephemeral output re-evaluation (issue #4582). Run 1.13.1, not 1.13.0.
Why This Release Exists: The Unknown-Value Problem
OpenTofu's core contract is that the plan phase predicts the apply phase. When a value can't be predicted — a cloud-assigned id, an API-generated ARN — OpenTofu renders it as (known after apply) and treats it as an unknown value. The release blog is candid that this conservatism has real costs: features like enabled, for_each, and count reject unknowns outright, and policy engines evaluating the machine-readable plan JSON are "effectively blinded" — forced to choose between optimistically allowing a change they can't evaluate or conservatively blocking valid work.
The v1.13 answer is a family of hint functions. The idea: the module author often knows things OpenTofu can't infer — that an AWS VPC id always starts with vpc-, that a list will have at least one element. Instead of hardcoding those assumptions into spaghetti conditionals, you declare them, and OpenTofu converts declared hints into known plan-time facts. If the hint turns out to be wrong at apply, the function call itself fails — fail-loudly at the point of the lie rather than corrupting downstream logic.
The assume... Family: Hints That Move the Plan-Time Boundary
PR #4449 (by @apparentlymart, merged August 13, 2026) introduces convert and the assume... family, documented at opentofu.org/docs/language/functions/assume_family. The roster:
| Function | Hint declared | Fails when |
|---|---|---|
convert(value, type) | The value's type (for wholly unknown-typed values) | Type conversion impossible |
assumenotnull(value) | Value will not be null | Value is null at apply |
assumestringprefix(string, prefix) | String will start with prefix | Prefix mismatch at apply |
assumeequal(actual, expected) | Value will equal a fully known expected value | Inequality at apply |
assumelistlength… / assumesetlength… / assumemaplength… | Collection length bounds (min/max variants) | Length outside bounds at apply |
We tested the mechanism with the real binary. The canonical pattern — a terraform_data resource whose output is unknown and unknown-typed at plan time:
resource "terraform_data" "example" {
input = format("prod-%s", timestamp())
}
# Bare comparison - unknown-typed value
output "bare" {
value = terraform_data.example.output != null
}
# convert pins the type, assumenotnull pins the nullness
output "assumed" {
value = assumenotnull(convert(terraform_data.example.output, string)) != null
}
Changes to Outputs:
+ assumed = true
+ bare = (known after apply)
One line of hints converts an opaque unknown into a known true at plan — the policy-checking and enabled/for_each use cases unlock immediately. The failure mode is equally explicit: calling assumenotnull alone on the unknown-typed value refuses to guess, with the error text pointing you at convert — we hit exactly this on our first attempt:
Error: Invalid function argument
on main.tf line 14, in output "assumed_check":
14: value = assumenotnull(terraform_data.example.output) != null
├────────────────
│ while calling assumenotnull(value)
Invalid value for "value" parameter: given value must have a known type;
consider using the "convert" function to specify a type to assume.
The docs' "Forward and Backward Compatibility" section carries the operational caveat that matters for shared modules: validity is promised, but when problems get detected can shift between versions — a config that fails at apply on 1.12 may fail at plan on 1.13, and vice versa. Your CI's plan-stage failures will move.
Linting: The Most-Voted Issue Becomes an Experiment
The linting request is "the most highly-voted issue in our GitHub repository" per the team's A Vision for Built-in Linting post (September 3, 2026, issue #4310, RFC tracker opened by @yottta). The v1.13 slice is deliberately thin: internal plumbing for rule selection and warning reporting, four built-in rules, one CLI flag. The vision doc's long-term scope is far more interesting — rules re-checked throughout validate/plan/apply (passing, failing, or unknown per phase), declarative rules in module-like syntax, reusable shared rulesets, and an escape hatch via provider plugins so a rule can call, say, aws_iam_principal_policy_simulation from the AWS provider. That's OPA-for-your-modules, embedded in the CLI. Nothing shipped yet — the ruleset language gets its own future RFC.
What shipped works. We ran the deliberate dirt config through it:
Warning: Experimental linting enabled
The linting functionality is under active development and may change or
break in future releases.
Warning: Variable with no type (core:no-type-variable)
on main.tf line 2:
2: variable "env" {
Variable "env" has no type specified.
Warning: Input Variable not used (core:unused-variable)
on main.tf line 6:
6: variable "unused_var" {
Found no usage of the variable "unused_var".
Warning: Local value not used (core:unused-local)
on main.tf line 12, in locals:
12: unused_local = "also never used"
Found no usage of the local value "unused_local".
The four initial rules — core:no-type-variable, core:count-instead-enabled, core:unused-variable, core:unused-local — are style-grade, not policy-grade. The interesting rule is core:count-instead-enabled: it flags the legacy Terraform pattern count = var.cond ? 1 : 0 where OpenTofu's native enabled meta-argument would do, and our count = var.enable_feature ? 1 : 0 config triggered it exactly. All lint output is warnings only — non-blocking by design, though nothing stops you from grepping CI logs. The flag also accepts a comma-separated rule list (-lint=core:unused-variable ran clean against just that rule, two warnings in our test: the experimental banner plus the rule hit). One more version note: 1.12.7 rejects the flag entirely (Error: Failed to parse command-line flags — flag provided but not defined: -lint), so any pipeline sharing configs across CLI versions needs feature detection, not version strings.
base64gzip: Equivalent, Not Equal — Measured
The changelog warns that base64gzip output "is equivalent to _but not equal to_ previous releases" thanks to a new optimized DEFLATE implementation (the Go upgrade forced the rewrite). We measured the drift on both ends:
| Input | 1.12.7 output | 1.13.0 output | Delta |
|---|---|---|---|
| 150-byte string | 204 b64 chars (153 gzip bytes) | 240 b64 chars (180 gzip bytes) | +17.6% larger |
| 7,787-byte generated text | 604 b64 chars (452 gzip bytes) | 596 b64 chars (446 gzip bytes) | −1.3% smaller |
Both outputs decompress to byte-identical payloads (verified with Python's gzip module) — the format contract, per RFC 1951, holds. The size direction flips with payload size, which is the real story: the new implementation is not uniformly "better," it's differently-sized. The changelog's warning is the operational one: if base64gzip feeds a managed resource argument, the changed bytes mean OpenTofu will propose an update or replacement on your next plan — on every resource using the function, fleet-wide, the moment you upgrade. Terraform's own base64gzip docs describe the same function; mixed Terraform/OpenTofu estates already live with cross-tool drift, but 1.13 makes it same-tool drift too. Inventory your base64gzip call sites before upgrading; if the consumer only decompresses (user-data pipelines), the churn is cosmetic but will still pollute plan diffs and trigger approval workflows.
WinRM: "Removed" Means Apply-Time, Not Validate-Time
The changelog says WinRM provisioner connections are "no longer supported" (PR #4012, @apparentlymart, April 2026): the upstream Go libraries were unmaintained, and modern Windows ships native OpenSSH. The precise mechanics, verified in source and by running the binary:
- Validate/plan: warning only. We ran
tofu validateon a config with atype = "winrm"connection block and got no error — just "WinRM connection type is deprecated … will be removed in a future version." Readinginternal/tofu/node_resource_validate.goconfirms the schema deliberately retains the winrm attributes "so that older modules … can still pass validation enough to reach the error about this type being unsupported." A green validate is not proof your provisioners work. - Apply: hard error.
internal/communicator/communicator.go'sNew()returns'winrm' connections are not supported in OpenTofu v1.13 or later— the connection is dead the moment a provisioner runs. - Migrate to SSH. Microsoft's OpenSSH guide covers the server-side setup; your
connection {}block changestypefromwinrmtosshand user/password to SSH credentials.
The trap: a pipeline that gates on tofu validate (green) then runs apply in a later stage will discover dead provisioners only when they fire, potentially mid-rollout. Audit for type *= *"winrm" across your configs before the CLI lands in CI runners.
errored.tfstate: Crash Recovery Gets Real
A quiet but production-grade change (PR #4064, @cam72cam, resolves issue #4027): when the apply graph walk hits a Go runtime panic, OpenTofu now dumps partial state to errored.tfstate before exiting with code 11 (same as SIGSEGV, deliberately). The wiring, from source: internal/tofu/graph.go wraps every vertex evaluation goroutine with a panic handler whose hailMaryStateDump callback locks the in-memory state and hands it to the backend's BackupStateForPanic (internal/backend/local/backend_local.go), which writes errored.tfstate and prints tofu state push errored.tfstate as the recovery command. The recovery doc string is blunt about the stakes: running apply again before pushing the errored state "will create a forked state, making it harder to recover."
We could not trigger the panic path in the shipped binary — the e2e crash hook (TOFU_E2E_APPLY_RESOURCE_PANIC) is compiled only into test builds via E2ETestingFeaturesEnabled — so we cite the mechanism from source and the project's own e2e test, not from a live crash. Treat this as: a crash that used to leave you reconstructing state from provider APIs now leaves a partial-state file. Add errored.tfstate to your .gitignore (it can contain secrets) and to your incident runbook: push before re-applying.
Symbol Libraries: The Module Problem, Rebuilt
The most forward-looking experiment: Symbol Libraries — reusable collections of values, functions, and type aliases importable from the same source types as modules. The motivating frustration, per the docs: modules are for sharing stateful resource definitions, not for sharing "arbitrary data and logic" — so teams duplicating DNS conventions or naming standards across modules get no first-class reuse. Implementation PR #4474 (@cam72cam, merged August 24, 2026); the design discussion lives in the still-open RFC #4052. Syntax sketch from the docs:
typedef "recordset" {
type = set(
{
name = string
type = string
ttl = number
records = set(string)
}
)
}
function "a_records" {
type = symbols::dns_recordset()
parameter "records" {
type = map(set(string))
}
parameter "ttl" {
type = number
}
return = [for name, ip_addrs in parameter.records : {
name = name
type = "A"
ttl = parameter.ttl
records = ip_addrs
}]
}
Ignore the syntax details; they will change. The signal is the direction: OpenTofu is building toward a shared-library tier below modules — the thing Terraform never shipped. Docs warn it's "experimental and subject to significant changes even in patch releases," and the changelog adds the standard do-not-use-in-production line. For platform teams: this is the feature to watch for internal module registries, but nothing to build on until the RFC closes.
Everything Else Worth 30 Seconds
- Windows on ARM64 is now an official platform (PR #4450, @apparentlymart) — 8 release artifacts confirmed in the tag. Provider coverage varies; third-party providers set their own platform policy in the OpenTofu Registry, so pin provider versions that ship
windows_arm64builds. - 32-bit deprecation: v1.13 is the last series with official
_386/_armbuilds;tofu initnow warns on 32-bit (issue #4018). If you run tofu in 32-bit CI runners or embedded devices, the clock started. - State encryption:
gcp_kmsgainsadditional_authenticated_data(PR #4287, @diofeher),aws_kmsgainsencryption_context(PR #4298, @KrishnaSindhur), and OpenBao gainsassociated_data(PR #4365, @KrishnaSindhur) — the encryption_context/associated-data fields map to the AWS KMS and OpenBao APIs of the same names, tightening integrity checks on state at rest. - Plan files now embed provider schemas (PR #4490, @Yantrio):
tofu showon a saved plan no longer relaunches providers where possible — faster CI inspection of plan files, and the schemas ship inside the artifact. providers lock -oci-mirror(verified in CLI help) mirrors theoci_mirrorblock from provider_installation config as a one-shot CLI template — useful for air-gapped lock generation without editing the CLI config.- Trace propagation:
local-execnow exportsTRACEPARENTto child processes when OTel tracing is active, per the W3C Trace Context spec (issue #4014) — provisioner scripts can now join the pipeline's trace tree. - Unicode 17 string processing (PR #4478),
cidrsubnetssupports >32-bit IPv6 prefix extensions (PR #4042), andtofu planstops printing the redundant explanation after "No changes" (issue #4340 — verified side-by-side: 1.12.7 prints the two-sentence explanation, 1.13.0 prints the one-liner). tofu testinstance overrides now support wildcards (PR #4067, @lbordowitz).- Security fix:
connection.script_pathis now escaped, closing an argument-injection path into remote-exec (PR #4330, @yottta).
Hidden Costs and Migration Order
The upgrade itself is a version bump; the costs hide in the fleet around it:
- Plan-diff noise from base64gzip — the churn described above hits every consumer of the function on first plan after upgrade. Schedule the upgrade inside a change window if those diffs route through approvals.
- WinRM configs pass validate and die at apply. Grep first. A warning-only validate stage gives false confidence.
- macOS floor is now 13 Ventura — audit developer machines still on older macOS.
- Support clock: the v1.13 series EOLs August 1, 2027. If your org takes months to roll CLI upgrades, this release's shortened development window means the next one arrives sooner — plan a recurring, boring upgrade cadence rather than hero migrations.
Sequence that worked for our test configs: (1) grep for winrm and base64gzip across all roots; (2) upgrade CI runners and developer machines together so -lint and plan diffs move in lockstep; (3) run 1.13.1 (not .0) everywhere; (4) expect a one-time fleet-wide plan diff from base64gzip consumers and pre-approve it; (5) only then experiment with -lint=all in CI as a warning-only stage.
Who Should Skip This Cycle
Honest contraindications: if your estate is 100% Terraform-managed with no OpenTofu usage, this release is a read-only signal, not a to-do. If you rely on WinRM provisioners heavily and can't migrate to SSH quickly, stay on 1.12.x (supported until per-series policy) and schedule the migration — 1.13 will not work for you at apply time. And if your team has no capacity to absorb a fleet-wide plan-diff event this quarter, the base64gzip churn alone argues for waiting until you can window it. The assume/linting/symbol features are all opt-in and non-breaking — there's no feature pressure forcing an immediate jump.
The Verdict
"Light" is the wrong adjective. OpenTofu 1.13 is a boundary-setting release: it formalizes how module authors teach the planner what they know (assume...), plants the first stake of in-CLI policy tooling (linting), kills a dead protocol dependency the right way (WinRM: warn at validate, fail at apply), and quietly fixes the worst day of any IaC operator's life (errored.tfstate). The base64gzip churn is the only genuine nuisance, and it's a one-time cost. For IaC teams already on OpenTofu, upgrade to 1.13.1 this cycle. For Terraform teams, the interesting question this release poses is the one OpenTofu has been asking since the fork: which roadmap treats the CLI as a programmability platform rather than a declarative file format? The assume family, linting vision, and Symbol Libraries all point the same direction.
Maintainer credit where due: @apparentlymart carried the release's architectural spine (assume/convert, WinRM removal, ARM64, Unicode 17, IPv6 cidrsubnets), @cam72cam shipped the crash-recovery and Symbol Libraries work, and @yottta, @KrishnaSindhur, @diofeher, and @lbordowitz landed the linting RFC, KMS/OpenBao encryption fields, and test-override improvements. Community releases this dense don't happen without that bench.