Python vs Terraform vs Ansible

Choosing between a Python IaC framework, Terraform's HCL, and Ansible is not a syntax preference — the three tools disagree about what infrastructure code is. One describes a desired graph and reconciles it against recorded state, one runs a sequence of tasks against machines that already exist, and one lets a general-purpose language emit the graph. This page sits under Python IaC Fundamentals & Strategy and compares the three on the axes that actually decide adoption: execution model, state, failure behaviour, testability, and migration cost.

Three guides go deeper on the decisions this page frames. Why Python is replacing HCL for modern IaC takes the typing and testability argument seriously, including where HCL is still the better answer. Pulumi vs CDKTF for AWS: a side-by-side comparison builds the same VPC and bucket in both Python engines and puts the trade-offs in a decision table. Pulumi vs AWS CDK for Python teams covers the other fork a Python team faces: an engine that owns the lifecycle versus a template generator that hands it to CloudFormation.

Problem Framing

The comparison goes wrong when it is framed as "which tool is best". All three can create an S3 bucket. What separates them is which layer of change they were designed to own, and what each one does when a change goes wrong at three in the morning.

A resource graph is a set of objects with identity and edges: a VPC that must exist before its subnets, a subnet group that must exist before an RDS instance, an IAM role whose ARN a Lambda function needs. Managing that layer without a ledger of what already exists means re-deriving identity on every run, which is why Terraform, Pulumi and CDKTF all keep state and why Ansible's cloud modules feel awkward as soon as the graph is more than a handful of resources deep.

The interior of a running host is a different problem. There is no graph to diff; there is a machine whose current configuration you can only discover by connecting to it. Ansible's model — connect over SSH or WinRM, run modules in written order, each module responsible for its own idempotency — fits that shape exactly. Expressing "nginx is installed and its config file matches this template" as a resource with a stable ID and a diffable attribute set is where declarative provisioners become painful.

Most teams own both layers, so the real question is where the seam sits and who is accountable for it. Deciding that before comparing syntax removes most of the argument, because it turns "Terraform or Pulumi or Ansible" into two smaller questions with clearer answers.

Prerequisites

An honest comparison needs all three toolchains actually installed, because the differences that matter show up in plan output and error messages rather than in documentation.

  • Python 3.9+ with a virtual environment per project — Pulumi executes your program with that interpreter, and cdktf get writes typed provider bindings into the same site-packages.
  • The terraform binary on PATH. CDKTF shells out to it for init, plan and apply, so a version mismatch surfaces during synthesis rather than as a CDKTF error.
  • ansible-core 2.15+ plus the amazon.aws or azure.azcollection collection if you intend to evaluate the configuration layer.
  • One cloud account you are permitted to create and destroy resources in, with credentials resolvable by each tool's standard chain.
# CLI: verify every engine is present and reporting a usable version.
python --version          # 3.9+ for the typing syntax used throughout this page
pulumi version            # engine version; provider plugins download per stack
cdktf --version           # must be compatible with the generated bindings
terraform version         # CDKTF and Terraform share this one binary
ansible --version         # confirms ansible-core and the collections path

Create a throwaway project per engine before comparing anything, so dependency-resolution problems are not mistaken for tool limitations — the mechanics are in Setting Up Dev Environments.

Core Paradigms: Declarative vs. Procedural vs. Configuration Management

When evaluating Python IaC frameworks against Terraform and Ansible, architects must first understand what each tool is actually doing. Terraform enforces idempotency via state-driven DAG resolution: it knows what exists and computes the minimal delta to reach the desired state. Ansible executes imperative task sequences against live inventory without persistent state tracking—making it excellent for configuration management and post-provision setup, but fragile as a provisioning engine for complex resource graphs. Python frameworks (Pulumi, CDKTF) introduce procedural control flow with full language features, enabling dynamic resource generation and native testing. For foundational context see Python IaC Fundamentals & Strategy, and for a focused engine-by-engine view read Pulumi vs CDKTF for AWS: A Side-by-Side Comparison.

Core Paradigms: Declarative vs Procedural vs Configuration Management Core Paradigms: Declarative vs Procedural vs Configuration Management: Core Paradigms then Configuration then Python IaC then Terraform then DAG Core Paradigms Configuration Python IaC Terraform DAG
Core Paradigms: Declarative vs Procedural vs Configuration Management: the stages run left to right — Core Paradigms, Configuration, Python IaC, Terraform, DAG.

