Terraform 1.16 and OpenTofu 1.13: Where They Have Diverged

For its first year OpenTofu was described as a drop-in replacement for Terraform, and for most configurations that was accurate. It is three years since the fork. Each project has shipped features the other does not have, each has made small decisions the other did not, and “drop-in” is now a claim to test, not to assume.

Feature comparison pages exist in quantity, and most are written from release notes. This article takes the other route. Twenty-four small configurations were run on Terraform 1.16.5 and OpenTofu 1.13.1, on the same machine, with a harness that records whether each tool accepts the configuration, where it stops, and what it says. The two previous articles in this series ran one test suite on both tools and found two differences by accident. This one goes looking.

What an architect needs from the comparison

  • Which features exist on only one side, checked on the current releases and not remembered from a blog post.
  • Which things both tools accept and do differently. These are the dangerous ones, because nothing fails.
  • Whether state can move between them, in both directions, and what stops that.
  • What a module author has to do to support both.
  • A way to re-run the comparison when either tool ships a release, without rewriting it.

The harness

Each experiment is a folder with a few lines of configuration. A JSON file gives it a title, the commands to run and the recorded result for each tool. The harness copies the folder, runs the steps with each binary, and stops at the first failure.

"""Run every experiment on both tools and record what each one did.

    python run_matrix.py <terraform binary> <tofu binary> <output directory>

Each experiment is a small configuration. A tool refusing it is a result, not an error. The run fails
only when a result differs from experiments.json, which is how a release that changes behaviour shows up.
"""
from __future__ import annotations

import json
import os
import pathlib
import shlex
import shutil
import subprocess
import sys
import tempfile

HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_STEPS = ["init -input=false", "validate", "apply -input=false -auto-approve"]


def first_error(output: str) -> str:
    lines = output.splitlines()
    for index, line in enumerate(lines):
        if (line.startswith("Error:") or line.startswith("│ Error:")) and "encountered problems during initialisation" not in line:
            rest = [l.strip("│ ").strip() for l in lines[index + 1:index + 12]]
            detail = " ".join(l for l in rest if l and not l.startswith("on ") and not l[:1].isdigit() and not l.startswith("with "))
            return (line.strip("│ ").strip() + " | " + detail)[:300]
    return (lines[-1] if lines else "")[:300]


def run(tool: str, other: str, experiment: dict) -> dict:
    work = pathlib.Path(tempfile.mkdtemp())
    shutil.copytree(HERE / "experiments" / experiment["id"], work, dirs_exist_ok=True)
    env = {**os.environ, "TF_IN_AUTOMATION": "1", "TF_CLI_ARGS": "-no-color", "CHECKPOINT_DISABLE": "1", **experiment.get("env", {})}
    for step in experiment.get("steps", DEFAULT_STEPS):
        # A step written as "other:<command>" runs with the other tool, in the same directory.
        binary, command = (other, step[len("other:"):]) if step.startswith("other:") else (tool, step)
        if binary is other:
            shutil.rmtree(work / ".terraform", ignore_errors=True)
        done = subprocess.run([binary, *shlex.split(command)], cwd=work, env=env, capture_output=True, text=True)
        if done.returncode != 0:
            stage = ("the other tool's " if binary is other else "") + command.split()[0]
            return {"result": "fail", "stage": stage, "detail": first_error(done.stdout + done.stderr)}
    note = ""
    if (work / "check.py").exists():
        check = subprocess.run([sys.executable, "check.py", tool], cwd=work, env=env, capture_output=True, text=True)
        note = check.stdout.strip()
        if check.returncode != 0:
            return {"result": "fail", "stage": "check", "detail": note}
    return {"result": "pass", "stage": "", "detail": note}


