Pulumi vs CDKTF for AWS: A Side-by-Side Comparison

Both Pulumi and CDKTF let you define AWS infrastructure in typed Python, but they differ on execution model, state ownership, and provider coverage in ways that decide which fits your team — this comparison puts them head to head with a decision table and the same S3 bucket plus VPC written both ways, as part of Python vs Terraform vs Ansible under Python IaC Fundamentals & Strategy.

The short version: CDKTF synthesizes to Terraform and reuses the entire Terraform provider and state ecosystem, while Pulumi runs your Python program directly against its own engine. The deeper trade-offs follow.

Context

Teams evaluating Python IaC for AWS almost always shortlist these two because they avoid HCL — a tension explored in Why Python is Replacing HCL for Modern IaC. The choice usually comes down to whether you want to stay inside the Terraform ecosystem (CDKTF) or adopt a self-contained engine with first-class language support (Pulumi).

Context Context: Context with 4 facets. Context Python IaC key element AWS key element HCL key element Why Python key element
Context: how Python IaC, AWS, HCL relate in this pattern.

The difference that surprises people is what runs your Python. Pulumi executes it as an ordinary CPython process: the CLI starts a language host, your program runs top to bottom, and each resource constructor sends a RegisterResource gRPC call to the Pulumi engine, which asks the relevant provider plugin to diff and then create it. A stack trace is a Python stack trace, a breakpoint in pdb works, and print() lands in the console.

Pulumi: from Python to the AWS API Pulumi: from Python to the AWS API: Your CPython program then Language host then Pulumi engine then Provider plugin Your CPythonprogram runs top to bottom Language host RegisterResource gRPC Pulumi engine diff against checkpoint Provider plugin aws-sdk-go calls
One process tree, one language: a stack trace is a Python stack trace.

CDKTF does not work that way. The constructs library is TypeScript, and the Python bindings are generated by jsii, which starts a Node.js child process and marshals every constructor call and property access across it. Your Python file is real Python, but Vpc(self, "main-vpc", ...) is a proxy for an object living in Node.

CDKTF: from Python to the AWS API CDKTF: from Python to the AWS API: Your CPython program then Node.js constructs then cdk.tf.json then Terraform core then AWS provider Your CPythonprogram jsii proxy objects Node.js constructs TypeScript library cdk.tf.json synthesized config Terraform core graph, refresh, plan AWS provider provider binary
Two runtimes and a JSON boundary: everything must survive synthesis to reach Terraform.

That indirection is invisible when things work and conspicuous when they do not. A validation failure inside a construct surfaces as a Python exception whose message was produced by TypeScript, often with a JavaScript stack fragment attached; a debugger stepping into a construct steps into jsii's marshalling layer rather than into the construct's logic; and process startup carries the cost of spawning Node before the first line of infrastructure code runs. In exchange, CDKTF inherits every Terraform provider, every published module and the entire Terraform state and policy ecosystem without reimplementation.

The second structural difference follows from the first. Pulumi's program is the plan — the engine learns what you want by running the code and watching the registrations, so a for loop, a conditional or a function call is simply Python. CDKTF's program produces a plan: it synthesizes cdk.tf.json and hands it to Terraform, which then builds its own graph and does its own refresh. Everything Terraform knows about your intent has to survive that JSON boundary, which is why CDKTF has no equivalent of "run a Python function during apply" and why its escape hatches are all about writing the right JSON.

Prerequisites

Prerequisites Prerequisites: layered from pulumi down to Terraform. pulumi cdktf Python AWS Terraform
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ and AWS credentials with permission to create VPCs and S3 buckets.
  • For Pulumi: pulumi CLI and pip install pulumi pulumi-aws.
  • For CDKTF: cdktf CLI, the Terraform binary, and the generated AWS provider bindings (cdktf get).
  • A chosen state backend for each tool — see Managing IaC State for Python Projects.

Decision Table

