Pulumi vs AWS CDK for Python Teams: A Decision Guide

Both Pulumi and the AWS CDK let you write infrastructure in Python, but they make very different bets on state, cloud coverage, and execution. This decision guide — part of Python vs Terraform vs Ansible under Python IaC fundamentals and strategy — compares them for teams already committed to Python, and complements the Pulumi vs CDKTF comparison.

Problem Framing

The AWS CDK synthesizes CloudFormation and is AWS-only; Pulumi runs your program directly against provider plugins and spans hundreds of clouds and SaaS. If your world is entirely AWS and you value CloudFormation's drift detection and rollback, CDK is a natural fit. If you are multi-cloud, want real Python execution rather than a synth-to-template step, or need a resource CDK cannot express, Pulumi wins.

Framed more precisely, the question is who owns the deployment engine. With the CDK you are writing a program whose output is a document, and AWS executes that document; CloudFormation decides the order, performs the API calls, retries, and rolls back. With Pulumi you are writing a program whose execution is the deployment; the Pulumi engine builds a dependency graph from the resources your code registers and drives the provider plugins itself. Everything else in this comparison — where errors surface, what a preview can tell you, how you extend the tool, what happens when a deploy fails halfway — follows from that one structural difference.

Two things this decision is not about are worth naming, because they dominate most discussions and shouldn't. It is not about Python quality: both expose typed, documented, IDE-completable APIs, and a team fluent in one will read the other without difficulty. And it is not about which produces less code; for a bucket with versioning the line counts are within a couple of lines of each other. The durable differences are operational, and they show up in month six rather than week one.

A useful sharpening question: does anything you manage live outside AWS? Not "might we go multi-cloud one day", but concretely — a DNS zone at another registrar, a Datadog monitor, a GitHub repository, a Snowflake warehouse, a Kubernetes add-on. If the honest answer is yes, the CDK requires a second tool for those, and you inherit the cost of operating two state systems and two review workflows. If the answer is genuinely no, that entire category of cost disappears and CloudFormation's managed lifecycle becomes a real asset.

Pulumi vs AWS CDK Pulumi vs AWS CDK: comparison across Pulumi, AWS CDK. Aspect Pulumi AWS CDK Cloud scope multi-cloud + SaaS AWS only Engine direct execution synth to CloudFormation State Pulumi/S3 state CloudFormation stacks Rollback preview + up native CFN rollback
The core trade-off is breadth and direct execution versus deep AWS/CloudFormation integration.

How Each Model Executes

The CDK is a template generator: cdk deploy produces CloudFormation and hands it to AWS, which owns the actual create/update/delete. Pulumi keeps ownership of the lifecycle in its own engine and state, calling provider plugins as it goes. That difference explains most of the day-to-day distinctions — error messages, preview fidelity, and how custom resources work.

Deploy paths Deploy paths: Your code → Tool → AWS. Your code Tool AWS run Pulumi: API calls CDK: CFN template result
Pulumi calls cloud APIs itself; the CDK submits a template AWS executes.
# The same intent in each tool — an S3 bucket with versioning
# Pulumi (runs directly against the AWS provider)
# CLI: pulumi up --stack prod
import pulumi
import pulumi_aws as aws

bucket: aws.s3.BucketV2 = aws.s3.BucketV2("data")

aws.s3.BucketVersioningV2(
    "data-ver",
    bucket=bucket.id,
    versioning_configuration={"status": "Enabled"},
)
# State implication: two resources, two entries in the Pulumi checkpoint. The
# bucket.id reference is an Output, so the engine orders them without a
# declared dependency.

pulumi.export("bucket_name", bucket.bucket)
# app.py — the CDK equivalent, which produces a CloudFormation template
# CLI: cdk deploy DataStack
from aws_cdk import App, Stack, RemovalPolicy, aws_s3 as s3
from constructs import Construct