Key characteristics of each tool:

  • Terraform: Declarative HCL, state-file-driven DAG, large provider ecosystem, mature but limited in control flow
  • Ansible: Agentless push-based execution, YAML playbooks, stateless—no drift detection without external tools
  • Pulumi / CDKTF: Python-first with full control flow, native testing frameworks, IDE autocomplete from typed SDKs

The word "declarative" does more harm than good in this comparison, because all three tools are declarative about something and imperative about something else. HCL is declarative about resources and imperative about nothing — which is why count, for_each and dynamic blocks exist as increasingly elaborate workarounds for the loops the language refuses to have. Ansible is declarative about the state of a host (a package is present, a service is running) and strictly imperative about ordering — tasks run top to bottom, and a playbook that works only when run twice is a playbook nobody notices is broken. Pulumi and CDKTF are imperative in the program and declarative in the result: your Python runs to completion building a graph, and only then does an engine decide what to create.

That last distinction has a consequence teams underestimate. In Pulumi and CDKTF, arbitrary Python runs at graph-construction time — you can read a file, call an API, or loop over a database query to decide how many subnets to create. Nothing in HCL can do that without an out-of-band data source, and nothing in Ansible can do it without a task that runs against a host. The cost is that infrastructure now depends on the correctness of a program, which is exactly why the testing section below is not optional.

The other axis nobody puts in a comparison table is what happens to a change nobody codified. Terraform and Pulumi both detect it: the next plan or preview compares recorded state with reality and shows you the difference. Ansible cannot, because it has no record of what it did last time — a playbook re-run simply reasserts the tasks it contains, and anything it does not mention is invisible to it. That single property is the strongest argument for keeping provisioning out of playbooks even in shops with deep Ansible expertise.

The Execution Model, Step by Step

Every comparison becomes concrete once you follow one change from source file to cloud API call. The four tools take four distinct paths, and most of the operational differences fall out of the path rather than the syntax.

How a desired state becomes an API call How a desired state becomes an API call: Source → Planner → Provider → Cloud API. Source Planner Provider Cloud API declare desired state read prior state Diff Describe actual attributes Create or Update write new state
Terraform, CDKTF and Pulumi all run this loop; Ansible skips the prior-state read entirely.

Terraform parses HCL into a graph, walks it to build a plan, and calls provider plugins over gRPC — each provider is a separate binary launched as a child process. Every resource is diffed against the state file first, so a plan is a three-way comparison between configuration, state, and (when refreshed) the live API. CDKTF changes only the first step: your Python program is a code generator that emits cdk.tf.json, and from terraform init onwards the path is identical, which is why CDKTF inherits the entire Terraform provider ecosystem and every one of its state semantics.

Pulumi replaces the middle of that path. There is no intermediate document: the Python program is a client of the engine's resource monitor, registering resources as it executes, and the engine schedules provider calls concurrently as dependencies resolve. That is why a Pulumi preview can be more accurate for values computed in code and less predictable when the program itself has side effects — the program is the plan.

Ansible does something categorically different. There is no graph, no plan, and no diff against recorded state. ansible-playbook resolves inventory, connects over SSH or WinRM, copies a module to the target, executes it, and collects JSON. Idempotency is a property each module implements individually, not a guarantee of the engine, which is why command and shell tasks need a hand-written creates: or changed_when: to behave. Nothing prevents a playbook from being run against half the fleet and abandoned.

# compare_engines.py — the shape of the same intent in each Python engine
# CLI: python compare_engines.py --engine pulumi
from dataclasses import dataclass

import pulumi_aws as aws
from cdktf import App, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.subnet import Subnet
from cdktf_cdktf_provider_aws.vpc import Vpc
from constructs import Construct


@dataclass(frozen=True)
class NetworkIntent:
    cidr_block: str
    az_count: int
    name: str