def main() -> int:
    tools = {"terraform": sys.argv[1], "tofu": sys.argv[2]}
    out = pathlib.Path(sys.argv[3])
    out.mkdir(parents=True, exist_ok=True)
    experiments = json.loads((HERE / "experiments.json").read_text(encoding="utf-8"))
    rows, unexpected = [], 0
    for experiment in experiments:
        row = {"id": experiment["id"], "title": experiment["title"]}
        for name, binary in tools.items():
            row[name] = run(binary, next(b for n, b in tools.items() if n != name), experiment)
            row[name]["expected"] = experiment["expect"][name]
            if row[name]["result"] != experiment["expect"][name]:
                unexpected += 1
        rows.append(row)
        print(f"{row['id']:<40} terraform={row['terraform']['result']:<5} tofu={row['tofu']['result']:<5}"
              + ("   UNEXPECTED" if any(row[t]["result"] != row[t]["expected"] for t in tools) else ""))
    (out / "matrix.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
    lines = ["| Experiment | Terraform | OpenTofu |", "| --- | --- | --- |"]
    for row in rows:
        cells = [f"{row[t]['result']}" + (f" at {row[t]['stage']}" if row[t]["stage"] else "") for t in tools]
        lines.append(f"| {row['title']} | {cells[0]} | {cells[1]} |")
    (out / "matrix.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"\n{len(rows)} experiments, {unexpected} unexpected result(s)")
    return 1 if unexpected else 0


if __name__ == "__main__":
    sys.exit(main())

Two design points. A tool refusing a configuration is recorded as a result and does not fail the run. The run fails only when a result differs from the recorded one, so the matrix doubles as a regression test: point it at next month’s releases and anything that changed is flagged. And every experiment uses the built-in terraform_data resource or the local provider, so there is no cloud account and nothing to clean up.

The first run flagged six of eighteen experiments as unexpected. Four were features the experiment list had marked as specific to one tool and both accepted: const variables, state show -json, contains() with a null element and convert(). That is the argument for running the comparison. The other two were differences in behaviour, and one of them is the most interesting finding below.

Three columns: features only OpenTofu accepted (state encryption, variables in the backend, provider for_each, -exclude, enabled, prevent_destroy from a variable, the language block), features only Terraform accepted (import blocks in child modules, override_during in tests), and features both accept with different behaviour (.tofu files, required_version, destroy exit code, lock file registry host).
The 24 experiments grouped by outcome.

The matrix

ExperimentTerraform 1.16.5OpenTofu 1.13.1
Plain configuration (control)passpass
State and plan encryption in the terraform blockrefused at initpass
Variable in the backend blockrefused at initpass
for_each on a provider configurationrefused at initpass
apply -exclude=ADDRESSrefused: flag not definedpass
lifecycle { enabled = ... }refused at initpass
prevent_destroy set from a variablerefused at initpass
lifecycle { destroy = false }, then destroypass, exit 0accepted; destroy exits non-zero
import block inside a child modulepassrefused at init
override_during = plan in a testpassrefused at init
Ephemeral input variablepasspass
main.tofu beside main.tfpass, loads main.tfpass, loads main.tofu
required_version = ">= 1.16.0" in a .tf filepasspass
required_version = ">= 99.0.0" in a .tf filerefused at initpass
required_version = ">= 99.0.0" in a .tofu filepass (file not read)refused at init
language { compatible_with { opentofu = ">= 99.0.0" } }refused: unknown blockrefused: incompatible
Variable with const = truepasspass
Variable with deprecated = "..."passpass
state show -jsonpasspass
contains() with a null elementpasspass
convert(value, type)passpass
Provider address written to the lock fileregistry.terraform.io/...registry.opentofu.org/...
Plain state written by this tool, planned by the otherno changesno changes
Encrypted state written by this tool, planned by the othernot applicableTerraform refuses the configuration

Only in OpenTofu

Seven experiments passed on OpenTofu and were refused by Terraform. Three of them change designs.

State encryption. OpenTofu can encrypt state and plan files on the client, before they reach any backend:

# Client-side encryption of state and plan files, configured in the terraform block.
variable "state_passphrase" {
  type      = string
  sensitive = true
}

terraform {
  encryption {
    key_provider "pbkdf2" "passphrase" {
      passphrase = var.state_passphrase
    }

    method "aes_gcm" "default" {
      keys = key_provider.pbkdf2.passphrase
    }

    state {
      method   = method.aes_gcm.default
      enforced = true
    }

    plan {
      method   = method.aes_gcm.default
      enforced = true
    }
  }
}

resource "terraform_data" "record" {
  input = "patient-index-shard-7"
}

After an apply, the state file did not contain the test value and did contain an encrypted_data field; the control experiment’s state held the value in clear text on both tools. For a regulated workload this is the feature with the most weight. State files routinely contain connection strings, generated passwords and resource attributes that describe a clinical system, and backend-side encryption protects them only from someone who cannot read the bucket. Terraform’s answer is different in kind: ephemeral values and write-only arguments that keep secrets out of state in the first place. Both tools accepted the ephemeral variable experiment, and neither wrote its value to state.

Provider for_each. One provider configuration per region or account, generated from a collection:

# One provider configuration per element of a set, selected per resource instance.
terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "2.5.3"
    }
  }
}

variable "regions" {
  type    = set(string)
  default = ["eu-west-1", "eu-central-1"]
}

provider "local" {
  alias    = "by_region"
  for_each = var.regions
}

resource "local_file" "marker" {
  for_each = var.regions
  provider = local.by_region[each.key]

  filename = "${path.module}/out/${each.key}.txt"
  content  = each.key
}

