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.
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.
# 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.
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.
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.
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.
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.
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.
Related
- Pulumi vs CDKTF for AWS: A Side-by-Side Comparison — the sibling comparison for teams weighing Terraform-based tools.
- Python vs Terraform vs Ansible — the parent topic framing the tool landscape.
- Pulumi Patterns & Provider Management — deeper Pulumi patterns once you have chosen it.