def build_pulumi(intent: NetworkIntent) -> None:
    """Registers resources directly with the Pulumi engine as this function runs."""
    vpc = aws.ec2.Vpc(intent.name, cidr_block=intent.cidr_block, enable_dns_hostnames=True)
    # State implication: the engine records this resource under a URN as soon as the
    # constructor returns; there is no intermediate template to inspect.
    for index in range(intent.az_count):
        aws.ec2.Subnet(
            f"{intent.name}-private-{index}",
            vpc_id=vpc.id,
            cidr_block=f"10.0.{index}.0/24",
        )


class NetStack(TerraformStack):
    """Emits Terraform JSON; the Terraform binary owns every provider call after synth."""

    def __init__(self, scope: Construct, ns: str, intent: NetworkIntent) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region="eu-west-1")
        vpc = Vpc(self, "vpc", cidr_block=intent.cidr_block)
        for index in range(intent.az_count):
            # Provider note: this becomes a plain resource block in cdk.tf.json —
            # the loop is unrolled at synth time, not evaluated by Terraform.
            Subnet(self, f"private-{index}", vpc_id=vpc.id,
                   cidr_block=f"10.0.{index}.0/24")


def build_cdktf(intent: NetworkIntent) -> None:
    app = App()
    NetStack(app, intent.name, intent)
    app.synth()

The loop in build_cdktf is the whole argument in miniature. It disappears at synthesis: the generated JSON contains three literal subnet blocks, so Terraform never sees a loop and none of count's restrictions apply. The same loop in HCL would need count = var.az_count, and if that value derived from another resource's attribute the plan would fail outright.

State Management & Provider Ecosystems

Terraform relies on centralized state files and explicit provider plugins with a consistent locking model (S3 + DynamoDB, Terraform Cloud, etc.). Ansible operates statelessly against live inventory—there is no drift detection built in. Python-based frameworks abstract this divergence differently: Pulumi maintains its own state backend (Pulumi Cloud, S3, or local), while CDKTF synthesizes to Terraform JSON and delegates state management entirely to the Terraform binary. Review IaC Design Principles for safe state isolation patterns.

State Management & Provider Ecosystems State Management & Provider Ecosystems: State Management & Pro with 4 facets. State Management & Pro State key element Provider key element Terraform key element DynamoDB key element
State Management & Provider Ecosystems: how State, Provider, Terraform relate in this pattern.

State corruption or concurrent writes can cascade into production outages. Enforce backend locking and encryption at the infrastructure layer regardless of which tool you use.

# CLI: pulumi login s3://my-iac-state && pulumi config set backend_uri s3://my-iac-state --secret
import pulumi

def resolve_backend_uri() -> str:
    """Resolve the backend URI selected with `pulumi login` before deployment."""
    config = pulumi.Config()
    return config.require("backend_uri")

# State implication: all resource IDs are serialized to the remote backend.
# Concurrent runs without stack isolation will corrupt state and trigger drift.

The two state formats are not interchangeable and the difference shows up in day-to-day work. A Terraform state document is a flat resource list keyed by module path and address, with a serial and a lineage; a Pulumi checkpoint is an ordered list of resources keyed by URN, with each entry carrying the inputs the program supplied and the outputs the provider returned. The practical consequence is that Pulumi can tell you which declared input drifted separately from which read-back attribute changed, whereas Terraform folds both into one diff.

Secrets are handled differently too, and this is often the deciding detail for regulated teams. Terraform writes sensitive attributes into state in plaintext and relies on the backend for confidentiality — an RDS password is readable by anyone with s3:GetObject on the state prefix. Pulumi encrypts values marked secret inside the checkpoint itself with a per-stack key, so the ciphertext is safe even if the object leaks. Neither model removes the need to lock down the backend, and the mechanics for both are covered in managing IaC state for Python projects.

Ansible's statelessness is not a defect, it is a scope decision — but it means drift detection has to come from somewhere else. Running a playbook in --check --diff mode tells you what this playbook would change on the hosts it targets, and nothing about resources it never mentions. Teams that rely on that as a compliance control are measuring their own coverage, not their estate. Pair it with a real detector, as described in detecting and remediating state drift in Python IaC.

Where Each Engine Actually Fails

Comparison tables tend to list features. It is more useful to know what each tool does when it is unhappy, because that is where the hours go.

