Pulumi and Terraform are both multi-cloud IaC tools — but Pulumi uses real programming languages while Terraform uses HCL. This matters more than most comparisons admit.
Quick Comparison
| Feature | Pulumi | Terraform |
|---|---|---|
| Language | TypeScript, Python, Go, C#, Java, YAML | HCL (declarative) |
| Multi-cloud | ✅ AWS, Azure, GCP, 150+ providers | ✅ AWS, Azure, GCP, 3000+ providers |
| State | Pulumi Cloud (default) or self-managed | Self-managed (S3 + DynamoDB) |
| License | Apache 2.0 (open source) | BSL 1.1 (not open source) |
| Testing | Native (Jest, pytest, Go test) | terraform test, Terratest |
| IDE support | Full (language-native) | Good (HCL plugins) |
| Secrets | Built-in encryption | External (Vault, ASM) |
| Pricing | Free tier + paid (Pulumi Cloud) | Free + paid (HCP Terraform) |
| Import | pulumi import | Import blocks |
| Maturity | Founded 2017 | Founded 2014 |
| Community | Growing | Largest IaC community |
| Provider count | 150+ | 3000+ |
Language: The Core Difference
Pulumi (TypeScript)
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// Real programming: loops, conditions, functions
const azs = ["us-east-1a", "us-east-1b", "us-east-1c"];
const vpc = new aws.ec2.Vpc("main", {
cidrBlock: "10.0.0.0/16",
enableDnsHostnames: true,
});
// Use a real for loop
const privateSubnets = azs.map((az, i) =>
new aws.ec2.Subnet(`private-${az}`, {
vpcId: vpc.id,
cidrBlock: `10.0.${i}.0/24`,
availabilityZone: az,
})
);
// Conditional logic with real if/else
const db = new aws.rds.Instance("main", {
engine: "postgres",
instanceClass: pulumi.getStack() === "prod"
? "db.r6g.xlarge"
: "db.t3.micro",
vpcSecurityGroupIds: [dbSg.id],
dbSubnetGroupName: subnetGroup.name,
});
// Reusable function
function tagAll(name: string): Record<string, string> {
return {
Name: name,
Environment: pulumi.getStack(),
ManagedBy: "pulumi",
};
}
Terraform (HCL)
variable "azs" {
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
}
resource "aws_subnet" "private" {
for_each = { for i, az in var.azs : az => i }
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, each.value)
availability_zone = each.key
}
resource "aws_db_instance" "main" {
engine = "postgres"
instance_class = terraform.workspace == "prod" ? "db.r6g.xlarge" : "db.t3.micro"
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
}
Both work. Pulumi feels natural to developers. HCL feels natural to ops teams.
When Programming Languages Help
Complex Logic
// Pulumi: Deploy different configs per region
const regions = ["us-east-1", "eu-west-1", "ap-southeast-1"];
for (const region of regions) {
const provider = new aws.Provider(`provider-${region}`, { region });
const cluster = new aws.ecs.Cluster(`app-${region}`, {}, { provider });
// Only deploy GPU instances in us-east-1
if (region === "us-east-1") {
new aws.ec2.Instance(`gpu-${region}`, {
instanceType: "p4d.24xlarge",
ami: gpuAmi,
}, { provider });
}
}
In Terraform, this requires nested for_each with conditional expressions — possible but less readable.
Dynamic Resource Generation
// Generate resources from a JSON config file
import * as fs from "fs";
interface ServiceConfig {
name: string;
port: number;
replicas: number;
cpu: number;
memory: number;
}
const services: ServiceConfig[] = JSON.parse(
fs.readFileSync("services.json", "utf-8")
);
for (const svc of services) {
new aws.ecs.Service(`svc-${svc.name}`, {
name: svc.name,
desiredCount: svc.replicas,
// ... use svc.port, svc.cpu, svc.memory
});
}
When HCL Wins
Readability for Non-Developers
# HCL: anyone can read this
resource "aws_s3_bucket" "data" {
bucket = "company-data"
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
HCL is constrained by design — you can’t write arbitrary logic, so the code stays predictable. Operations teams and security reviewers prefer this.
Plan Output
Both show execution plans, but Terraform’s plan output is the gold standard:
# terraform plan
+ aws_s3_bucket.data
bucket = "company-data"
~ aws_instance.web
instance_type: "t3.micro" → "t3.small"
- aws_security_group.old
Pulumi’s preview is similar but less widely understood.
State Management
Pulumi Cloud (Default)
pulumi login # Uses Pulumi Cloud by default
pulumi up # State stored in Pulumi Cloud
Free tier: 1 user, unlimited resources. Paid: team features.
Pulumi Self-Managed
pulumi login s3://my-pulumi-state
pulumi login file://~/.pulumi-state
Terraform
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
}
}
Always self-managed (unless using HCP Terraform).
Built-In Secrets
Pulumi encrypts secrets in state by default:
const config = new pulumi.Config();
const dbPassword = config.requireSecret("dbPassword");
// Encrypted in state, shown as [secret] in previews
new aws.rds.Instance("db", {
password: dbPassword, // Decrypted only during deployment
});
pulumi config set --secret dbPassword "MyS3cur3P@ss"
# Stored encrypted in Pulumi.prod.yaml
Terraform stores secrets in plaintext state (use backend encryption or ephemeral resources).
Testing
Pulumi: Native Unit Tests
// __tests__/infra.test.ts
import * as pulumi from "@pulumi/pulumi";
import { AppStack } from "../index";
pulumi.runtime.setMocks({
newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
call: (args) => args.inputs,
});
test("S3 bucket has versioning", async () => {
const stack = new AppStack("test");
const versioning = await new Promise((resolve) =>
stack.bucket.versioning.apply(resolve)
);
expect(versioning).toBeTruthy();
});
Terraform: terraform test
run "bucket_versioning" {
command = plan
assert {
condition = aws_s3_bucket_versioning.data.versioning_configuration[0].status == "Enabled"
error_message = "Versioning must be enabled"
}
}
Provider Ecosystem
| Metric | Pulumi | Terraform |
|---|---|---|
| Total providers | ~150 | ~3,000 |
| AWS provider | ✅ Full | ✅ Full |
| Azure provider | ✅ Full | ✅ Full |
| GCP provider | ✅ Full | ✅ Full |
| Niche providers | Limited | Extensive |
| Community modules | Construct Hub | Terraform Registry |
Pulumi auto-generates providers from Terraform providers (via Pulumi Bridge), so AWS/Azure/GCP coverage is equivalent. But niche providers may only exist in Terraform.
When to Choose Pulumi
- Your team writes TypeScript/Python/Go daily and doesn’t want another language
- You need complex logic (conditional resources, dynamic generation from configs)
- You want built-in secret encryption in state
- Unit testing with existing test frameworks matters
- You prefer open-source licensing (Apache 2.0)
- You’re building a platform with programmatic infrastructure generation
When to Choose Terraform
- You want the largest community and ecosystem (3000+ providers, thousands of modules)
- Your team includes ops engineers who prefer declarative config over code
- You need maximum transparency — HCL constraints prevent complex logic bugs
- Hiring — more engineers know Terraform than Pulumi
- You want to stay with the industry standard
- You need niche providers (many only exist in Terraform)
Hands-On Courses
- Terraform for Beginners on CopyPasteLearn
- Terraform By Example — practical code examples
Conclusion
Pulumi is the better choice for developer-heavy teams who want TypeScript/Python infrastructure with unit tests and built-in secrets. Terraform is the better choice for ops-oriented teams, mixed-skill organizations, and projects that need the largest provider/module ecosystem. Both are multi-cloud and production-ready. The decision usually comes down to team skills: developers lean Pulumi, operations teams lean Terraform.