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 .tf.json

write a configuration from a program (a generator, a template engine, a migration tool)

Machine-readable UI

-json on plan, apply, refresh, init and test (and query)

watch a running command as a stream of events (progress, planned changes, diagnostics)

JSON plan

terraform show -json <planfile>

review what a saved plan will do before applying it (gates, cost checks, policy engines)

JSON state

terraform show -json (no file), terraform output -json

inspect what is deployed (inventories, tests, hand-offs to other tools)

Validation result

terraform validate -json

annotate code with diagnostics (editors, pull request bots)

Provider schemas

terraform providers schema -json

discover every resource, data source, argument and function a provider offers (editors, linters, generators)

MCP server

hashicorp/terraform-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.

flowchart LR gen["Generator or template engine"] -->|writes| tfjson["main.tf.json"] tfjson --> cli["terraform CLI"] cli -->|"plan -json / apply -json"| ui["JSON Lines event stream"] cli -->|"plan -out"| planfile["binary plan file"] planfile -->|"show -json"| planjson["JSON plan"] cli -->|"show -json / output -json"| statejson["JSON state and outputs"] cli -->|"providers schema -json"| schemas["Provider schemas"] ui --> wrapper["Python or Go wrapper"] planjson --> gate["CI gate with jq or policy engine"] statejson --> inventory["Inventory and tests"] schemas --> editor["Editors and generators"] mcp["Terraform MCP server"] -.->|"registry and HCP Terraform APIs"| assistant["AI assistant"]

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 null are 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: default and description of a variable, most settings in the terraform block, a module’s source and version, and the type of a connection.

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

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.

Common keys

Every message, whatever its type, carries the same five keys:

Key Meaning

@level

Usually info; warn or error for diagnostics.

@message

A human-readable summary. A consumer that does not know the type should at least show this.

@module

Always terraform.ui for UI output.

@timestamp

An RFC 3339 timestamp.

type

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

version

terraform and ui versions; always first.

log

An unstructured log line.

diagnostic

A warning or error; the object has the same shape as in terraform validate -json (see below).

init_output

Messages of terraform init -json (added in 1.9). Carries a message_code such as initializing_backend_message, initializing_provider_plugin_message, dependencies_lock_changes_info or output_init_success_message.

Plan and apply results

Type Content

resource_drift

A resource changed outside Terraform: change.resource and change.action (update or delete). The details are in the JSON plan.

planned_change

change.resource, change.action (noop, create, read, update, replace, delete, move, import), an optional change.reason (for example tainted, requested or cannot_update) and, for a move, change.previous_resource.

change_summary

changes.add, changes.change, changes.remove and changes.operation (plan, apply or destroy). A replacement counts as one add and one remove.

outputs

A map from output name to action (during a plan), value (after an apply), type and sensitive. A sensitive output still carries its value, so the consumer must honour sensitive.

planned_action_invocation

An action (see Provisioners, Actions and Post-Apply Operations) that will run, either through -invoke or a lifecycle trigger.

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

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 with value, type and sensitive (child-module outputs are not listed);

  • root_module: an object with resources and child_modules, nested recursively. Each resource has address, mode (managed or data), type, name, index (only with count or for_each), provider_name, schema_version, values and sensitive_values. Deposed objects are not included.

The plan representation

A plan document has these top-level keys:

Key Meaning

format_version

The format version of this document.

applyable

A boolean. The reference tells automation to use this as the primary signal for whether to attempt an apply.

complete

A boolean. false means at least one more plan and apply round is expected afterwards.

errored

A boolean. An errored plan cannot be applied.

variables

Map from root variable name to an object with a value.

prior_state

The state representation the plan was computed against.

planned_values

A values representation of what the world will look like after the apply, with unknown values omitted.

proposed_unknown

A values-shaped object marking, with booleans, which planned values are unknown.

resource_changes

One entry per resource instance that has a planned action (including no-ops).

resource_drift

Changes detected outside Terraform since the previous run; same structure as resource_changes.

relevant_attributes

A list of resource and attribute pairs that the plan depends on; filter resource_drift with it to see only drift that may matter.

output_changes

Root-module output changes keyed by name; the actions are create, update and delete.

configuration

A configuration representation (providers, resources, expressions, module calls) of the code that produced the plan.

checks