Where each engine breaks, and what it prints Where each engine breaks, and what it prints: comparison across Characteristic failure, Error text. Engine Characteristic failure Error text Terraform HCL Count depends on an unknown Invalid count argument Terraform HCL Mutual reference between resources Cycle: aws_security_group.a Ansible Task not idempotent on re-run FAILED! changed: false, msg Pulumi Two resources share a name Duplicate resource URN CDKTF Construct arg rejected in the bridge jsii.errors.JSIIError
The first minute of debugging is recognising which layer produced the message.

Terraform's signature failure is the unknown value at plan time: Error: Invalid count argument — The "count" value depends on resource attributes that cannot be determined until apply. It appears whenever the number of resources depends on something that does not exist yet, and there is no way to write around it in HCL — the standard remedy is to split the configuration into two applies. Neither Pulumi nor CDKTF hits it in the same way, because both compute the loop in Python before anything is planned, though CDKTF can reintroduce it if you pass a Terraform token where a Python int is required.

The second Terraform classic is Error: Cycle: aws_security_group.a, aws_security_group.b, produced by two resources that reference each other — typically two security groups each allowing the other. The graph is built from references, so the fix is to break the reference with a standalone rule resource rather than to reorder anything. And Error: Provider produced inconsistent final plan is the provider's way of saying the value it returned after apply differs from what it promised at plan; it is a provider bug, and pinning the provider version is the only reliable mitigation.

Ansible fails per host and keeps going, which is both its strength and its hazard: fatal: [web03]: FAILED! => {"changed": false, "msg": "..."} next to nine hosts that succeeded leaves the fleet in a mixed state with no record of the split. Recovery is re-running with --limit web03 and hoping the earlier tasks were genuinely idempotent.

Pulumi's characteristic errors are identity errors — error: Duplicate resource URN, usually a loop that forgot to include its index in the resource name — and Python errors leaking into the deploy, such as AttributeError: 'Output' object has no attribute 'split' when code treats an unresolved output as a string. CDKTF's are bridge errors: the Python classes are generated bindings over a JavaScript implementation, so a rejected argument surfaces as a jsii.errors.JSIIError with a stack trace that crosses the language boundary and is worth learning to read once.

Actionable Workflows: Provisioning, Configuration, and Orchestration

Real-world deployments require chaining resource creation, network configuration, and application bootstrapping. Establishing reproducible local testing environments is critical before scaling these workflows—see Setting Up Dev Environments for consistent dependency resolution across CI pipelines.

Actionable Workflows: Provisioning, Configuration, and Orchestration Actionable Workflows: Provisioning, Configuration, and Orchestration: Command then local_exec then Actionable then Setting Up Dev then Terraform Command local_exec Actionable Setting Up Dev Terraform
Actionable Workflows: Provisioning, Configuration, and Orchestration: the stages run left to right — Command, local_exec, Actionable, Setting Up Dev, Terraform.

Common orchestration patterns:

  • Bootstrap cloud resources with Terraform modules or CDKTF Python constructs
  • Run post-provisioning configuration with Ansible playbooks targeting provisioned inventory
  • Unify provisioning and configuration in a single Python stack using Pulumi's Command resource or CDKTF's local_exec provisioner
# CLI: cdktf synth && cdktf deploy --auto-approve
from constructs import Construct
from cdktf import TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.vpc import Vpc
from cdktf_cdktf_provider_aws.eks_cluster import EksCluster

class NetworkStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region="us-east-1")
        vpc = Vpc(self, "base-vpc", cidr_block="10.0.0.0/16")
        eks = EksCluster(
            self, "eks-cluster",
            name="prod-workloads",
            role_arn="arn:aws:iam::123456789012:role/eks-role",
            vpc_config={"subnet_ids": ["subnet-aaa", "subnet-bbb"]},
        )
        # Explicit dependency: the VPC must exist before the EKS control plane.
        eks.add_dependency(vpc)
        TerraformOutput(self, "cluster_endpoint", value=eks.endpoint)

# State implication: dependency ordering is compiled into the execution plan.
# Missing add_dependency calls can cause parallel creation failures.

The seam between provisioning and configuration is the decision most teams get wrong, and it is worth stating plainly: anything that has an API and a lifecycle belongs to the provisioning engine, and anything that lives inside a machine image belongs to configuration management — or, better, to the image build. A playbook that creates a load balancer is a resource nobody can find in state. A Terraform provisioner "remote-exec" that installs packages is a step that runs exactly once at create time and never again, so the host drifts from the day it is built.