Terraform’s response is informative: The provider argument name "for_each" is reserved for use by Terraform in a future version. Multi-region and multi-account roots are where this removes the most copy-and-paste.

Lifecycle decided by input. A resource that exists or not without count, and deletion protection that depends on the environment:

# Zero-or-one instance of a resource without count and without an index on every reference.
variable "with_audit" {
  type    = bool
  default = false
}

resource "terraform_data" "audit" {
  input = "audit"

  lifecycle {
    enabled = var.with_audit
  }
}
# prevent_destroy decided by an input, for example on in prod and off in dev.
variable "protect" {
  type    = bool
  default = true
}

resource "terraform_data" "record" {
  input = "a"

  lifecycle {
    prevent_destroy = var.protect
  }
}

Terraform refuses both with Variables not allowed or An argument named "enabled" is not expected here. The other OpenTofu-only results were a variable in the backend block and the -exclude flag, which applied one of two resources and left the other out of state.

Only in Terraform

Two experiments passed on Terraform and were refused by OpenTofu.

# An import block that lives with the resource it imports, inside a child module.
import {
  to = terraform_data.adopted
  id = "existing-object-id"
}

resource "terraform_data" "adopted" {}

An import block inside a child module lets a module carry the adoption of existing resources with it. OpenTofu 1.13.1 says Import blocks are only allowed in the root module. The second is override_during = plan in tests, which the first article in this series ran into.

This list is short because the experiments are limited to what runs without a cloud provider, and it understates Terraform’s side. Terraform 1.16 and its recent predecessors also added provider-defined actions with lifecycle triggers, list resources with terraform query, a store block on terraform_data, and the Stacks and policy features tied to HCP Terraform. None of those were run here.

Accepted by both, different in effect

These four are the reason to run a comparison and not read one.

required_version in a .tf file is not enforced by OpenTofu 1.13.1. A module that declares required_version = ">= 99.0.0", which no release of anything satisfies, was refused by Terraform and was initialised, validated and applied by OpenTofu. The same constraint in a file named versions.tofu was enforced, and so was the block OpenTofu introduced in 1.12 for the purpose:

# The OpenTofu way to state compatibility: a language block.
language {
  compatible_with {
    opentofu = ">= 99.0.0"
  }
}

resource "terraform_data" "record" {
  input = "a"
}
# The same impossible constraint as experiment 13, in a file only OpenTofu reads.
terraform {
  required_version = ">= 99.0.0"
}

OpenTofu’s documentation explains the reasoning: older releases read required_version as an OpenTofu constraint, which broke modules written for Terraform version numbers, so the constraint was separated. The consequence for a module author is concrete. A required_version in versions.tf now protects Terraform users only. If the module relies on a minimum OpenTofu version, it has to say so in a .tofu file or a language block, or nothing stops an old OpenTofu from trying.

A .tofu file replaces the .tf file of the same name. With main.tf and main.tofu side by side, Terraform reported an output from the first and OpenTofu from the second. This is the mechanism for shipping one module to both tools, and it is also a way for the two to run different code from the same commit without anyone noticing in review.

destroy with a forgotten resource exits differently. Both tools accept lifecycle { destroy = false }. terraform destroy exits 0. tofu destroy completes and exits non-zero with Destroy was successful but left behind forgotten instances. A pipeline that treats a non-zero exit as failure will report a failed teardown on one tool and a clean one on the other; OpenTofu has a -suppress-forget-errors flag for that case.

The lock file records a different registry host. registry.terraform.io/hashicorp/local on one side, registry.opentofu.org/hashicorp/local on the other. A lock file committed from one tool does not pin anything for the other.

Moving state between the tools

Diagram: plain state written by Terraform is planned by OpenTofu with no changes and the reverse also holds; once the OpenTofu encryption block is added, Terraform refuses the configuration, so that step is one-way. Three points to settle before a migration: lock file, version constraints and state encryption.
Plain state moved in both directions with no changes. Encryption is the step that cannot be taken back casually.

State written by Terraform 1.16.5 was planned by OpenTofu 1.13.1 with no changes, and state written by OpenTofu was planned by Terraform with no changes. That is one resource of a built-in type on a local backend, so it shows the format is still shared; it does not show that every provider’s state survives the trip, and a real migration should be rehearsed on a copy of real state.