class DataStack(Stack):
    def __init__(self, scope: Construct, construct_id: str) -> None:
        super().__init__(scope, construct_id)
        # Provider note: `versioned=True` is an L2 convenience — the synthesized
        # template contains an AWS::S3::Bucket with a VersioningConfiguration
        # block, not a second resource.
        self.bucket = s3.Bucket(
            self, "Data",
            versioned=True,
            removal_policy=RemovalPolicy.RETAIN,
        )


app = App()
DataStack(app, "DataStack")
app.synth()

The two snippets differ in a way that matters beyond taste. Pulumi's provider mirrors the AWS API surface, so versioning is a separate resource because PutBucketVersioning is a separate call. The CDK's L2 constructs are a curated abstraction over CloudFormation's resource types, so versioning is a keyword argument that expands into a nested block. The abstraction is genuinely nicer when it covers what you need — bucket.grant_read(role) writes a correct IAM policy for you — and genuinely obstructive when it does not, at which point you drop to the L1 CfnBucket and lose it entirely.

The runtime difference shows up in how unresolved values behave. Both tools represent "not known until deploy" with a placeholder — Pulumi's Output[T], the CDK's Token — and both refuse to be treated as plain strings. But a CDK token resolves into a Fn::GetAtt or Ref inside a template that AWS evaluates later, whereas a Pulumi Output resolves in your own process as the engine receives real API responses. That is why Pulumi lets you run arbitrary Python on a resolved value inside .apply() while the CDK must express the same manipulation with CloudFormation intrinsic functions like Fn.select and Fn.split.

Failure handling diverges just as sharply. CloudFormation is transactional per stack: if the eleventh resource fails, the ten before it are rolled back automatically and the stack returns to its previous state. That is a real operational benefit, with a real edge — a stack whose first deploy fails lands in ROLLBACK_COMPLETE and cannot be updated at all; it must be deleted before you can retry, which is startling the first time it happens in a pipeline. Pulumi does not roll back. A failed update leaves successfully created resources in state and reports what failed, and you fix forward by re-running pulumi up. Neither behaviour is strictly better: rollback protects you from partial states, fix-forward protects you from losing twenty minutes of successful work because of one typo.

The template model also brings hard ceilings that a directly executing engine does not have.

CloudFormation ceilings a CDK stack inherits CloudFormation ceilings a CDK stack inherits: Resources per stack, Parameters, Outputs, Mappings, Template size (KB, S3). Resources per stack 500 Parameters 200 Outputs 200 Mappings 200 Template size (KB, S3) 1000 KB
A CDK stack is bounded by the template format; a Pulumi stack has no equivalent ceiling.

Those numbers are not theoretical for a large environment. A monolithic CDK stack that grows past the resource ceiling fails at deploy time with The following resource(s) failed to create preceded by a limit error, and the fix is architectural — split into nested stacks or multiple stacks with cross-stack references. Pulumi has no equivalent per-stack ceiling; the practical limit is how long you are willing to wait for a preview and how much blast radius you want in one update.

Testing and Extensibility

The two areas where teams most often discover they picked wrong are testing and the moment a resource has no first-class support.

Extending past first-class support Extending past first-class support: comparison across AWS CDK, Pulumi. Concern AWS CDK Pulumi Test artefact synthesized template mocked registrations Unsupported resource Lambda custom resource dynamic provider class Runs where in AWS, per event in the deploy process Operational cost function, role, logs none deployed
The escape hatch you reach for when coverage runs out is deployed in one tool and local in the other.

Both tools can assert on infrastructure without deploying it, but they assert on different artefacts. The CDK gives you the synthesized template and a matcher library over it; Pulumi gives you the resource registrations and a mock layer that intercepts them.

# tests/test_stack.py — CDK asserts against the synthesized template
# CLI: pytest tests/test_stack.py -q
from aws_cdk import App
from aws_cdk.assertions import Template
from app import DataStack


def test_bucket_is_versioned() -> None:
    template = Template.from_stack(DataStack(App(), "test"))
    # Provider note: this reads the generated CloudFormation, so it validates
    # what AWS will be asked to do — not what AWS will actually do.
    template.has_resource_properties(
        "AWS::S3::Bucket",
        {"VersioningConfiguration": {"Status": "Enabled"}},
    )