Where the two genuinely have to meet, make the handoff explicit rather than implicit. Export the inventory from the provisioning engine — a stack output containing instance IDs and private addresses — and feed it to Ansible as a dynamic inventory source, so the playbook targets exactly what was provisioned. Pulumi's Command resource and CDKTF's local-exec provisioner can invoke the playbook inline, but both then hide a mutable step inside a graph that assumes idempotency; running it as a separate pipeline stage keeps the failure attributable.

Testing, Modularity, and CI/CD Integration Patterns

The shift toward programmatic infrastructure enables native unit testing, mocking, and static analysis—a primary reason teams adopt Python IaC. See Why Python is replacing HCL for modern IaC for a deeper treatment of this tradeoff. Untested IaC introduces silent configuration drift and compliance violations that only manifest during production incidents.

Testing, Modularity, and CI/CD Integration Patterns Testing, Modularity, and CI/CD Integration Patterns: ruff then bandit then moto then unittest.mock then CD Integration ruff bandit moto unittest.mock CD Integration
Testing, Modularity, and CI/CD Integration Patterns: the stages run left to right — ruff, bandit, moto, unittest.mock, CD Integration.

Testing strategies by layer:

  • Static analysis: mypy --strict, ruff, bandit for security linting
  • Unit testing: Mock cloud APIs with moto (AWS) or unittest.mock for provider responses
  • Plan validation: pulumi preview --diff or cdktf synth && terraform validate
  • Policy gates: OPA or Checkov against synthesized JSON before merge
# CLI: pytest tests/test_vpc.py --cov=infra --cov-fail-under=80
import pytest
from moto import mock_aws
import boto3
from my_infra.vpc import create_vpc

@pytest.fixture
def aws_session():
    with mock_aws():
        yield boto3.Session(region_name="us-east-1")

def test_vpc_creation_logic(aws_session) -> None:
    """Validate VPC CIDR allocation and subnet tagging without live cloud calls."""
    ec2 = aws_session.client("ec2")
    vpc_id = create_vpc(ec2, cidr="10.0.0.0/16", env="test")
    response = ec2.describe_vpcs(VpcIds=[vpc_id])
    assert response["Vpcs"][0]["CidrBlock"] == "10.0.0.0/16"
    env_tag = next(
        t["Value"] for t in response["Vpcs"][0]["Tags"] if t["Key"] == "Environment"
    )
    assert env_tag == "test"

# State implication: mocked tests validate logic only. Always run cdktf synth
# in staging to verify provider compatibility before merging to main.

The honest comparison here is narrower than the marketing. Terraform is not untestable — terraform validate, terraform plan -detailed-exitcode, tflint, Checkov and Terratest cover a great deal, and a policy check against a plan JSON is engine-agnostic. What Python buys is the unit level: you can instantiate one component with three different argument objects and assert on the resulting graph in milliseconds, with no cloud account and no plan. That is a different kind of test, and it is the one that catches a wrong default before it reaches an environment.

Ansible's equivalent tooling is Molecule, which spins up a container or VM per scenario and runs the role twice — once to converge, once to prove idempotency. That second run is the whole point, and it is a test HCL and Python engines get for free from their diff model. The three toolchains therefore need different CI shapes: a lint-and-plan gate for Terraform, a lint-typecheck-unit-preview gate for Python engines, and a converge-and-verify matrix for Ansible.

# tests/test_engine_parity.py — the synthesized graph must match the intent
# CLI: pytest tests/test_engine_parity.py -q
import json

from cdktf import Testing

from compare_engines import NetStack, NetworkIntent


def test_subnet_count_matches_intent() -> None:
    intent = NetworkIntent(cidr_block="10.0.0.0/16", az_count=3, name="net")
    stack = NetStack(Testing.app(), "net", intent)
    rendered = json.loads(Testing.synth(stack))
    subnets = rendered.get("resource", {}).get("aws_subnet", {})
    # Provider note: the loop is unrolled at synth, so the JSON holds three blocks.
    assert len(subnets) == intent.az_count

A Decision Guide