The status of check blocks, precondition and postcondition. The reference labels this section experimental: details may change even in minor releases.

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 contain delete, a check for any destruction can simply look for delete in the array.

  • before and after: the object before and after. before is absent for a create; after is absent for a delete.

  • after_unknown: the same shape as after, with true at every leaf that is unknown until apply and unknown leaves omitted otherwise. Combine it with after to reconstruct the post-apply value.

  • before_sensitive and after_sensitive: the same shape, with true at 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 import id.

  • action_reason: a display hint that may change over time. Documented values are replace_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_unknown and read_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: error or warning (ignore other values);

  • summary and an optional detail (lines starting with spaces are preformatted, other lines are paragraphs);

  • an optional range with filename and start and end positions (byte, one-based line, one-based column counted in Unicode characters; start inclusive, end exclusive);

  • an optional snippet with context, code, start_line, highlight_start_offset, highlight_end_offset and a values array of traversal and statement pairs.

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_schemas and data_source_schemas: one schema per type;

  • ephemeral_resource_schemas: one schema per ephemeral resource type;

  • functions: the provider-defined functions, each with summary, description, return_type, parameters, an optional variadic_parameter and a deprecation_message when 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 returns error or (T, error) where T is a terraform-json type.

  • 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-json ships a fixed snapshot of the JSON formats and often lags behind Terraform. The Terraform 1.17 changelog says it is worth using the latest terraform-json and terraform-exec for 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 terraform with -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, the beelit94/python-terraform repository). Its README describes it as a wrapper of the terraform command line tool: you create a Terraform() object and call a command as a method (t.plan(...), t.apply(...)), dashes in options become underscores (-no-color becomes no_color), commands that are Python keywords get a _cmd suffix (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 (the GoogleCloudPlatform/terraform-python-testing-helper repository, on PyPI as tftest). It is a test helper for pytest: you create a TerraformTest object for a module directory, call setup(), then plan(output=True) to get parsed plan data (variables, outputs, resources) or apply(), output() and destroy() 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"

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-cdk repository 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 --hcl to produce HCL .tf files 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, terraform and all, plus default, which is registry. The --toolsets flag selects groups and --tools selects individual tools; the two cannot be combined. Tool names mentioned in the README include search_provider_docs, get_provider_details and list_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 (default false) 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_OPERATIONS at its default, and use a token that only has read access to the organizations you want the assistant to see (MCP_ORGANIZATION_ALLOWLIST restricts 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 set TFE_ADDRESS through headers or query parameters (HTTP 403).

  • X-Forwarded-For and X-Real-IP can be spoofed unless a trusted proxy overwrites them; keep the default RemoteAddr method unless you operate the proxies, and set MCP_XFF_TRUSTED_HOPS to 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.json in addition to .tf.json and prefers it when both exist.

  • -json-into: OpenTofu’s plan, apply and init accept -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 version message and the same ignore-unknown, reject-unsupported-major rule. OpenTofu’s page does not compare itself to Terraform, and its example shows an older ui value than the text states; the message sets of the two tools have diverged (for example, Terraform’s -policies, query and 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 -json produces the same kind of documents, but treat the format_version and 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 with show -json is 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.json works 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 -json output now includes a format_version field, so consumers can detect future changes safely; existing tooling that ignores unknown fields keeps working, and the changelog advises using the latest terraform-json and terraform-exec. Terraform Policy is documented as generally available in the 1.17 changelog (-policies no longer needs an experimental build), and it adds policy messages to the -json stream; the documentation pages for the feature may still describe it as beta, so check the page for the version you run. terraform show of a local plan now reads variable values from the plan file.

  • 1.16 (August 26, 2026): terraform state show and terraform workspace list accept -json.

  • 1.15: fixes to the JSON renderings: a removed block no longer lists the removed resource under planned_values in the JSON plan; terraform show -json no longer crashes on plans with ephemeral resources that have preconditions or postconditions; terraform init -json formats all backend messages as JSON; terraform output -json no longer prints warnings into the JSON; validate -json returns JSON for early diagnostics it previously missed; and list_start messages list the attribute paths marked as sensitive.

  • 1.14: actions and the -invoke flag, which add the planned_action_invocation message to the UI stream.

  • 1.10: the terraform modules -json command.

  • 1.9: terraform init -json and the init_output messages.

  • December 10, 2025: the CDK for Terraform is deprecated and its repository archived.

  • 1.1.0: the documented ui (1.0) and format_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, apply or show is 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 ui and format_version, and tolerate unknown properties.

  • Treating plan.json or state.json as harmless: terraform show -json prints sensitive values in cleartext; keep these files out of artifacts, caches and logs, and delete them after the job.

  • Forgetting that apply -json needs -auto-approve or a saved plan, and that -json implies -input=false.

  • Gating on the stream instead of the plan: the stream reports what is happening, the plan JSON (with applyable, errored and resource_changes) is what you decide on. Gate on delete appearing in actions so both replacement orders are caught.

  • Reading after without after_unknown: an attribute that is unknown until apply is simply absent from after.

  • Printing values without consulting before_sensitive and after_sensitive.

  • Depending on checks (experimental) or on action_reason (a display hint) as a stable contract.

  • Using a stale terraform-json against 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.