# tests/test_program.py — Pulumi asserts against mocked resource registrations
# CLI: pytest tests/test_program.py -q
from typing import Any
import pulumi


class Mocks(pulumi.runtime.Mocks):
    def new_resource(self, args: pulumi.runtime.MockResourceArgs) -> tuple[str, dict]:
        # State implication: nothing is created and no state file is written;
        # the engine runs entirely against these fabricated outputs.
        return f"{args.name}_id", dict(args.inputs)

    def call(self, args: pulumi.runtime.MockCallArgs) -> dict[str, Any]:
        return {}


pulumi.runtime.set_mocks(Mocks())

import infra  # noqa: E402  — import after mocks are installed


@pulumi.runtime.test
def test_versioning_enabled():
    def check(status: str) -> None:
        assert status == "Enabled"
    return infra.versioning.versioning_configuration["status"].apply(check)

The CDK's approach is more direct — a template is a document you can match against — and it inherits CloudFormation's coverage: if the template is valid, the resource types exist. Pulumi's is more flexible, because you are mocking a function call and can therefore test conditional logic, loops and helper functions the way you would test any Python. The fuller treatment is in unit testing Pulumi programs with mocks.

Extensibility is the sharper divide. When AWS ships a service before CloudFormation supports it, or when the thing you need to manage is not an AWS resource at all, the CDK's answer is a custom resource: a Lambda function that CloudFormation invokes with Create/Update/Delete events and that must signal back to a pre-signed URL. It works, and it means every custom resource is a deployed function with a log group, an execution role and a cold-start latency. Pulumi's answer is a dynamic provider — a Python class with create, update, delete and diff methods that runs in the deploy process, with nothing deployed and nothing to operate. For a team that regularly manages things outside the provider's coverage, that difference compounds; see dynamic providers and custom resources for what the Python side looks like.

Choosing for Your Team

Pick based on scope and operational preferences rather than syntax — both are pleasant Python. Choose the CDK when you are AWS-only and want CloudFormation's managed rollback and org-wide guardrails. Choose Pulumi when you need multiple providers, want to unit test the program against mocks, or must model a resource via a dynamic provider.

Which tool? Which tool?: choose among 3 options. What is your scope? AWS-only AWS CDK multi-cloud Pulumi custom API Pulumi dynamic
Scope and the need for custom resources usually decide it.

Migration Considerations

Moving between them is a re-import, not a text transform, because state formats differ entirely. Pulumi can pulumi import existing AWS resources into its state; the CDK can adopt resources via CloudFormation import. Plan a resource-by-resource cutover behind unchanged names, and validate with a no-op preview on both sides before deleting anything, echoing the approach in migrating IaC state between backends.

Migration Considerations Migration Considerations: Migration then Pulumi then AWS Migration Pulumi AWS
Migration Considerations: the stages run left to right — Migration, Pulumi, AWS.

The mechanics are asymmetric. Importing into Pulumi is a per-resource operation that also generates code: pulumi import aws:s3/bucketV2:BucketV2 data my-bucket-name writes the resource into state and prints a typed Python definition you paste into the program. Get the printed code wrong and the very next preview plans a change, which is exactly the feedback you want — the import is verified by a preview that must come back empty.

Going the other way, CloudFormation import requires a template that already describes the resources, an import change set, and the resources to carry a DeletionPolicy: Retain. It is a bulk operation rather than a per-resource one, which makes it faster when it works and harder to debug when it does not.

The sequencing that keeps this safe is the same in both directions. First set the retention policy on everything so that removing a resource from the old tool cannot delete it — RemovalPolicy.RETAIN in the CDK, pulumi.ResourceOptions(retain_on_delete=True) in Pulumi. Then import into the new tool and prove an empty diff. Only then remove the resource from the old tool's management, and confirm nothing was destroyed. Statefully-important resources — databases, buckets with data, anything with a DNS record pointed at it — should each get their own rehearsal in a non-production environment first.