Decision Table Decision Table: choose among 3 options. Decision Table if Testing.synth if Output if Decision Table
Decision Table: choosing between Testing.synth, Output, Decision Table.
Criterion Pulumi (Python) CDKTF (Python)
Execution model Runs your Python program against the Pulumi engine Synthesizes Python to Terraform JSON, then Terraform applies
State Pulumi-format checkpoint (Pulumi Cloud, S3, GCS, local) Standard Terraform .tfstate in any Terraform backend
Typing Native typed SDK, Output[T] for deferred values Native typed constructs over generated provider bindings
Testing pulumi.runtime.set_mocks unit tests Snapshot test synthesized JSON via Testing.synth
Provider coverage Native + Terraform-bridged providers Every Terraform provider (largest ecosystem)
Learning curve One tool, async Output model to learn Two layers (CDKTF + Terraform), but reuses TF knowledge
Best when You want a self-contained, language-first engine You already invest in Terraform providers/state/policy

Implementation

1. The same AWS resources with Pulumi

Implementation Implementation: 1. The same AWS then 2. The same AWS then 3. Decide based on 1. The same AWS 2. The same AWS 3. Decide based on
Implementation: the stages run left to right — 1. The same AWS, 2. The same AWS, 3. Decide based on.

Pulumi expresses the VPC and bucket as Python objects; the engine diffs them against its own state.

# CLI Context: pulumi up
# Provider note: pulumi_aws is the native AWS provider; region comes from `aws:region` config.
import pulumi
import pulumi_aws as aws
from dataclasses import dataclass

@dataclass(frozen=True)
class NetConfig:
    cidr: str = "10.0.0.0/16"
    bucket_name: str = "example-iac-data"

def build(cfg: NetConfig) -> None:
    vpc = aws.ec2.Vpc(
        "main-vpc",
        cidr_block=cfg.cidr,
        enable_dns_hostnames=True,
        tags={"Name": "main-vpc"},
    )
    bucket = aws.s3.BucketV2("data", bucket=cfg.bucket_name)
    # State implication: resource IDs are recorded in the Pulumi checkpoint on `up`.
    pulumi.export("vpc_id", vpc.id)
    pulumi.export("bucket", bucket.bucket)

build(NetConfig())

2. The same AWS resources with CDKTF

CDKTF builds the same two resources as constructs, synthesizes Terraform JSON, and Terraform applies it.

# CLI Context: cdktf get && cdktf synth && cdktf deploy
# Provider note: AwsProvider configures the Terraform AWS provider; state is plain .tfstate.
from constructs import Construct
from cdktf import App, TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.vpc import Vpc
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket

class NetStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region="us-east-1")
        Vpc(self, "main-vpc", cidr_block="10.0.0.0/16",
            enable_dns_hostnames=True, tags={"Name": "main-vpc"})
        bucket = S3Bucket(self, "data", bucket="example-iac-data")
        # State implication: IDs land in Terraform state after `cdktf deploy`.
        TerraformOutput(self, "bucket", value=bucket.bucket)

app = App()
NetStack(app, "net")
app.synth()

3. Decide based on ecosystem fit

If your organization already standardizes on Terraform providers, modules, and policy tooling, CDKTF lets you keep all of it — the synthesis mechanics are detailed in CDKTF Workflows & Terraform Synthesis. If you want a single tool with a native engine and rich component model, Pulumi is the better fit — its patterns live in Pulumi Patterns & Provider Management.

Deferred Values: Output versus Token

Neither tool knows a VPC's ID while your Python is running, because the resource does not exist yet. Both therefore hand you a placeholder — and the two placeholders behave so differently that this is where most cross-tool confusion lives.

How each tool represents a value that does not exist yet How each tool represents a value that does not exist yet: comparison across Pulumi Output[T], CDKTF token. Aspect Pulumi Output[T] CDKTF token Python type Output object Plain str sentinel In an f-string Raises a clear error Works, stays a token In an if statement Rejected by typing Silently wrong Transform with apply or Output.concat String operations Carries ordering Yes, a dependency edge No, resolved at synth
The same problem, opposite ergonomics: loud at the call site versus quiet until synthesis.

