Machine-Readable Output and Integrations
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
The default output of the terraform command is written for people: coloured, wrapped to the terminal width, and free to
change its wording in any release. A script that parses it with grep works until the day a message is reworded. For
everything that is not a person at a keyboard, Terraform offers stable, versioned, machine-readable interfaces: a JSON
syntax for writing the configuration itself, a stream of JSON messages for the long-running commands, a JSON document for
plans and state, a JSON description of every provider schema, and, since the rise of AI assistants, an MCP server. This page
describes each of them, shows how to consume them from the shell, Python and Go, covers the status of the CDK for
Terraform (deprecated and archived), and shows how to gate a pipeline on the plan.
The baseline is Terraform 1.17 (1.17.0 was released on October 21, 2026) and OpenTofu 1.13. The page was checked against the
Terraform JSON configuration syntax page, the machine-readable UI reference, the JSON output format reference, the
reference pages of show, validate, output, plan, apply and providers schema, the Terraform changelogs, the
README files of hashicorp/terraform-exec, hashicorp/terraform-json, hashicorp/terraform-cdk and
hashicorp/terraform-mcp-server, and the OpenTofu documentation. Where something could not be confirmed from a primary
source the text says so. Outputs shown are abbreviated and illustrative unless the text says they are quoted from an
official page.
The page sits in the reading path after CLI Essentials (the commands used here), The Dependency Graph and the Plan (what a plan contains) and State (what the state JSON describes). The pipeline that consumes these formats is built in CI/CD and Automation, and policy engines that read the plan JSON are described in Policy as Code and Code Quality and Security Scanning. The syntax rules for the native language are in HCL Syntax and Configuration Files.
Which interface for which job
Terraform exposes several machine-readable surfaces. They are separate on purpose: each has its own version field and its own stability promise, and they answer different questions.
| Interface | How to get it | Use it to |
|---|---|---|
JSON configuration syntax |
files ending in |
write a configuration from a program (a generator, a template engine, a migration tool) |
Machine-readable UI |
|
watch a running command as a stream of events (progress, planned changes, diagnostics) |
JSON plan |
|
review what a saved plan will do before applying it (gates, cost checks, policy engines) |
JSON state |
|
inspect what is deployed (inventories, tests, hand-offs to other tools) |
Validation result |
|
annotate code with diagnostics (editors, pull request bots) |
Provider schemas |
|
discover every resource, data source, argument and function a provider offers (editors, linters, generators) |
MCP server |
|
let an AI assistant search provider documentation and modules and, optionally, work with HCP Terraform |
The rule that applies to all of them: consumers must ignore unknown properties and must reject an unsupported major version. Minor version bumps only add things.
The following diagram shows where each interface sits between a Terraform run and the tools around it.
The JSON configuration syntax
Terraform reads two syntaxes for the same language: the native syntax in .tf files, and a JSON syntax in .tf.json
files. Both are processed together in a directory, so a module can mix them, and both can be used for override files
(override.tf.json). OpenTofu also reads .tofu.json; when both foo.tf.json and foo.tofu.json exist OpenTofu uses the
.tofu.json file and ignores the .tf.json one, which is how a module can offer different content to the two tools.
The reference page is explicit about the intended audience: the JSON syntax exists so that programs can generate configuration, and it recommends against hand-editing such files. People should write the native syntax.
Structure
The root of the file is a JSON object whose property names are block types (resource, variable, output, locals,
module, provider, terraform, and so on). A block’s labels become nested objects, one level per label, and the
innermost object holds the arguments and the nested blocks. A resource therefore needs two levels (type, then name).
{
"//": "main.tf.json -- generated by tools/render-bookshelf.py, do not edit by hand",
"terraform": {
"required_version": ">= 1.17.0",
"required_providers": {
"aws": {
"source": "hashicorp/aws",
"version": "~> 6.0"
}
}
},
"variable": {
"environment": {
"type": "string",
"default": "dev",
"description": "Deployment environment of the Bookshelf stack."
}
},
"locals": {
"covers_bucket_name": "bookshelf-covers-${var.environment}"
},
"resource": {
"aws_s3_bucket": {
"covers": {
"bucket": "${local.covers_bucket_name}",
"tags": {
"Application": "bookshelf",
"Environment": "${var.environment}"
}
}
}
},
"output": {
"covers_bucket_arn": {
"value": "${aws_s3_bucket.covers.arn}"
}
}
}
Expressions in JSON
The mapping from JSON values to Terraform values is simple, with one important twist:
-
JSON booleans, numbers and
nullare literal values; JSON arrays and objects become Terraform tuples and objects. -
A JSON string is parsed as a string template, so
Hello, ${var.name}interpolates. That is the only way to write a reference or a function call, because JSON has no expression syntax of its own:${aws_s3_bucket.covers.arn}. -
A string that consists of a single interpolation keeps the type of the referenced value (a number stays a number, a list stays a list). Any literal text or template directive around it turns the result into a string.
-
Some arguments are literal only and are never interpolated:
defaultanddescriptionof a variable, most settings in theterraformblock, a module’ssourceandversion, and thetypeof aconnection.
Nested blocks follow two forms. A single block without labels is an object (a lifecycle block is a JSON object holding its arguments). Several blocks of the same type, or blocks whose order matters (provisioners), use an array in which each element
is an object keyed by the block’s label. For labelled blocks where order does not matter, a single object keyed by the
unique labels is accepted as a shorthand, and the array form is the most general and the one a generator should always
emit.
{
"resource": {
"terraform_data": {
"seed_catalog": {
"depends_on": ["aws_s3_bucket.covers"],
"lifecycle": {
"ignore_changes": "all"
},
"provisioner": [
{ "local-exec": { "command": "echo seeding the catalog" } },
{ "local-exec": { "command": "echo seeded" } }
]
}
}
}
}
Block-specific rules worth remembering when generating files: provider inside a resource is one string (aws.west),
depends_on is an array of reference strings, ignore_changes is the string all or an array of attribute names,
module providers is an object mapping child provider addresses to parent provider addresses (both literal strings), and
several configurations of one provider are an array under the provider name.
Comments and limits
JSON has no comments, so Terraform ignores a property named // inside a block body and at the root of the file. A
generator can use it to leave a "do not edit" marker. It is not ignored inside an object that is evaluated as an
expression, where it becomes an ordinary attribute named //. Other limits are practical: JSON is verbose, an arbitrary
expression has to be written as a template string, and diffs of generated JSON are harder to review than HCL. When the
goal is to produce configuration for humans to maintain, generate .tf instead, or, for the CDK for Terraform, see the
migration section below.
The machine-readable UI: -json
Commands that run for a while (plan, apply, refresh, test, init) accept -json, which replaces the human UI
with a stream of JSON objects, one per line (JSON Lines), written to standard output. Because messages are written as
events occur, messages about different resources interleave; the resource object inside a message is how a consumer
correlates them.
Two consequences are documented on the plan and apply reference pages: -json implies -input=false, so every root
variable must have a value, and terraform apply -json additionally requires either -auto-approve or a previously saved
plan file, because there is no terminal to ask for approval.
export TF_VAR_db_password="$BOOKSHELF_DB_PASSWORD" # supplied by the CI secret store
terraform plan -json -out=tfplan > plan-events.jsonl
terraform apply -json tfplan > apply-events.jsonl
Source: HashiCorp Developer — Command: apply.
OpenTofu adds -json-into=<file> on plan, apply and init: it produces the same JSON as -json, but redirected to a
file, so the human UI still goes to the terminal. That is convenient when a pipeline wants both a readable log and a
machine-readable record.
Source: OpenTofu — Command: plan.
Common keys
Every message, whatever its type, carries the same five keys:
| Key | Meaning |
|---|---|
|
Usually |
|
A human-readable summary. A consumer that does not know the |
|
Always |
|
An RFC 3339 timestamp. |
|
Determines how the other keys are interpreted. |
The version message and the compatibility rule
The first message of every stream has type equal to version, and two extra keys: terraform (the version of the binary
that emitted it) and ui (the version of the message schema). The reference documents the ui value as 1.0 as of
Terraform 1.1.0, and its own later examples show higher minor versions (for example 1.2 in a Terraform 1.9 example),
which is the normal evolution the rule allows:
-
a minor bump (
1.1,1.2) only adds message types or properties: ignore what you do not recognise; -
a major bump (
2.0) may break consumers: refuse to process a stream whose major version you do not support.
A consumer therefore reads the first line, checks the major version of ui, and only then processes the rest.
Message types
The messages group into a handful of families. The tables list the types documented in the 1.17 reference.
Generic and initialisation
| Type | Content |
|---|---|
|
|
|
An unstructured log line. |
|
A warning or error; the object has the same shape as in |
|
Messages of |
Plan and apply results
| Type | Content |
|---|---|
|
A resource changed outside Terraform: |
|
|
|
|
|
A map from output name to |
|
An action (see Provisioners, Actions and Post-Apply Operations) that will run, either through |
Progress hooks, each with a hook object that holds the resource and, where relevant, the action, id_key,
id_value and elapsed_seconds: apply_start, apply_progress, apply_complete and apply_errored;
provision_start, provision_progress (one per line of provisioner output), provision_complete and
provision_errored; refresh_start and refresh_complete; and the ephemeral-resource hooks ephemeral_op_start,
ephemeral_op_progress, ephemeral_op_complete and ephemeral_op_errored (with the open, renew and close
actions). An apply_errored message carries no explanation; the cause arrives as a separate diagnostic message.
Test, query and policy messages: terraform test -json emits test_abstract, test_file, test_run, test_cleanup,
test_summary (and test_plan and test_state with -verbose, plus test_interrupt); terraform query -json emits
list_start, list_resource_found and list_complete; and the policy engine emits policy_info, policy_diagnostic,
policy_result and policy_query_summary when -policies is combined with -json. See
Testing, Import and Query and
Policy as Code for those features.
The resource object used by planned_change, the hooks and the drift messages has these keys: addr (the full
address), module (empty for the root module), resource (the address without the module), resource_type,
resource_name, resource_key (the count index or for_each key, or null) and implied_provider.
An illustrative stream
The following is an abbreviated, illustrative excerpt for the Bookshelf covers bucket. The keys follow the reference; the values, the missing keys and the timestamps are invented for the example, and each line is shortened.
{"@level":"info","@message":"Terraform 1.17.0","@module":"terraform.ui","type":"version","terraform":"1.17.0","ui":"1.2"}
{"@level":"info","@message":"aws_s3_bucket.covers: Plan to create","@module":"terraform.ui","type":"planned_change","change":{"resource":{"addr":"aws_s3_bucket.covers","module":"","resource":"aws_s3_bucket.covers","resource_type":"aws_s3_bucket","resource_name":"covers","resource_key":null,"implied_provider":"aws"},"action":"create"}}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","type":"change_summary","changes":{"add":1,"change":0,"import":0,"remove":0,"operation":"plan"}}
A rendering of the stream for a person is a few lines of jq:
terraform plan -json -input=false \
| jq -r 'select(.type == "planned_change") | "\(.change.action)\t\(.change.resource.addr)"'
Reading the stream from Python
A small Python program can run a command, read the stream line by line (so progress is visible while the apply runs),
check the ui major version and collect diagnostics. The program below is a sketch built on the documented message keys;
it uses only the standard library.
"""Run a saved Bookshelf plan and report progress from the machine-readable UI."""
import json
import subprocess
import sys
SUPPORTED_UI_MAJOR = 1
def apply_plan(workdir: str, planfile: str) -> int:
proc = subprocess.Popen(
["terraform", "apply", "-json", planfile],
cwd=workdir,
stdout=subprocess.PIPE,
text=True,
)
errors = []
for line in proc.stdout:
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue # tolerate any non-JSON line instead of crashing the run
kind = msg.get("type")
if kind == "version":
major = int(str(msg["ui"]).split(".")[0])
if major != SUPPORTED_UI_MAJOR:
proc.terminate()
raise RuntimeError(f"unsupported machine-readable UI version {msg['ui']}")
elif kind == "apply_complete":
print("created:", msg["hook"]["resource"]["addr"])
elif kind == "diagnostic" and msg["@level"] == "error":
errors.append(msg["diagnostic"]["summary"])
elif kind == "change_summary":
print("summary:", msg["changes"])
code = proc.wait()
for summary in errors:
print("error:", summary, file=sys.stderr)
return code
if __name__ == "__main__":
sys.exit(apply_plan(sys.argv[1], sys.argv[2]))
The program deliberately ignores every message type it does not know, as the compatibility rule requires.
The JSON plan and state formats
The stream above tells you what is happening. To decide whether to proceed, you need the plan itself. A saved plan file
(terraform plan -out=tfplan) is a binary, Terraform-version-specific artifact; terraform show -json tfplan converts it
into a JSON document that is the supported way to inspect it. The same command without a file argument renders the
current state, and with a state file argument renders that file.
terraform plan -out=tfplan -input=false
terraform show -json tfplan > plan.json
terraform show -json > state.json
Source: HashiCorp Developer — Command: show.
Three practical notes from the show reference: -json needs Terraform 0.12 or later; the plan must have been created
without -refresh=false for the JSON rendering of it to be complete; and -json prints sensitive values in plain text,
because the plan and state hold them in cleartext. Treat plan.json and state.json exactly like the plan file and the
state: keep them out of build logs and artifacts that others can read (see
Sensitive Data, Ephemeral Values and Write-Only
Arguments).
Versioning
Plan and state documents carry a format_version key, documented as 1.0 since Terraform 1.1.0. The same rule applies:
minor bumps are additive and consumers must ignore unknown properties, major bumps may break and consumers must reject a
major version they do not know. The terraform-json Go library pins the plan and validate formats it understands with the
constraint >= 0.1, < 2.0.
The state representation
A state document has two top-level keys, values (a values representation, always complete since state is fully known)
and terraform_version. The values representation holds:
-
outputs: each root-module output withvalue,typeandsensitive(child-module outputs are not listed); -
root_module: an object withresourcesandchild_modules, nested recursively. Each resource hasaddress,mode(managedordata),type,name,index(only withcountorfor_each),provider_name,schema_version,valuesandsensitive_values. Deposed objects are not included.
The plan representation
A plan document has these top-level keys:
| Key | Meaning |
|---|---|
|
The format version of this document. |
|
A boolean. The reference tells automation to use this as the primary signal for whether to attempt an apply. |
|
A boolean. |
|
A boolean. An errored plan cannot be applied. |
|
Map from root variable name to an object with a |
|
The state representation the plan was computed against. |
|
A values representation of what the world will look like after the apply, with unknown values omitted. |
|
A values-shaped object marking, with booleans, which planned values are unknown. |
|
One entry per resource instance that has a planned action (including no-ops). |
|
Changes detected outside Terraform since the previous run; same structure as |
|
A list of |
|
Root-module output changes keyed by name; the actions are |
|
A configuration representation (providers, resources, expressions, module calls) of the code that produced the plan. |
|
The status of |
Each resource_changes entry has address, previous_address (only when the object moved, for example through a
moved block), module_address, mode, type, name, index, deposed (only for deposed objects), action_reason
and the change object. The change object is where the content is:
-
actions: one of["no-op"],["create"],["read"],["update"],["delete"], and the two replacement forms["delete","create"](destroy then create) and["create","delete"](create before destroy). Because both replacement forms containdelete, a check for any destruction can simply look fordeletein the array. -
beforeandafter: the object before and after.beforeis absent for a create;afteris absent for a delete. -
after_unknown: the same shape asafter, withtrueat every leaf that is unknown until apply and unknown leaves omitted otherwise. Combine it withafterto reconstruct the post-apply value. -
before_sensitiveandafter_sensitive: the same shape, withtrueat every sensitive leaf. A tool that prints plans must consult these before printing a value. -
replace_paths: the attribute paths that force a replacement; omitted when the action is not a replacement, or when no attribute caused it (a tainted object, for example). -
importing: present when the change is also an import, with the importid. -
action_reason: a display hint that may change over time. Documented values arereplace_because_tainted,replace_because_cannot_update,replace_by_request,delete_because_no_resource_config,delete_because_no_module,delete_because_wrong_repetition,delete_because_count_index,delete_because_each_key,read_because_config_unknownandread_because_dependency_pending. A value you do not recognise must be treated as unspecified.
The Go library terraform-json models a few more fields than the JSON format page lists (for example an ActionForget
constant used for the forget action of a removed block, and resource identity and generated configuration on the
change). If you consume the plan with a typed library, upgrade the library together with Terraform.
The configuration section stores each expression either as a constant_value or as a list of references, which is
how a tool can see what depends on what without evaluating anything. Expressions inside dynamic blocks are not included.
An abbreviated, illustrative plan fragment for a Bookshelf change that replaces the Redis cache and updates a tag:
{
"format_version": "1.0",
"applyable": true,
"complete": true,
"errored": false,
"resource_changes": [
{
"address": "aws_elasticache_replication_group.sessions",
"mode": "managed",
"type": "aws_elasticache_replication_group",
"name": "sessions",
"action_reason": "replace_because_cannot_update",
"change": {
"actions": ["delete", "create"],
"replace_paths": [["engine_version"]]
}
},
{
"address": "aws_s3_bucket.covers",
"mode": "managed",
"type": "aws_s3_bucket",
"name": "covers",
"change": { "actions": ["update"] }
}
]
}
Querying a plan with jq
jq is the natural companion of these documents. The filters below are examples against the structure described above.
# Every address with its actions, one per line
jq -r '.resource_changes[] | "\(.change.actions | join(","))\t\(.address)"' plan.json
# Only the resources that will be destroyed or replaced
jq -r '.resource_changes[]
| select(.change.actions | index("delete"))
| .address' plan.json
# Drift that touches attributes the plan actually uses
jq '.resource_drift | length' plan.json
# Output changes (root module only)
jq '.output_changes | keys' plan.json
A CI gate on the plan
The plan JSON lets a pipeline enforce a rule that no linter can: this change must not destroy the Bookshelf database or
the covers bucket. The script below fails the job when a protected resource type is planned for deletion, when the plan
is errored or not applyable, and when the plan format has a major version the script does not know. It complements, and does
not replace, prevent_destroy in the configuration
(see Lifecycle, Validation and Checks) and a policy engine.
#!/usr/bin/env bash
# gate-plan.sh -- fail when a Bookshelf plan would destroy stateful infrastructure.
set -euo pipefail
PLAN_FILE="${1:-tfplan}"
terraform show -json "$PLAN_FILE" > plan.json
# 1. Refuse a plan format we do not understand (major version must be 1).
major="$(jq -r '.format_version | split(".")[0]' plan.json)"
if [ "$major" != "1" ]; then
echo "unsupported plan format_version $(jq -r .format_version plan.json)" >&2
exit 2
fi
# 2. The plan must be applyable and not errored.
jq -e '.errored == false and .applyable == true' plan.json > /dev/null \
|| { echo "plan is errored or not applyable" >&2; exit 3; }
# 3. Protected types must never be deleted (a replacement also contains "delete").
protected='["aws_db_instance","aws_s3_bucket","aws_elasticache_replication_group"]'
doomed="$(jq -r --argjson p "$protected" '
.resource_changes[]
| select(.type as $t | $p | index($t))
| select(.change.actions | index("delete"))
| .address' plan.json)"
if [ -n "$doomed" ]; then
echo "plan would destroy protected resources:" >&2
echo "$doomed" >&2
exit 4
fi
echo "plan passed the destroy gate"
In a GitHub Actions job the gate is one more step between plan and apply, and the JSON file must be handled as a
secret because it may contain sensitive values:
- name: Plan
run: terraform plan -input=false -out=tfplan
- name: Gate on the plan
run: ./gate-plan.sh tfplan
- name: Remove the rendered plan
if: always()
run: rm -f plan.json
Policy engines read the same document: Open Policy Agent policies, conftest, Checkov and Trivy can scan a plan JSON file,
and HCP Terraform runs policy checks against its own copy of the plan data (see
Policy as Code).
Output values, validation and provider schemas
terraform output -json
terraform output -json prints the root-module outputs as one JSON object with a key per output; with a name it prints just
that output. The reference recommends it (and -raw for a single string, number or boolean) for scripts, because the
default format can change. Sensitive outputs are shown as <sensitive> in the default listing, but appear in plain text
with -json or -raw or when requested by name; ephemeral outputs are omitted entirely. A smoke test for the Bookshelf
stack:
catalog_url="$(terraform output -raw catalog_api_url)"
curl --fail --silent "$catalog_url/healthz"
terraform output -json | jq -r 'keys[]'
terraform validate -json
terraform validate -json prints a single JSON document and disables colour. Its keys are format_version (1.0 since
Terraform 1.1.0), valid, error_count, warning_count and diagnostics. This is the same diagnostic object that
appears in diagnostic messages of the UI stream, so one parser serves both. A diagnostic has:
-
severity:errororwarning(ignore other values); -
summaryand an optionaldetail(lines starting with spaces are preformatted, other lines are paragraphs); -
an optional
rangewithfilenameandstartandendpositions (byte, one-basedline, one-basedcolumncounted in Unicode characters;startinclusive,endexclusive); -
an optional
snippetwithcontext,code,start_line,highlight_start_offset,highlight_end_offsetand avaluesarray oftraversalandstatementpairs.
terraform init -backend=false -input=false
terraform validate -json \
| jq -r '.diagnostics[] | "\(.severity)\t\(.range.filename // "-"):\(.range.start.line // 0)\t\(.summary)"'
terraform providers schema -json
terraform providers schema -json (the -json flag is mandatory) prints the schemas of every provider used by the
configuration in the current directory. It accepts -var and -var-file because provider requirements can now depend on
variables. The document has a format_version (default 1.0; versions up to 2.0 are backward compatible) and a
provider_schemas object keyed by provider, each with:
-
provider: the schema of the provider’s own configuration block; -
resource_schemasanddata_source_schemas: one schema per type; -
ephemeral_resource_schemas: one schema per ephemeral resource type; -
functions: the provider-defined functions, each withsummary,description,return_type,parameters, an optionalvariadic_parameterand adeprecation_messagewhen deprecated.
A schema is { version, block } where version is the schema version, not the provider version. A block has
attributes and block_types. An attribute has type, description, required, optional, computed and sensitive.
A nested block type has nesting_mode (single, list, set or map), a nested block, and min_items and
max_items for lists and sets. The reference page does not document the sections for resource identity, actions or list
resources, so check the provider schema of your Terraform version before relying on them.
terraform providers schema -json > schemas.json
# Every argument a user must set on aws_s3_bucket
jq -r '.provider_schemas["registry.terraform.io/hashicorp/aws"]
.resource_schemas.aws_s3_bucket.block.attributes
| to_entries[] | select(.value.required == true) | .key' schemas.json
The schema document is what editors, language servers and documentation generators are built on, and it is the source for
the ProviderSchemas type of terraform-json. Provider keys in the JSON are fully qualified source addresses; the page’s own
example uses a short key, so inspect the keys of your own output first (jq .provider_schemas|keys).
Wrapping Terraform from other languages
Terraform is a command-line program; there is no stable embeddable API. Every integration therefore does the same thing:
start the binary, pass -json or read a JSON document, and parse it. A wrapper earns its keep by doing that carefully
(argument building, environment, process management, typed results). Writing the subprocess call yourself is also
legitimate, and for a pipeline step it is often the simplest choice.
Go: terraform-exec and terraform-json
hashicorp/terraform-exec is the Go module for building and running Terraform CLI commands. It is not a plugin SDK (use the
Plugin Framework for that). Structured results use the types of hashicorp/terraform-json, which parses the output of
terraform show -json and terraform providers schema -json. Key facts from the READMEs:
-
NewTerraform(workingDir, execPath)creates the handle; each top-level method returnserroror(T, error)whereTis aterraform-jsontype. -
Terraform 1.x is the recommended target; compatibility with older minor versions from 0.12 on is best effort, and the module does not claim to cover every CLI feature.
-
terraform-exec is not yet at v1.0.0, so breaking changes can appear in minor releases (it follows semantic versioning); the two latest major Go releases are supported. The module is MPL-2.0 licensed.
-
terraform-jsonships a fixed snapshot of the JSON formats and often lags behind Terraform. The Terraform 1.17 changelog says it is worth using the latestterraform-jsonandterraform-execfor that reason (see "What changed recently"). The README also warns that the library drops unknown attributes, so it is unsuitable for read-modify-write workflows over a plan or state.
The method signatures used below were checked on pkg.go.dev: Init(ctx, opts...) error, Plan(ctx, opts...) (bool, error)
(the boolean reports whether there are changes), ShowPlanFile(ctx, path, opts...) (*tfjson.Plan, error) and
Validate(ctx) (*tfjson.ValidateOutput, error), with option constructors such as tfexec.Upgrade, tfexec.Out,
tfexec.Refresh, tfexec.Var and tfexec.VarFile.
// gate.go -- a Go version of the Bookshelf destroy gate.
// Illustrative: build it in a module that requires github.com/hashicorp/terraform-exec.
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"github.com/hashicorp/terraform-exec/tfexec"
)
var protected = map[string]bool{
"aws_db_instance": true,
"aws_s3_bucket": true,
"aws_elasticache_replication_group": true,
}
func main() {
ctx := context.Background()
execPath, err := exec.LookPath("terraform")
if err != nil {
log.Fatalf("terraform not found: %v", err)
}
tf, err := tfexec.NewTerraform("environments/prod", execPath)
if err != nil {
log.Fatal(err)
}
if err := tf.Init(ctx, tfexec.Upgrade(false)); err != nil {
log.Fatalf("init: %v", err)
}
if _, err := tf.Plan(ctx, tfexec.Out("tfplan")); err != nil {
log.Fatalf("plan: %v", err)
}
plan, err := tf.ShowPlanFile(ctx, "tfplan")
if err != nil {
log.Fatalf("show: %v", err)
}
blocked := false
for _, rc := range plan.ResourceChanges {
if protected[rc.Type] && rc.Change.Actions.Delete() {
fmt.Printf("would destroy protected resource %s\n", rc.Address)
blocked = true
}
}
if blocked {
os.Exit(1)
}
fmt.Println("plan passed the destroy gate")
}
The Actions.Delete() helper is documented in terraform-json as true when the action set denotes a removal (it also
returns true for both replacement forms). terraform-exec chooses the binary via execPath; the package documentation
suggests hc-install or os.LookPath to obtain one.
Python
No HashiCorp-published Python wrapper was found while preparing this page. Three approaches are in common use:
-
Plain subprocess. Call
terraformwith-json, read standard output, parse it. Nothing to install, no dependency to track, and the examples earlier on this page show the whole technique. -
python-terraform(pip install python-terraform, thebeelit94/python-terraformrepository). Its README describes it as a wrapper of theterraformcommand line tool: you create aTerraform()object and call a command as a method (t.plan(...),t.apply(...)), dashes in options become underscores (-no-colorbecomesno_color), commands that are Python keywords get a_cmdsuffix (import_cmd), and every call returns a tuple(return_code, stdout, stderr). It returns text; you still parse the JSON yourself. The README shows no deprecation notice; whether the project is actively maintained could not be confirmed from the page (the page shows no releases section), so check the repository and the package index before adopting it. -
tftest(theGoogleCloudPlatform/terraform-python-testing-helperrepository, on PyPI astftest). It is a test helper forpytest: you create aTerraformTestobject for a module directory, callsetup(), thenplan(output=True)to get parsed plan data (variables, outputs, resources) orapply(),output()anddestroy()for full tests. Its README states it is not an officially supported Google product, and that version 1.0.0 requires Terraform 0.12 or later.
A test that asserts on the plan of the Bookshelf module without touching any cloud, using only the standard library:
"""test_plan.py -- pytest against terraform show -json (no cloud access needed for plan-only checks)."""
import json
import subprocess
import pytest
@pytest.fixture(scope="module")
def plan(tmp_path_factory):
out = tmp_path_factory.mktemp("plan")
subprocess.run(["terraform", "init", "-backend=false", "-input=false"], check=True)
subprocess.run(["terraform", "plan", "-input=false", f"-out={out}/tfplan"], check=True)
shown = subprocess.run(
["terraform", "show", "-json", f"{out}/tfplan"],
check=True, capture_output=True, text=True,
)
return json.loads(shown.stdout)
def test_plan_is_applyable(plan):
assert plan["applyable"] is True
assert plan["errored"] is False
def test_covers_bucket_is_tagged(plan):
bucket = next(
rc for rc in plan["resource_changes"]
if rc["address"] == "aws_s3_bucket.covers"
)
assert bucket["change"]["after"]["tags"]["Application"] == "bookshelf"
Source: HashiCorp Developer — Command: show.
Terraform’s own terraform test command, with command = plan and mock providers, covers most of this without any
wrapper (see Testing). Reach for plan-JSON tests when the assertion needs the whole
plan, such as "no resource anywhere is destroyed".
Other languages and shells
The same recipe works everywhere: invoke terraform show -json, terraform output -json or terraform plan -json, parse
JSON. Third-party libraries exist for other ecosystems; prefer the subprocess approach unless a library is clearly
maintained and supports the JSON format version your Terraform release emits.
The CDK for Terraform: deprecated and archived
The Cloud Development Kit for Terraform (CDKTF) let you write infrastructure in TypeScript, Python, Java, C# or Go; the
cdktf tool then synthesised it into Terraform JSON configuration (cdk.tf.json) and ran the Terraform CLI on it. That
is exactly the JSON syntax described at the start of this page, which is why the topic belongs here.
The status, as stated by HashiCorp:
-
The CDKTF documentation says the Cloud Development Kit for Terraform is deprecated as of December 10, 2025 and that HashiCorp no longer supports or maintains it.
-
The
hashicorp/terraform-cdkrepository carries a sunset notice: the project did not reach product-market fit at scale, HashiCorp (an IBM company) stops maintaining and developing it, and the repository "was archived by the owner on Dec 10, 2025" and is read-only. The code stays available under the MPL, no further fixes or compatibility updates will be released, use is at your own risk, and the maintainers encourage community forks. -
The documentation site still carries older marketing language (for example the list of supported languages) that is inconsistent with the deprecation notice; follow the notice.
# Migration path recommended by the repository's sunset notice
cdktf synth --hcl # writes HCL .tf files instead of cdk.tf.json
terraform init
terraform plan # review: generated files may need cleanup
The migration options, in the order of preference the notice suggests:
-
Run
cdktf synth --hclto produce HCL.tffiles from the existing application, review and tidy them (generated code is rarely idiomatic), commit them, and continue with plain Terraform from then on. The notice says the generated files may need review. -
If the infrastructure is tightly integrated with AWS CDK, move to AWS CDK; otherwise the notice recommends standard Terraform and HCL.
-
Pulumi and the community forks are other routes; neither is covered by HashiCorp documentation, and no fork was evaluated for this page.
After migrating, the state is unaffected: CDKTF stored state in the same backends as any Terraform configuration, so the
synthesised .tf files can take over the existing state, provided the resource addresses match. Run a plan and confirm it
shows no changes before applying anything; if addresses differ, use moved blocks (see
Refactoring: moved, removed and import). The wider timeline of
removals is in What Changed and Migration.
The Terraform MCP server
The Model Context Protocol (MCP) is a standard way for AI assistants to call external tools. The Terraform MCP server,
published as hashicorp/terraform-mcp-server, gives an assistant structured access to the Terraform Registry and to HCP
Terraform or Terraform Enterprise, so that it can read the current documentation of a provider instead of relying on its
training data. This is the same motivation as the machine-readable interfaces above: give a program a stable interface
instead of a web page. The assistants themselves are discussed in the AI section of this site.
What it offers
According to the HashiCorp documentation and the repository README:
-
Registry access: search and retrieve provider documentation, module information (inputs, outputs, examples) and policies.
-
HCP Terraform and Terraform Enterprise: listing organizations, projects and workspaces, private registry access, and workspace management (create, update and delete workspaces, variables, tags, variable sets and runs).
-
Two transports: stdio (the assistant starts the server as a child process) and Streamable HTTP.
-
Toolsets:
registry,registry-private,terraformandall, plusdefault, which isregistry. The--toolsetsflag selects groups and--toolsselects individual tools; the two cannot be combined. Tool names mentioned in the README includesearch_provider_docs,get_provider_detailsandlist_workspaces; the full list is defined in the repository, so read it for the version you run. -
Operations: tools that change infrastructure-related state require explicit enabling with
ENABLE_TF_OPERATIONS(defaultfalse) per the README environment-variable table.
The documentation site labels the current docs as v1.x and does not state a beta or preview status. The README of the repository does not state one either; treat it as a young project and read its release notes.
Running it
Both documented ways start the server in stdio mode by default:
# Container (stdio, the default transport)
docker run -i --rm hashicorp/terraform-mcp-server
# Container with the Streamable HTTP transport
docker run -p 8080:8080 --rm \
-e TRANSPORT_MODE=streamable-http \
-e TRANSPORT_HOST=0.0.0.0 \
hashicorp/terraform-mcp-server
# From source
go install github.com/hashicorp/terraform-mcp-server/cmd/terraform-mcp-server@latest
terraform-mcp-server stdio
Registering it with an assistant is a one-line command for command-line assistants; the README documents Claude Code and Codex CLI among others:
# Claude Code, stdio transport
claude mcp add terraform -s user -t stdio -- docker run -i --rm hashicorp/terraform-mcp-server
# Claude Code, HTTP transport against a server you started
claude mcp add --transport http terraform http://localhost:8080/mcp
Editors such as VS Code and Cursor take the same Docker command in their MCP configuration file, and the repository also
contains a Helm chart for running the server in Kubernetes. Credentials for HCP Terraform are supplied through the
environment variables TFE_TOKEN and TFE_ADDRESS; for the Bookshelf team that means an HCP Terraform team token with
the narrowest scope that does the job, never a personal user token in a shared configuration file.
Configuration and security notes
The README lists these environment variables, among others: TFE_ADDRESS, TFE_TOKEN, TFE_SKIP_TLS_VERIFY (default
false), ENABLE_TF_OPERATIONS (default false), TRANSPORT_MODE, TRANSPORT_HOST and TRANSPORT_PORT (defaults
127.0.0.1 and 8080), MCP_ENDPOINT (default /mcp), MCP_ALLOWED_ORIGINS and MCP_CORS_MODE (strict,
development or disabled; default strict), rate limits (MCP_RATE_LIMIT_GLOBAL, MCP_RATE_LIMIT_SESSION),
MCP_SESSION_MODE, MCP_TLS_CERT_FILE and MCP_TLS_KEY_FILE (required for a non-localhost deployment),
MCP_ORGANIZATION_ALLOWLIST, and proxy-related settings (MCP_FORWARD_CLIENT_IP, MCP_REMOTE_IP_METHOD,
MCP_XFF_TRUSTED_HOPS). A health check is served at /health in HTTP mode.
The security guidance in the README, in practical terms:
-
The server can expose Terraform data to the MCP client and therefore to the language model behind it. The README says not to use the server with untrusted MCP clients or LLMs. Anything an assistant can read through the server (workspace names, variable names, run output) may end up in a prompt log.
-
Keep write-capable tooling off unless you need it: leave
ENABLE_TF_OPERATIONSat its default, and use a token that only has read access to the organizations you want the assistant to see (MCP_ORGANIZATION_ALLOWLISTrestricts the organizations a bearer token must reach). -
For a shared HTTP deployment use TLS, restrict origins with
MCP_ALLOWED_ORIGINS, and do not put tokens in query strings: the server rejects tokens in query parameters (HTTP 400) and rejects clients that try to setTFE_ADDRESSthrough headers or query parameters (HTTP 403). -
X-Forwarded-ForandX-Real-IPcan be spoofed unless a trusted proxy overwrites them; keep the defaultRemoteAddrmethod unless you operate the proxies, and setMCP_XFF_TRUSTED_HOPSto the exact number of proxies you control. -
The README cautions that outputs are generated dynamically and must be reviewed against security, cost and compliance requirements before use: an assistant’s Terraform is a draft to be run through
fmt,validate, a scanner and a human review, exactly like a colleague’s (see Code Quality and Security Scanning).
Terraform and OpenTofu differences
-
File names: OpenTofu reads
.tofu.jsonin addition to.tf.jsonand prefers it when both exist. -
-json-into: OpenTofu’splan,applyandinitaccept-json-into=<file>, which writes the JSON output to a file while keeping the human UI. The Terraform reference pages read for this page do not list it; redirect standard output instead. -
Machine-readable UI: both tools document a stream with a first
versionmessage and the same ignore-unknown, reject-unsupported-major rule. OpenTofu’s page does not compare itself to Terraform, and its example shows an olderuivalue than the text states; the message sets of the two tools have diverged (for example, Terraform’s-policies,queryand HCP Terraform integration messages have no OpenTofu counterpart), so test a consumer against both binaries instead of assuming identical streams. -
Plan and state JSON:
tofu show -jsonproduces the same kind of documents, but treat theformat_versionand the keys as per-tool facts; this page verified them only for Terraform. OpenTofu can encrypt state and plan files at rest; a JSON rendering produced withshow -jsonis still plain text. -
CDKTF: OpenTofu is not affected by the deprecation as a tool, but the CDKTF project itself is archived, and the generated
cdk.tf.jsonworks with both tools. -
MCP server: the HashiCorp server targets the Terraform Registry and HCP Terraform; it is not an OpenTofu product.
What changed recently
-
1.17 (October 21, 2026):
terraform version -jsonoutput now includes aformat_versionfield, so consumers can detect future changes safely; existing tooling that ignores unknown fields keeps working, and the changelog advises using the latestterraform-jsonandterraform-exec. Terraform Policy is documented as generally available in the 1.17 changelog (-policiesno longer needs an experimental build), and it adds policy messages to the-jsonstream; the documentation pages for the feature may still describe it as beta, so check the page for the version you run.terraform showof a local plan now reads variable values from the plan file. -
1.16 (August 26, 2026):
terraform state showandterraform workspace listaccept-json. -
1.15: fixes to the JSON renderings: a
removedblock no longer lists the removed resource underplanned_valuesin the JSON plan;terraform show -jsonno longer crashes on plans with ephemeral resources that have preconditions or postconditions;terraform init -jsonformats all backend messages as JSON;terraform output -jsonno longer prints warnings into the JSON;validate -jsonreturns JSON for early diagnostics it previously missed; andlist_startmessages list the attribute paths marked as sensitive. -
1.14: actions and the
-invokeflag, which add theplanned_action_invocationmessage to the UI stream. -
1.10: the
terraform modules -jsoncommand. -
1.9:
terraform init -jsonand theinit_outputmessages. -
December 10, 2025: the CDK for Terraform is deprecated and its repository archived.
-
1.1.0: the documented
ui(1.0) andformat_version(1.0) values for the UI stream and for the plan, state and validate documents.
Where the books are outdated
One of the books drives Terraform from Python by capturing and parsing command output. The technique is sound, but the details have moved on:
-
Scraping the human-readable text of
plan,applyorshowis the fragile approach; use-json(the stream or the documents above) and honour the version fields. -
Older wrappers return only text and an exit code; parse JSON yourself or use a typed library (
terraform-json) whose release you keep in step with Terraform. -
Treat the CDK for Terraform material as historical: the tool is deprecated and archived, so new projects should not start with it.
-
JSON syntax appears in the books as a curiosity; the reference positions it as a target for generators, not for hand authoring.
Pitfalls
-
Parsing the default output instead of
-json: any release can reword a message. -
Ignoring the version fields: check the major version of
uiandformat_version, and tolerate unknown properties. -
Treating
plan.jsonorstate.jsonas harmless:terraform show -jsonprints sensitive values in cleartext; keep these files out of artifacts, caches and logs, and delete them after the job. -
Forgetting that
apply -jsonneeds-auto-approveor a saved plan, and that-jsonimplies-input=false. -
Gating on the stream instead of the plan: the stream reports what is happening, the plan JSON (with
applyable,erroredandresource_changes) is what you decide on. Gate ondeleteappearing inactionsso both replacement orders are caught. -
Reading
afterwithoutafter_unknown: an attribute that is unknown until apply is simply absent fromafter. -
Printing values without consulting
before_sensitiveandafter_sensitive. -
Depending on
checks(experimental) or onaction_reason(a display hint) as a stable contract. -
Using a stale
terraform-jsonagainst a newer Terraform: it drops attributes it does not know. -
Starting new work on the archived CDK for Terraform.
-
Giving an assistant a long-lived, broad HCP Terraform token through the MCP server, or exposing the HTTP transport without TLS and an origin allow-list.