Budget for the parts that do not transfer at all. CDK L2 constructs generate supporting resources you never named — bucket policies, log groups, IAM roles with auto-generated names — and each of those is a separate import into Pulumi. Stack outputs consumed by other stacks have to be re-established as Pulumi exports before the consumers can be cut over. And a mid-migration environment is running two tools against one account, so agree up front which one is authoritative for each resource and write it down; the ambiguity is what causes an accidental deletion, not the tooling.

Operational Notes

Once the choice is made, the daily texture differs more than the comparison table suggests, and it is worth knowing what you are signing up for.

Day-two ownership Day-two ownership: layered from Bootstrap down to Recovery. Bootstrap CDK needs a toolkit stack per account and region State managed by CloudFormation, or a file you own Drift detect-stack-drift, or pulumi refresh Policy SCPs and synth-time rules, or CrossGuard at preview Recovery rollback states, or fix forward and re-run
What each tool asks of you after the first successful deploy.

Credentials and CI are the easiest part and behave almost identically: both tools authenticate with the standard AWS credential chain, both work with OIDC federation from a pipeline, and both want one role per environment. The CDK adds one prerequisite Pulumi does not have — bootstrapping. Every account and region pair needs a bootstrap stack holding the asset bucket and deployment roles, and a first deploy into an unbootstrapped region fails with This stack uses assets, so the toolkit stack must be deployed to the environment (Run "cdk bootstrap aws://<account>/<region>"). It is a one-line fix that reliably costs half an hour the first time it appears in a pipeline.

State is where the operational load differs. CloudFormation state is a managed AWS service: nothing to back up, nothing to lock, drift detection available through aws cloudformation detect-stack-drift, and console visibility for anyone with read access. Pulumi state is a file you are responsible for, whether on Pulumi Cloud or in an S3 bucket you run — which buys you portability and full history at the cost of owning backups, locking and encryption keys. Neither is free; the CDK's cost is that you cannot inspect or repair state outside what CloudFormation exposes, and a stack stuck in UPDATE_ROLLBACK_FAILED is a support-ticket-shaped problem rather than something you can edit.

Policy enforcement is available on both sides but sits in different places. Pulumi CrossGuard runs Python policy packs during preview, so a violation blocks the update before any API call — the approach described in Pulumi policy as code. The CDK's equivalent runs at synth time over the template, and it composes with the AWS-native controls you already have: service control policies, permission boundaries and CloudFormation guard rules apply regardless of what generated the template. For an organisation already invested in AWS-native governance, that is a genuine advantage; for one that wants policy expressed in the same language as the infrastructure, CrossGuard is the closer fit.

FAQ

Is CDKTF the same as the AWS CDK?

No. CDKTF uses the CDK programming model but synthesizes Terraform JSON and is multi-cloud, whereas the AWS CDK synthesizes CloudFormation and is AWS-only.

Can I use both in one organisation?

Yes, but standardise per team or per domain — mixing them in a single deployment path multiplies the state systems you must operate.

Which has better Python typing?

Both ship typed APIs; Pulumi's are generated from provider schemas and the CDK's from the AWS Construct Library. Typing quality is comparable for common resources.

Does Pulumi lose CloudFormation's automatic rollback?

Yes, and deliberately. A failed Pulumi update keeps the resources it already created and reports the failure, so you fix the cause and re-run. If transactional rollback is a hard requirement — because a partial state is genuinely unsafe for your workload — that is one of the few arguments that decides this comparison on its own.

Can Pulumi deploy a CloudFormation template or a CDK stack?

It can deploy raw CloudFormation through the aws.cloudformation.Stack resource, which is a reasonable bridge for a template you do not want to rewrite yet. It does not execute CDK constructs, so a CDK application has to be synthesized to a template first, and at that point you have given up the CDK's Python model anyway.

Which handles drift better?

CloudFormation has native drift detection you can run on a stack and read in the console, which the CDK inherits for free. Pulumi detects drift with pulumi refresh, which reconciles state against the live cloud and shows the differences before you decide what to do. The CDK's is easier to reach for; Pulumi's is easier to automate and act on.