The direction stops being symmetric the moment an OpenTofu-only feature is in the configuration. With the encryption block present, Terraform does not get as far as reading the state: it refuses the configuration. The same is true, less dramatically, of every row in the “only in OpenTofu” section. Adopting those features is the real migration decision, and it can be taken later than the switch of binary.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    A[On Terraform today] --> B{A reason to move? Licence terms, state encryption, a feature you need}
    B -->|No| C[Stay. Re-run the matrix when either tool ships]
    B -->|Yes| D[Run your own modules and tests on both tools in CI]
    D --> E[Switch the binary. Regenerate lock files. Move version constraints]
    E --> F{Still reversible: plain state reads both ways}
    F --> G[Adopt OpenTofu-only features: encryption, provider for_each, enabled]
    G --> H[One-way from here. Terraform refuses the configuration]
    classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
    classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
    classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
    classDef policy fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
    class A,B,D,E,F compute
    class C store
    class G policy
    class H danger

If you maintain modules for both

  • Test on both in CI. The same .tftest.hcl files ran on both tools in the earlier articles once tool-specific arguments were kept out of them.
  • State compatibility twice. required_version in versions.tf for Terraform; a language block or required_version in versions.tofu for OpenTofu.
  • Do not commit one tool’s lock file for the other. Generate each in its own pipeline.
  • Use .tofu overrides sparingly and review them as code that only half your users run.
  • Stay in the shared subset for anything published: every “only in” row above breaks one audience.

Choosing

ConsiderationTerraformOpenTofu
LicenceBusiness Source License since 2023MPL 2.0, under the Linux Foundation
Secrets and stateEphemeral values and write-only arguments keep secrets out of stateThe same, plus client-side encryption of state and plan
Multi-region and multi-account rootsProvider configurations written outProvider for_each
Adopting existing resourcesimport blocks in any moduleimport blocks in the root module
Commercial platformHCP Terraform, Stacks, policy integrationNone of its own; works with third-party platforms
Risk to weighLicence terms for your use; vendor directionA smaller vendor ecosystem for support contracts

The licence row is a statement of fact, not advice; whether the Business Source License affects your organisation is a question for whoever advises you on licensing. On the engineering evidence here, neither tool is a subset of the other. An estate that needs client-side state encryption has a reason to be on OpenTofu today. An estate that leans on HCP Terraform has a reason to stay. Everyone else can make the decision slowly, because the first step is reversible.

⚖️
SECURITY AND DATA PROTECTION

State is a data store and deserves the treatment of one. On either tool: restrict who can read the backend, turn on backend encryption and versioning, and keep secrets out of state with ephemeral values where providers support them. Client-side encryption adds protection against a reader of the bucket and adds a key to manage: lose the passphrase or the key and the state is gone, so key custody and recovery need a written procedure before it is switched on. The passphrase in this sample is a throwaway value in a test and must not be copied.

Cost

Running both tools in CI costs a second download and a second test run, a few seconds for a module. Supporting both in published modules costs discipline: staying in the shared subset and maintaining two version constraints. A migration’s cost is mostly rehearsal, and state encryption adds key management as a standing operational duty. The cheapest thing in this article is the matrix itself: twenty-four experiments on two binaries completed in a few minutes on a hosted runner, and it can be re-run on every release of either tool.

Key takeaways

  • Neither tool is a subset of the other. Seven experiments passed only on OpenTofu 1.13.1 and two only on Terraform 1.16.5, among those that run without a cloud provider.
  • OpenTofu 1.13.1 did not enforce required_version from a .tf file. Module authors need a language block or a .tofu file to constrain OpenTofu.
  • Plain state moved between the tools in both directions with no changes. Encrypted state does not move back.
  • Several differences produce no error: which file is loaded, which exit code destroy returns, which registry the lock file names.
  • Four assumptions about which tool had which feature were wrong on the first run. Run the comparison; do not remember it.

If your experience of either tool contradicts a row in the matrix, leave a comment with the version so it can be added as an experiment.

ℹ️
HOW THIS WAS CHECKED

All results are from GitHub Actions on ubuntu-latest with Terraform 1.16.5 and OpenTofu 1.13.1 downloaded from their release pages (run 43300370959): 24 experiments, none differing from the recorded result. The listings are the files in the samples repository. Every experiment uses the built-in terraform_data resource or the local provider with a local backend, so nothing here covers cloud providers, remote backends, HCP Terraform, Stacks, actions or queries. Features attributed to release notes were not run. The licence statements are from the projects’ own pages.

Glossary

  • State— The file in which Terraform or OpenTofu records the resources it manages and their attributes.
  • Backend— Where state is stored: a local file, an object store, or a hosted service.
  • Client-side state encryption— Encrypting state and plan files before they leave the machine running the tool.
  • Ephemeral value— A value available during a run and never written to state or plan.
  • Lock file— .terraform.lock.hcl, which records provider versions and hashes, under a registry address.
  • required_version— The setting in the terraform block that states which tool versions a module supports.
  • language block— OpenTofu’s block for stating which OpenTofu versions a module supports.
  • Forgotten resource— A resource removed from state without being destroyed.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.