Tool choice is a portfolio decision, not a single answer. Most estates end up with two of the three, and the useful question is which object each one owns.

Which engine owns this change? Which engine owns this change?: choose among 3 options. What does the changeactually manage? resources Pulumi or CDKTF packages Ansible playbook shared HCL Terraform modules
Ownership follows the object being changed, not the team's language preference.
Dimension Terraform (HCL) Ansible Pulumi / CDKTF (Python)
Unit of work Resource in a DAG Task against a host Resource registered by a program
Prior state State file, locked None Checkpoint (Pulumi) or state file (CDKTF)
Drift detection Yes, on plan No Yes, on preview
Control flow count, for_each, dynamic Loops and conditionals per task Full Python
Unit testing Plan-level and policy tools Molecule scenarios Mocks, in-process, no cloud
Secrets in state Plaintext, backend-protected Not stored Encrypted per stack (Pulumi)
Best at Broad provider coverage Host configuration Dynamic graphs, typed contracts
Weakest at Loops over unknown values Provisioning, drift Onboarding non-Python teams

Read the table with one caveat: rows are not equally weighted for every team. Provider coverage decides nothing if you use three services; unit testing decides nothing if your infrastructure is fifty resources that change twice a year. Rank the rows for your estate before comparing the columns.

There is also a cost the table cannot show. A Python engine makes your infrastructure a Python project, with a lockfile, a virtual environment, a type checker in CI, and an upgrade treadmill for the provider SDKs. That is unremarkable to a team that already ships Python services and genuinely burdensome to a platform team whose members are strong in HCL and shell. Adoption failures in practice are far more often about that operational load than about any capability listed above.

Migration Strategy: Bridging Python Development to Infrastructure

Transitioning from legacy HCL or Ansible to Python-native IaC should be incremental. Begin by wrapping existing Terraform modules in CDKTF Python constructs—this preserves proven configurations while introducing programmatic validation. For Ansible migrations, identify which playbooks are actually provisioning cloud resources (better handled by Pulumi/CDKTF) versus post-provision configuration (Ansible remains appropriate). Enforce cost tracking and security baselines through automated PR checks to prevent regression during the migration phase.

Migration Strategy: Bridging Python Development to Infrastructure Migration Strategy: Bridging Python Development to Infrastructure: TerraformHclModule then mypy then Migration Strategy then HCL then Python TerraformHclModule mypy Migration Strategy HCL Python
Migration Strategy: Bridging Python Development to Infrastructure: the stages run left to right — TerraformHclModule, mypy, Migration Strategy, HCL, Python.

Practical migration steps:

  1. Audit existing resources and map them to provider resource types
  2. Wrap Terraform modules in TerraformHclModule (CDKTF) or use pulumi import to bring existing resources under management
  3. Add typed configuration classes and mypy gates to the new Python layer
  4. Replace one workload at a time, running parallel plan comparisons to verify parity

Two details separate a migration that finishes from one that stalls. The first is the acceptance criterion: a workload is migrated when the new engine produces an empty plan against the live estate, not when the code compiles. Anything else means the new code and the real world disagree, and every subsequent apply will fight the difference. The second is direction of travel — migrate leaf workloads before shared foundations, because a half-migrated VPC that both engines believe they own is the one failure mode with no clean rollback.

# CLI: prove parity before cutover — both sides must report no changes
terraform -chdir=legacy/network plan -detailed-exitcode
cdktf diff --stack network-prod
pulumi preview --stack prod --diff --non-interactive

terraform plan -detailed-exitcode returns 0 for no changes, 1 for an error and 2 for a non-empty plan, which makes it directly usable as a pipeline gate during the parallel-run period. Keep both pipelines running against the same estate for at least one full change cycle, with the legacy one in plan-only mode, and only remove it once the new engine has applied a real change successfully. The state-level mechanics of moving resources between engines are covered in migrating IaC state between backends.

Step-by-Step: Run the Evaluation on One Real Workload

Reading comparisons only narrows the field. The decision is made by building the same thing three ways and measuring what happened, on a workload you already operate — your providers, your policies, your pipeline.

