Terraform plan output is surprisingly bad at telling you what’s actually changing in your infrastructure.

Let’s say you have a Terraform configuration that manages an AWS EC2 instance.

resource "aws_instance" "example" {
  ami           = "ami-0c55b159cbfafe1f0" # Example AMI ID
  instance_type = "t2.micro"
  tags = {
    Name = "HelloWorld"
  }
}

When you run terraform plan, you get output like this:

Terraform used the selected backend "local" to store state. Terraform will automatically
use this backend unless the backend configuration changes.

Terraform will perform the following actions:

  # aws_instance.example will be created
  + resource "aws_instance" "example" {
      + ami                                  = "ami-0c55b159cbfafe1f0"
      + arn                                  = (known after apply)
      + associate_public_ip_address          = (known after apply)
      + availability_zone                    = (known after apply)
      + cpu_core_count                       = (known after apply)
      + cpu_threads_per_core                 = (known after apply)
      + disable_api_termination              = false
      + ebs_block_device                     = (known after apply)
      + hibernation                          = false
      + host_id                              = (known after apply)
      + id                                   = (known after apply)
      + instance_state                       = (known after apply)
      + instance_type                        = "t2.micro"
      + ipv6_address_count                   = (known after apply)
      + ipv6_addresses                       = (known after apply)
      + monitoring                           = false
      + outpost_arn                          = (known after apply)
      + password_data                        = (known after apply)
      + placement_group                      = (known after apply)
      + placement_partition_number           = (known after apply)
      + private_dns_name                     = (known after apply)
      + private_ip                           = (known after apply)
      + public_dns_name                      = (known after apply)
      + public_ip                            = (known after apply)
      + root_block_device                    = (known after apply)
      + vpc_default                          = (known after apply)
      + vpc_security_group_ids               = (known after apply)

      + enclave_options {
          + enabled = (known after apply)
        }

      + launch_template {
          + id      = (known after apply)
          + version = (known after apply)
        }

      + metadata_options {
          + http_put_response_hop_limit = (known after apply)
          + http_tokens                 = (known after apply)
          + instance_metadata_tags      = (known after apply)
        }

      + network_interface {
        }

      + network_interface_association {
        }

      + root_block_device {
          + delete_on_termination = (known after apply)
          + device_name           = (known after apply)
          + volume_id             = (known after apply)
        }

      + tags = {
          + "Name" = "HelloWorld"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

This output is a bit overwhelming, especially for complex changes. It shows everything that Terraform knows about the resource, including attributes that will be determined after the apply ((known after apply)) or are simply defaults. It’s hard to quickly scan and see the meaningful changes.

The problem Terraform is solving is "infrastructure as code." You declare your desired state in Terraform files, and Terraform figures out how to get your actual infrastructure to match that state. The plan command shows you what Terraform thinks needs to happen. But the raw output isn’t designed for human readability in a PR review.

The core issue is that terraform plan provides a verbose, detailed diff that mixes intended changes with inherent resource attributes and computed values. This makes it difficult for a human reviewer to quickly grasp the impact of the proposed changes.

To make this useful for CI and PRs, we need to parse this output and present a more concise, human-readable summary. This typically involves using tools that can process the JSON output of terraform plan -out=tfplan and then format it.

Here’s how you can approach this:

  1. Generate a JSON Plan: Run terraform plan -out=tfplan to create a binary plan file. This is a prerequisite for generating structured output.

    terraform plan -out=tfplan
    

    This command creates a file named tfplan containing the execution plan.

  2. Convert to JSON: Use terraform show -json tfplan to convert the binary plan into a JSON format.

    terraform show -json tfplan > plan.json
    

    This plan.json file is now machine-readable and contains all the details of the plan.

  3. Parse and Summarize: This is where the custom logic comes in. You’ll write a script (e.g., in Python, Go, or even a shell script with jq) to read plan.json and extract key information. You want to focus on:

    • Resources being created, updated, or destroyed.
    • Specific attribute changes, especially for critical attributes.
    • A summary count of actions.

    For example, a simple jq filter to list resources and actions:

    jq '.resource_changes[] | {address: .address, action: .change.actions}' plan.json
    

    This will output a list like:

    {
      "address": "aws_instance.example",
      "action": [
        "create"
      ]
    }
    

    For more detailed attribute changes, you’d dive deeper into .change.before and .change.after.

  4. Format for PR Comments: The parsed data can then be formatted into a clear, concise message for your Pull Request. Many CI/CD platforms (GitHub Actions, GitLab CI, etc.) have APIs or specific syntax to post comments.

    A good format might be:

    • Summary: Plan: X to add, Y to change, Z to destroy.
    • Resources Added: List of resource_type.resource_name.
    • Resources Modified: List of resource_type.resource_name with a high-level indication of what changed (e.g., "instance_type modified", "tags updated").
    • Resources Destroyed: List of resource_type.resource_name.

    For example, a comment might look like:

    Terraform Plan Summary:
    
    *   **Additions (1):**
        *   `aws_instance.example`
    *   **Modifications (0):**
        *   None
    *   **Destructions (0):**
        *   None
    
    View full plan: [link to artifact or detailed output]
    

    You can get more granular by showing which attributes changed. If instance_type changed from t2.micro to t3.small, you’d highlight that.

    Terraform Plan Summary:
    
    *   **Additions (1):**
        *   `aws_instance.example`
    *   **Modifications (1):**
        *   `aws_instance.production_db`:
            *   `instance_type`: `t2.micro` -> `t3.small`
            *   `tags.Name`: `production` -> `prod-db`
    *   **Destructions (0):**
        *   None
    

    Tools like tf-plan-parser or infracost (which also does cost analysis) can automate much of this parsing and formatting.

The most surprising thing about terraform plan is how much of its output is noise for human review. The (known after apply) placeholders are not indicating a potential failure, but simply that Terraform doesn’t know the value until the resource is actually created or updated by the cloud provider. It’s a signal of dependency resolution, not an error state.

When you parse the JSON output, you’ll see that each resource_changes object has a change field. Within change, you’ll find actions (like create, update, delete), before (the state before the change), and after (the state after the change). The before and after are maps of attribute names to their values. To identify specific attribute modifications, you compare these maps. For instance, if after["instance_type"] is different from before["instance_type"], that’s a change you want to report.

The terraform plan -json output is structured such that resource_changes is an array. Each element in this array represents a single resource operation. For updates, the change object contains before and after maps. You iterate through these maps, comparing keys and values. If a key exists in both before and after but has a different value, it’s a modified attribute. If a key exists only in after, it’s an attribute being set for the first time (common for newly created resources or attributes added in a Terraform version upgrade). If a key exists only in before, it’s an attribute that is being removed.

The next conceptual hurdle is integrating this into your CI/CD pipeline reliably, handling cases where Terraform itself fails to plan, and deciding on a threshold for what constitutes a "significant" change that requires explicit PR approval.

Want structured learning?

Take the full Terraform course →