Pulumi's Output[T] is a future with a dependency edge attached. Every transformation goes through .apply(), and the engine uses the resulting graph to order operations. Because it is a real object with real semantics, misuse is loud: interpolating one into an f-string produces Calling __str__ on an Output[T] is not supported., followed by advice to use .apply() or Output.concat.

CDKTF's token is the opposite design. vpc.id returns an ordinary Python str containing a sentinel like ${TfToken[TOKEN.9]}, which the synthesizer rewrites into a Terraform interpolation such as ${aws_vpc.main-vpc.id} when it emits the JSON. Because it is a plain string, concatenation and f-strings work, and nothing complains — right up until you branch on it:

# tokens.py — the CDKTF failure mode that has no error message
# CLI: cdktf synth
from cdktf_cdktf_provider_aws.vpc import Vpc

vpc = Vpc(self, "main-vpc", cidr_block="10.0.0.0/16")

name = f"flow-logs-{vpc.id}"          # fine: becomes an interpolation in cdk.tf.json
if vpc.id.startswith("vpc-"):          # WRONG: the token is not the id at synth time
    ...                                # this branch is decided by "${TfToken[TOKEN.9]}"
# State implication: the branch is baked into the JSON at synth time, so the emitted
# configuration reflects a comparison against a placeholder, not against reality.

The Pulumi equivalent of that mistake cannot compile past the type checker, because Output[str] has no startswith. The Pulumi mistake CDKTF cannot make is the mirror image: forgetting .apply() and passing an Output where a str is required, which fails at runtime with a type error from the provider. Whichever tool you pick, the rule is the same — a value produced by a resource is not available to your program's control flow, only to the configuration it emits.

The practical consequence for team onboarding is that Pulumi front-loads the learning: engineers hit the Output model in their first hour and are forced to understand it. CDKTF defers it: everything looks like a normal string until someone writes a conditional that silently produces the wrong infrastructure, which is a harder bug to find in review.

Verification

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# Pulumi: a dry run shows planned resources without applying.
pulumi preview --diff

# CDKTF: synthesize, then validate the generated Terraform.
cdktf synth
terraform -chdir=cdktf.out/stacks/net validate
# Both should report exactly one VPC and one bucket to create on first run.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks vpc.id watch this boundary Output.concat watch this boundary Output watch this boundary Edge Cases watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Pulumi Output[T] values are not plain strings. You cannot use vpc.id directly in an f-string; you must use .apply() or Output.concat. Treating an Output as a resolved value is the most common Pulumi-on-AWS mistake.

CDKTF needs cdktf get before the first synth. The typed AWS bindings are generated from the provider; skipping cdktf get yields import errors. Cache the generated .gen directory in CI to avoid regenerating on every run.

State formats are not interchangeable. You cannot copy a Pulumi checkpoint into a Terraform backend or vice versa. Switching tools means re-importing resources, not migrating state — see How to Migrate IaC State Between Backends for the within-ecosystem case.

Missing CDKTF bindings look like a Python packaging problem. Before cdktf get has run, or after a provider version bump without regenerating, the import fails with ModuleNotFoundError: No module named 'cdktf_cdktf_provider_aws'. Engineers reach for pip install and find a package that exists on PyPI, install it, and end up with bindings for a provider version that does not match cdktf.json. Decide once whether the project uses prebuilt provider packages or locally generated ones, and enforce it — mixing them produces type errors that read as jsii marshalling failures.

Pulumi runs your program on every operation. pulumi preview, pulumi up, pulumi refresh and pulumi destroy all execute the Python. Anything expensive or non-deterministic at module scope — a network call, a timestamp, a random value — runs four times over a normal workflow and can produce a diff that never converges. CDKTF has the inverse property: synthesis happens once and Terraform then works from the JSON, so a non-deterministic program shows up as a changed cdk.tf.json in review rather than as a phantom diff.