A three-week evaluation that produces evidence rather than opinion A three-week evaluation that produces evidence rather than opinion: Pick one workload then Build it three ways then Measure the loop then Score the operations then Commit or discard Pick one workload VPC + queue + role Build it threeways same acceptance test Measure the loop edit to applied Score theoperations audit, rollback, on-call Commit or discard written decision
Evaluate on a workload you already run in production so the numbers reflect your providers, your policies and your pipeline.

1. Choose a workload with real edges

Pick something with at least three resource types and one cross-resource reference — a VPC with private subnets, an SQS queue, and an IAM role whose policy names the queue ARN. A single bucket proves nothing, because every tool creates a bucket well. What you are testing is how each engine handles a value that is unknown until another resource exists.

2. Build it three ways against the same acceptance test

Write the acceptance test first, in Python, so all three implementations are judged identically. Assert on the live account rather than on the tool's own output, because that is the only claim that matters.

from __future__ import annotations

import boto3


def assert_workload_present(queue_name: str, role_name: str) -> None:
    """Acceptance test run against the live account after each engine applies."""
    # CLI: pytest tests/test_acceptance.py -q --engine pulumi
    sqs = boto3.client("sqs")
    iam = boto3.client("iam")
    queue_url = sqs.get_queue_url(QueueName=queue_name)["QueueUrl"]
    attrs = sqs.get_queue_attributes(
        QueueUrl=queue_url, AttributeNames=["QueueArn", "SqsManagedSseEnabled"]
    )["Attributes"]
    assert attrs["SqsManagedSseEnabled"] == "true", "queue encryption is off"
    # State implication: this reads the cloud, not the state ledger, so it stays
    # honest even when an engine records a resource it failed to finish creating.
    policy = iam.get_role_policy(RoleName=role_name, PolicyName="queue-access")
    statements = policy["PolicyDocument"]["Statement"]
    assert any(attrs["QueueArn"] in str(s.get("Resource", "")) for s in statements)

3. Measure the edit-to-applied loop

Time the same one-line change — add a tag, widen a retention period — from keystroke to applied, three times per engine, and record where the seconds go. CDKTF pays a synthesis cost on every run that Pulumi does not; Terraform pays a provider-init cost that CDKTF inherits; Ansible pays a connection cost proportional to inventory size. These numbers vary enough between estates that borrowing someone else's is worthless.

4. Score the operational properties, then decide in writing

Rate each engine on the things you will live with: what a reviewer sees in a pull request, how a failed apply is rolled back, whether an on-call engineer unfamiliar with the tool can read the plan, and how secrets reach the provider. Write the decision down with the evidence attached — the value of the exercise is mostly in having a record of why, so the question is not reopened every quarter.

Verification

An evaluation or a migration is verified by the estate, not by the code. Three checks establish that.

# CLI: an empty plan from every engine that claims to own the workload
terraform -chdir=legacy/network plan -detailed-exitcode   # 0 = no changes, 2 = drift
pulumi preview --stack eval --diff --non-interactive      # expect "no changes"
cdktf diff --stack eval                                   # expect 0 to add/change/destroy
ansible-playbook site.yml --check --diff --limit eval      # expect changed=0

An empty plan from the engine you intend to keep proves the code matches reality. A non-empty plan from the engine you intend to retire proves you have not finished — usually because it still owns a resource the new engine also created, which is the mixed-ownership failure described below. Run both for at least one full change cycle with the outgoing engine restricted to plan-only mode.

Verify the typed layer too. mypy --strict infra/ catches the class of mistake that HCL only surfaces at apply, and running it in CI is what converts the "types catch errors earlier" argument from a claim into a measurement you can point at.

Troubleshooting

These are the failures specific to running more than one engine over the same estate, which is the situation nearly every evaluation and migration passes through.

Where a mixed toolchain breaks Where a mixed toolchain breaks: Terraform, Ansible and Python together with 4 facets. Terraform, Ansible andPython together Ownership two tools editing one resource Inventory hosts created after the play started Credentials different auth chain per tool Ordering config runs before boot finishes
Most mixed-toolchain incidents trace to unclear ownership of a resource or to inventory that was generated before it existed.

