We ran Pulumi in TypeScript and Terraform in HCL side by side across 60+ services. Each won different categories of work. Here's the breakdown.
We've been running both Pulumi (TypeScript) and Terraform (HCL) in production for 18 months across 60+ services on AWS. Different teams adopted different tools at different times, and we got to compare them under real conditions. Each won different categories of work. Here's the breakdown — the wins, the limits, and what we'd do differently.
A new engineer can read HCL and figure out what infrastructure exists. Pulumi requires understanding TypeScript, Pulumi's resource graph, and async/await semantics. We measured: median time-to-first-PR was 2 days for Terraform, 4 days for Pulumi.
terraform plan output is uniform. Every reviewer reads the same shape: resource type, name, attributes that change. Pulumi's diff output for complex inputs is harder to scan, especially when inputs are computed expressions.
# Terraform plan — easy to scan
~ resource "aws_security_group_rule" "ingress" {
~ description = "old" -> "new"
}
# Pulumi diff with computed input
~ aws:ec2/securityGroupRule:SecurityGroupRule
~ description: "[computed]" -> "[computed]"
Terraform providers, especially hashicorp/aws, are battle-tested. Pulumi's AWS Classic provider is largely a wrapper around the same backing code, but the abstraction layer above (Crosswalk, etc.) sometimes lags behind direct Terraform usage by weeks for new AWS services.
We had a corrupted state once on each side.
terraform import + terraform state rm/mv — verbose but tractable. Took 90 minutes.pulumi state delete/import works similarly, but the documentation is thinner. Took 4 hours.We can find Terraform experience easily. We've never had a candidate list Pulumi on a CV.
Anything beyond simple "create N copies" is painful in HCL. Pulumi handles it naturally:
// Pulumi
const subnets = config.requireObject<Subnet[]>("subnets");
for (const s of subnets) {
if (s.public && config.getBoolean("enablePublicAccess")) {
new aws.ec2.Subnet(s.name, { ... });
}
}
# Terraform — works but reads worse
resource "aws_subnet" "public" {
for_each = {
for s in var.subnets : s.name => s
if s.public && var.enable_public_access
}
# ...
}
For complex conditional infrastructure (cross-region failover, feature-flag-driven topologies), Pulumi was clearly easier to read and maintain.
We have a "platform component" that creates a service: ECS task, ALB rules, IAM role, log group, alarms. In Pulumi:
// Published as @ourorg/pulumi-service
export class StandardService extends pulumi.ComponentResource {
constructor(name: string, args: ServiceArgs, opts?: pulumi.ComponentResourceOptions) {
super("ourorg:platform:StandardService", name, {}, opts);
this.task = new aws.ecs.TaskDefinition(`${name}-task`, ..., { parent: this });
this.alarm = new aws.cloudwatch.MetricAlarm(`${name}-alarm`, ..., { parent: this });
// ...
}
}
Versioned, semver, published to our internal npm. Teams npm install it.
The Terraform equivalent (a Terraform module in a git repo with source = "git::...") works but has weaker semver story and worse IDE support.
// pulumi/test/service.spec.ts
import { runPreview } from "@pulumi/pulumi/tests";
import * as service from "../src/service";
test("service creates exactly one task definition", async () => {
const result = await runPreview(() => service.create("test", { ... }));
const taskDefs = result.resources.filter(r => r.type === "aws:ecs/taskDefinition:TaskDefinition");
expect(taskDefs).toHaveLength(1);
});
You can write unit tests for Pulumi code with regular test frameworks. Terraform has terraform test (newer, improving) but it's still less expressive than running Jest against TypeScript.
We caught two real bugs in pre-deploy unit tests: an alarm threshold off by 100×, and a tag missing on resources required by our cost allocation policy.
Sometimes you need to provision based on data from outside your config. In Pulumi:
const dnsClient = new awsSdk.Route53({ region: "us-east-1" });
const hostedZones = await dnsClient.listHostedZones().promise();
for (const zone of hostedZones.HostedZones) {
if (zone.Config?.PrivateZone) {
new aws.route53.Record(`record-${zone.Id}`, { ... });
}
}
Terraform requires data sources or external scripts. Awkward.
Renaming a resource in Terraform requires terraform state mv for every instance. Pulumi has pulumi rename for components.
After 18 months, we landed on this split:
| Use case | Tool |
|---|---|
| Network/VPC, base IAM, account scaffolding | Terraform |
| Cross-account/cross-region orchestration | Terraform |
| Service-shaped resources (ECS, RDS, ALB) | Pulumi |
| Internal "platform component" packages | Pulumi |
| One-off scripts and computed topology | Pulumi |
| Anything a third party will read (audit, security review) | Terraform |
The bias: base / static / "infrastructure" things → Terraform. Application-shaped / dynamic / "platform" things → Pulumi.
Pulumi's TypeScript code runs in two modes:
We had a bug where someone tried to read a file at preview time, but the file only existed inside an apply block. The error message was unhelpful. New engineers stumble on this regularly.
Terraform's evaluation order is implicit and sometimes surprising. We hit cases where a depends_on was needed but not obvious; a missing one caused intermittent apply failures (only when state hadn't been refreshed). Pulumi's explicit parent and dependsOn are more predictable.
Neither tool shines here. Drift caught at plan time is fine. Drift introduced manually then corrected by re-applying days later silently restores config but doesn't report what changed. We supplemented both with a separate drift-detection job that diffs state vs reality on a schedule and posts to Slack.
| Metric | Terraform | Pulumi |
|---|---|---|
| Engineers using daily | 22 | 9 |
| Lines of code | 38k | 12k |
| Average plan time | 28s | 47s |
| Provider version bumps causing breakage | 3 | 5 |
| Cross-team component reuse | low | high |
| New-engineer ramp | 2 days | 4 days |
apply from a laptop.You can absolutely use both. We do. The cost is split context — engineers need to know both tools eventually. The benefit is using each where it shines.
There's no universal winner. There's the tool that best fits the shape of the work in front of you. After 18 months, we use both, deliberately.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
We deleted every static GCP service account key in our org over six weeks. Here's the migration plan, the gotchas, and the policies we now enforce.
A two-line config change to an Argo Rollouts analysis template caught a regression that would have cost ~$40k in API spend before we noticed. Here's the pattern.
Explore more articles in this category
We launched Backstage in October. Six months in, 80% of services are catalogued, on-boarding takes a third of the time, and we mostly know what owns what.
How we shipped three schema migrations with zero customer impact. Expand-then-contract, dual-writes, and the rollback plan we never had to use — but tested anyway.
How we went from 200 alerts per week (most ignored) to 15 actionable alerts with clear runbooks and useful dashboards.