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.

The matrix
| Experiment | Terraform 1.16.5 | OpenTofu 1.13.1 |
|---|---|---|
| Plain configuration (control) | pass | pass |
State and plan encryption in the terraform block | refused at init | pass |
Variable in the backend block | refused at init | pass |
for_each on a provider configuration | refused at init | pass |
apply -exclude=ADDRESS | refused: flag not defined | pass |
lifecycle { enabled = ... } | refused at init | pass |
prevent_destroy set from a variable | refused at init | pass |
lifecycle { destroy = false }, then destroy | pass, exit 0 | accepted; destroy exits non-zero |
import block inside a child module | pass | refused at init |
override_during = plan in a test | pass | refused at init |
| Ephemeral input variable | pass | pass |
main.tofu beside main.tf | pass, loads main.tf | pass, loads main.tofu |
required_version = ">= 1.16.0" in a .tf file | pass | pass |
required_version = ">= 99.0.0" in a .tf file | refused at init | pass |
required_version = ">= 99.0.0" in a .tofu file | pass (file not read) | refused at init |
language { compatible_with { opentofu = ">= 99.0.0" } } | refused: unknown block | refused: incompatible |
Variable with const = true | pass | pass |
Variable with deprecated = "..." | pass | pass |
state show -json | pass | pass |
contains() with a null element | pass | pass |
convert(value, type) | pass | pass |
| Provider address written to the lock file | registry.terraform.io/... | registry.opentofu.org/... |
| Plain state written by this tool, planned by the other | no changes | no changes |
| Encrypted state written by this tool, planned by the other | not applicable | Terraform 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

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 dangerIf you maintain modules for both
- Test on both in CI. The same
.tftest.hclfiles ran on both tools in the earlier articles once tool-specific arguments were kept out of them. - State compatibility twice.
required_versioninversions.tffor Terraform; alanguageblock orrequired_versioninversions.tofufor OpenTofu. - Do not commit one tool’s lock file for the other. Generate each in its own pipeline.
- Use
.tofuoverrides 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
| Consideration | Terraform | OpenTofu |
|---|---|---|
| Licence | Business Source License since 2023 | MPL 2.0, under the Linux Foundation |
| Secrets and state | Ephemeral values and write-only arguments keep secrets out of state | The same, plus client-side encryption of state and plan |
| Multi-region and multi-account roots | Provider configurations written out | Provider for_each |
| Adopting existing resources | import blocks in any module | import blocks in the root module |
| Commercial platform | HCP Terraform, Stacks, policy integration | None of its own; works with third-party platforms |
| Risk to weigh | Licence terms for your use; vendor direction | A 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.
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_versionfrom a.tffile. Module authors need alanguageblock or a.tofufile 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
destroyreturns, 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.
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.
The complete sample for this article is on GitHub: nithinmohantk/blog-samples-working-repo/terraform-vs-opentofu. Clone it with git clone https://github.com/nithinmohantk/blog-samples-working-repo.git and open the terraform-vs-opentofu folder. Its README says how to run it and what has been verified.
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
terraformblock 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
- → Terraform 1.16 changelog— Features and fixes in the 1.16 series
- → OpenTofu 1.13 changelog— Features, upgrade notes and experiments in the 1.13 series
- → OpenTofu: state and plan encryption— Key providers, methods and enforcement
- → OpenTofu: settings— The language block, required_version and .tofu files
- → Terraform: ephemeral resources— Keeping values out of state
- → Testing Infrastructure Code— First article in this series
- → One Contract, Two Clouds— Second article in this series
- → Sample code for this article— The terraform-vs-opentofu folder on GitHub
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.