Symptom: two engines each want to create the same resource, and applies alternate between them. Cause: the workload was reimplemented in the new engine without importing the existing resources, so both ledgers believe they own a resource that exists once. You see Error: creating S3 Bucket (acme-invoices): BucketAlreadyOwnedByYou from one side and a clean create plan from the other. Fix: stop both pipelines, pulumi import aws:s3/bucketV2:BucketV2 invoices acme-invoices (or terraform import) to adopt the live resource into the new engine, then terraform state rm it from the old one so exactly one ledger tracks it.

Symptom: an Ansible play targets hosts that do not exist yet. Cause: the inventory was materialised before the provisioning step finished, so aws_ec2.yml dynamic inventory was queried against an account that had not yet created the instances. The play reports skipping: no hosts matched and exits 0, which no pipeline notices. Fix: re-run inventory refresh between the provisioning and configuration stages, and add - fail: msg="empty inventory" guarded by when: groups['app'] | length == 0 so an empty match is an error rather than a silent success.

Error: UnauthorizedOperation: You are not authorized to perform this operation from one tool and success from another. Cause: each engine resolves credentials through a different chain — Terraform and CDKTF read the AWS provider block plus the environment, Pulumi reads its own config plus the environment, Ansible reads ansible.cfg, environment variables and module arguments. A profile set for one is invisible to the others. Fix: pin the identity explicitly per engine and assert it before running anything: aws sts get-caller-identity --query Arn in the pipeline step, compared against the ARN you expect.

Symptom: configuration runs succeed but the change is missing after a reboot. Cause: the play executed while cloud-init was still writing the same files, so the image bootstrap overwrote the configuration afterwards. Nothing fails; the ordering is simply wrong. Fix: gate the configuration stage on readiness rather than on the provisioning stage returning — wait_for_connection plus a cloud-init status --wait task — or move the settings into the image build so there is no race to lose.

Error: Error: Provider configuration not present after removing a resource from a partially migrated stack. Cause: the resource was deleted from the Python program while its entry remained in state, and the provider block that served it was removed at the same time, so Terraform cannot plan the destroy. Fix: restore the provider block, apply the destroy, then remove the provider — or terraform state rm the orphan if the resource is now owned by the other engine.

Key Takeaways

Terraform remains the right choice when your team needs the broadest provider ecosystem and established HCL expertise. Ansible shines for configuration management and ad-hoc operational tasks. Python frameworks (Pulumi, CDKTF) deliver real value when you need dynamic resource generation, unit testing of infrastructure logic, or tighter integration with application codebases. The migration cost is real—budget for state migration, team upskilling, and pipeline rework before committing. Decide by which object each tool owns, keep the seam between provisioning and configuration explicit, and make an empty plan the definition of done.

FAQ

Are these tools mutually exclusive?

No — many teams use Terraform or Python IaC for provisioning and Ansible for configuration management; the question is which owns which layer. Draw the line at the API boundary: anything with a cloud lifecycle belongs to the provisioning engine, anything inside a machine belongs to configuration management or the image build.

Which is best for a Python team?

A Python-native tool (Pulumi or CDKTF) reduces context switching; see Pulumi vs CDKTF and Pulumi vs AWS CDK. Choose CDKTF if you want to keep the Terraform provider and state ecosystem, and Pulumi if you want the engine and the language to be the same thing.

Is Ansible infrastructure as code?

It is configuration-as-code and can provision, but its strength is imperative configuration of existing hosts rather than declarative resource graphs. It keeps no record of previous runs, so it cannot detect drift in anything a playbook does not explicitly mention.

Can I use Python IaC and keep my existing Terraform modules?

Yes. CDKTF's TerraformHclModule construct instantiates a published or local HCL module from Python, passing typed variables in and reading outputs back, so proven modules stay in service while new code is written in Python. Pulumi's equivalent is to import the resources the module created and reimplement it as a component when it next needs changing.

Why does Terraform reject a loop that works fine in Python?

Because the loop is evaluated at different times. In CDKTF and Pulumi the Python loop runs before anything is planned, so the count is always a known number; in HCL, count is evaluated by Terraform itself, and if the value derives from an attribute that does not exist yet it fails with Error: Invalid count argument.

How long does a migration from HCL to Python IaC take?

Budget by workload rather than by line count, and treat an empty plan from the new engine as the acceptance test for each one. A team migrating leaf workloads first, with both pipelines running in parallel for a change cycle, typically moves a service in days and a shared network foundation in weeks.