Conversion between the two is one-directional and partial. pulumi convert --from terraform --language python translates HCL into a Pulumi program, and cdktf convert translates HCL into CDKTF constructs. Neither converts between Pulumi and CDKTF, and neither moves state. A genuine migration is a re-import exercise against live resources, which is a project rather than an afternoon.

Operational Notes

Once either tool is in a pipeline, the operational differences narrow to three things: what has to be installed, what has to be pinned, and how long a run takes.

Installation is asymmetric. A Pulumi runner needs the CLI, Python and the provider plugins, which the CLI downloads into ~/.pulumi/plugins on first use — cache that directory in CI or every job pays a multi-megabyte download. A CDKTF runner needs Python, Node.js, the Terraform binary and the generated provider bindings, and cdktf get is slow enough that regenerating on every run is a real cost:

# CLI: the two cache directories worth persisting between CI jobs
pulumi plugin ls --json | python -c "import json,sys; print(len(json.load(sys.stdin)))"
du -sh ~/.pulumi/plugins .gen 2>/dev/null

Version pinning is where the ecosystems differ most in day-two practice. CDKTF pins the provider in cdktf.json and the Terraform binary separately, so a reproducible run needs both nailed down plus the generated bindings that match; the discipline is covered in pinning Terraform provider versions in CDKTF. Pulumi pins the provider through the Python package version in requirements.txt, because the plugin version is derived from the SDK package — one file, one number, and pip freeze already captures it.

Run time favours Pulumi for small stacks and evens out for large ones. Pulumi skips a separate synthesis step and parallelises resource operations across the dependency graph automatically; CDKTF pays jsii startup and a full terraform init/plan cycle, but Terraform's own parallelism and its mature refresh behaviour close the gap once there are hundreds of resources. Neither difference should decide the choice — a thirty-second gap in a pipeline matters far less than which ecosystem your team can debug at three in the morning.

The honest decision rule is about existing investment, not features. If there are Terraform modules, Sentinel or OPA policies, and an operations team fluent in terraform state subcommands, CDKTF keeps all of it and adds types. If the infrastructure work is new, the team writes more Python than HCL, and you want unit tests that run without synthesizing anything, Pulumi is the shorter path. Both are credible; running both in one repository is not, because every engineer then has to hold two state models and two deferred-value semantics in their head at once.

FAQ

Which has broader AWS provider coverage?

CDKTF, because it can use any Terraform provider, which collectively cover the most services. Pulumi covers AWS natively and can also bridge Terraform providers, so the practical gap on AWS is small.

Is Pulumi or CDKTF easier to test?

Both test well. Pulumi unit tests inject mocks via pulumi.runtime.set_mocks; CDKTF favors snapshot testing the synthesized JSON. Pick the style your team prefers — neither requires live AWS calls.

Can I use boto3 inside either tool?

Yes, for read-only lookups not covered by the provider. Be careful about side effects that bypass state. The pattern is the same in both engines.

Can I migrate an existing project from one to the other?

Not by moving state. pulumi convert --from terraform and cdktf convert both translate HCL into code, but neither translates between Pulumi and CDKTF and neither moves the recorded resources. A real switch means re-importing every live resource into the new tool, verifying an empty plan, and only then decommissioning the old state.

Which one needs Node.js installed?

CDKTF does, always — the constructs library is TypeScript and the Python bindings proxy into a Node process through jsii, so a runner needs Node, the Terraform binary and Python. Pulumi needs only its CLI and Python, with provider plugins downloaded into ~/.pulumi/plugins on first use.

Do they both support remote state with locking on AWS?

Yes. CDKTF uses S3+DynamoDB through Terraform; Pulumi can use an S3 backend or Pulumi Cloud. Both support locking and encryption.