Performance Optimization #

Slow Terraform isn’t just a convenience problem — a CI/CD pipeline waiting 20 minutes for one plan slows down the entire team workflow. Most Terraform performance problems come from one source: too many API calls to the cloud provider during state refresh. Understanding where time is spent and how to reduce it — without sacrificing reliability — is an essential skill for teams managing large infrastructure.

flowchart TD
    A["terraform plan"] --> B["Refresh state\n(API calls)"]
    B --> C["Compare with\nconfig"]
    C --> D["Generate plan"]
    B --> E["100 resources\n= 100+ API calls"]
    E --> F["Slow plan\n~20 min"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#f59e0b,stroke:#d97706,color:#fff
    style C fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style D fill:#10b981,stroke:#059669,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style F fill:#ef4444,stroke:#dc2626,color:#fff

Measure Before Optimizing #

# Always measure before optimizing — don't assume where the bottleneck is

# Enable detailed logging
TF_LOG=INFO terraform plan 2>&1 | grep -E "provider|Refreshing|Reading"

# Or use timestamps for each phase
time terraform init
time terraform plan
time terraform apply

# For more detailed profiling (Terraform 1.2+)
TF_LOG=TRACE terraform plan 2>&1 | grep "provider.aws" | tail -50
# This shows all API calls to the AWS provider

# Output to watch for:
# [INFO] provider.aws: Refreshing state... (aws_instance.web)
# → Each line like this is one API call
# → If there are hundreds of lines, this is the source of slowness

-refresh=false: Skipping State Refresh #

# ANTI-PATTERN: Always refreshing (the default)
terraform plan
# → Terraform calls the cloud API for every resource in state
# → 200 resources = 200 API calls before the plan starts
# → Can take minutes if there are many resources

# CORRECT: Skip refresh if the state is known to be accurate
terraform plan -refresh=false
# → Directly compare the configuration with the existing state
# → Much faster, but doesn't detect drift

# When -refresh=false is SAFE to use:
# ✓ CI/CD pipelines where nobody can change infrastructure outside Terraform
# ✓ During fast iteration while developing a new configuration
# ✓ Right after an apply was just run (state is definitely accurate)

# When -refresh=false is NOT SAFE:
# ✗ If there's a possibility of manual changes in the cloud
# ✗ For drift detection (that's exactly when refresh is needed)
# ✗ Before applying to production that hasn't been planned in a long time

Parallelism: Controlling Concurrency #

Terraform by default runs a maximum of 10 operations concurrently. For independent resources, raising this number can speed up applies. But too high can cause API throttling from the cloud provider.

# The default parallelism is 10
terraform apply

# Raise it for infrastructure with many independent resources
terraform apply -parallelism=20

# Lower it if you often hit API rate limits
terraform apply -parallelism=5

# For AWS: rate limits differ per service
# EC2: fairly high (parallelism 20+ is fine)
# IAM: stricter (stay at 5-10)
# CloudFormation: very strict (drop to 3-5)

# Find the optimal value by experimenting:
for p in 5 10 15 20; do
  echo "Testing parallelism=$p"
  time terraform apply -parallelism=$p -auto-approve
done

Optimizing Data Sources #

Unnecessary data sources are one of the hidden causes of slow plans — every data source produces at least one API call.

# ANTI-PATTERN: Data sources called too often
# Each resource calls the data source separately
resource "aws_instance" "web_1" {
  ami = data.aws_ami.ubuntu.id  # 1 API call
}
resource "aws_instance" "web_2" {
  ami = data.aws_ami.ubuntu.id  # The same API call again (but Terraform caches it)
}
# Terraform automatically caches identical data sources — this is actually OK

# What's TRULY problematic: data sources with heavy filters
data "aws_instances" "all_running" {
  filter {
    name   = "instance-state-name"
    values = ["running"]
  }
  # This lists ALL running instances → expensive if there are thousands
}

# CORRECT: Use more specific filters
data "aws_instances" "web_fleet" {
  filter {
    name   = "tag:Role"
    values = ["web-server"]
  }
  filter {
    name   = "tag:Environment"
    values = ["production"]
  }
  # Tight filters → lighter API calls
}
# ANTI-PATTERN: Data sources for already-known values
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

# If the region and account ID are known and don't change,
# it's more efficient to use a variable or local
locals {
  region     = "ap-southeast-1"
  account_id = "123456789012"
}

# Data sources remain useful for truly dynamic values
# (latest AMI, available AZs, etc.)

-target: Specific Operations Only #

-target lets you run a plan or apply on specific resources only, ignoring the others.

# Plan only specific resources
terraform plan -target=aws_instance.web
terraform plan -target=module.database

# Apply only specific resources
terraform apply -target=aws_security_group.new_rule

# Useful for:
# ✓ Debugging — isolate a problematic resource
# ✓ Bootstrapping — create needed resources before others
# ✓ Emergency fixes — fix one resource without risking others
-target is a tool for special situations, not routine workflows. Repeated use can cause inconsistent state — Terraform’s dependency graph isn’t fully executed, so changes to the targeted resource may not propagate to resources depending on it. After using -target, always run terraform plan without a target to make sure nothing is left behind.

Provider Cache #

Every time terraform init runs, Terraform downloads the provider. In frequently run CI/CD, this consumes time and bandwidth.

# Set up a provider cache (in CI/CD or locally)
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
mkdir -p $TF_PLUGIN_CACHE_DIR

# Now terraform init will check the cache before downloading
terraform init
# If the provider is already cached → use it directly, no re-download

# In GitHub Actions: cache the provider with actions/cache
- name: Cache Terraform Plugins
  uses: actions/cache@v4
  with:
    path: ~/.terraform.d/plugin-cache
    key: ${{ runner.os }}-terraform-${{ hashFiles('**/.terraform.lock.hcl') }}
    restore-keys: |
      ${{ runner.os }}-terraform-

- name: Terraform Init
  run: terraform init
  env:
    TF_PLUGIN_CACHE_DIR: ~/.terraform.d/plugin-cache

Optimizing terraform init #

# ANTI-PATTERN: terraform init that re-downloads every time
terraform init  # Always downloads from the registry

# CORRECT: Use -upgrade only when you actually need a provider upgrade
terraform init          # Use the already locked versions
terraform init -upgrade # Only when upgrading to the latest versions

# For CI/CD: skip backend init if not needed
terraform init -backend=false  # For validation only, no state access

# Typical benchmark times:
# terraform init without cache:   30-60 seconds (provider download)
# terraform init with cache:  2-5 seconds (copy from cache)
# This difference is significant if the pipeline runs dozens of times a day

flowchart LR
    A["Split\nstate"] --> B["-target\nspecific"]
    B --> C["Parallel\nexecution"]
    C --> D["Caching\nprovider"]
    D --> E["Fast plan\n~2 min"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#3b82f6,stroke:#1e40af,color:#fff
    style C fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style D fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style E fill:#10b981,stroke:#059669,color:#fff

Parallelism Tuning #

Terraform can create resources in parallel. Adjusting parallelism can speed up applies.

# Default: 10 parallel resources
terraform apply

# Raise it for large infrastructure
terraform apply -parallelism=20

# Lower it if the API is rate-limited
terraform apply -parallelism=3

# For providers sensitive to concurrent requests
# (for example: some SaaS APIs)
terraform apply -parallelism=1
# Monitoring execution times
time terraform plan
time terraform apply -auto-approve

# Terraform trace logging for performance debugging
TF_LOG=TRACE terraform plan 2>&1 | tee trace.log
# Look for "applyable" to see which resources are slow

Provider Caching #

# Enable provider plugin caching to speed up init
# ~/.terraformrc:
# provider_cache {
#   dir = "$HOME/.terraform.d/plugin-cache"
# }

# Or set it via an environment variable
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
terraform init
# The provider is only downloaded once, other workspaces use the cache

# Clean the cache if needed
rm -rf $TF_PLUGIN_CACHE_DIR/*
# Use efficient data sources
# BAD: Fetching all instances
data "aws_instances" "all" {}

# GOOD: Specific filters
data "aws_instances" "web" {
  filter {
    name   = "tag:Role"
    values = ["web"]
  }
}

Plan Caching #

# Terraform doesn't cache plans natively
# But there are several strategies to speed things up:

# 1. Save the plan output and apply later
terraform plan -out=plan.cache
terraform apply plan.cache
# Avoids re-planning which can take time

# 2. Targeted refresh (only changed resources)
terraform plan -target=aws_instance.web
# Useful during development, NOT in production

# 3. Reduce data source calls
# Reduce the number of data sources, use locals caching

# 4. Parallel backend initialization
# terraform init can download providers in parallel
terraform init -plugin-dir=/shared/plugin-cache

Provider Plugin Caching #

# Cache provider plugins to speed up init
mkdir -p ~/.terraform.d/plugin-cache

# ~/.terraformrc
# plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

# Or set via an environment variable
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"

# In CI/CD: persist the cache between runs
# GitHub Actions:
- uses: actions/cache@v3
  with:
    path: ~/.terraform.d/plugin-cache
    key: terraform-providers-${{ hashFiles('.terraform.lock.hcl') }}
# Shared plugin cache for the team
# Store it on a shared filesystem or S3
terraform init -plugin-dir=/shared/terraform/plugins

Summary #

  • Measure first, optimize later — enable TF_LOG=INFO to see which API calls take the most time before deciding on an optimization approach.
  • -refresh=false is the biggest optimization for tightly controlled CI/CD — skipping hundreds of refresh API calls can cut plan time from minutes to seconds.
  • Parallelism can be raised from the default 10 for large infrastructure, but lower it if you often hit cloud provider API rate limits.
  • Data sources with loose filters are a hidden bottleneck — filter as tightly as possible so API responses are smaller and faster.
  • Provider caches cut terraform init time from 30-60 seconds to 2-5 seconds — a must for frequently run CI/CD.
  • -target for special situations only — after using it, always run a full plan to make sure no inconsistency is left behind.

← Previous: Scale Terraform   Next: Anti-Pattern: Terraform as CM →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact