Terraform's declarative HCL and Pulumi's real programming languages solve the same problem in very different ways, and the right pick depends on your team.
Both tools provision the same clouds and both keep a state file. The argument between them is really about one thing: do you want a purpose-built configuration language, or your existing programming language pointed at infrastructure? That choice ripples into how you write loops, how you test, and who on your team can safely make changes.
Terraform uses HCL, a declarative DSL built only for describing infrastructure. You state the desired end state and the engine figures out the plan. HCL has no classes, no real functions you author, and a deliberately small surface area. That constraint is a feature. A junior engineer can read a .tf file and mostly understand it without knowing a programming language.
Pulumi flips this. You write TypeScript, Python, Go, or C#, and the infrastructure is the result of running your program. It reads imperatively but Pulumi still builds a dependency graph and reconciles against state underneath, so the mental model is closer to Terraform than it first appears. The difference is that you now have the entire language available: real functions, packages from npm or PyPI, IDE autocomplete, and type checking.
This is where the contrast gets concrete. Say you want a bucket, or several buckets.
In Terraform with HCL:
variable "bucket_names" {
type = list(string)
default = ["logs", "artifacts", "backups"]
}
resource "aws_s3_bucket" "this" {
for_each = toset(var.bucket_names)
bucket = "acme-${each.value}-prod"
tags = {
Team = "platform"
}
}
for_each and count cover most iteration needs, and modules give you reuse. But anything conditional gets awkward. You end up with ternaries inside string interpolation and count = var.enabled ? 1 : 0 tricks that read like puzzles once logic gets nested.
The same thing in Pulumi with TypeScript:
import * as aws from "@pulumi/aws";
const names = ["logs", "artifacts", "backups"];
const buckets = names.map((name) =>
new aws.s3.Bucket(`acme-${name}`, {
bucket: `acme-${name}-prod`,
tags: { Team: "platform" },
})
);
A map is just a map. Need a conditional? Use an if. Need to derive names from an API call at build time? Import a library and call it. This is genuinely more powerful for complex, dynamic infrastructure, and genuinely easier to over-engineer. The freedom that lets you write a clean helper also lets you write a tangle of abstraction that nobody else can follow.
Testing is Pulumi's strongest structural argument. Because your infrastructure is a normal program, you can write real unit tests with Jest, pytest, or Go's testing package, mock the cloud provider, and assert on resource properties before anything is deployed. You can check that every bucket has encryption enabled or that no security group opens port 22 to the world, all in a fast in-memory test.
Terraform's testing story has improved with the native terraform test framework and tools like Terratest, but most of it still runs against real or planned infrastructure rather than pure unit assertions. Policy-as-code via Sentinel or OPA covers the guardrail case well. If a strong automated testing culture matters to you, Pulumi fits the habits your developers already have.
Both tools track reality in a state file, and both need that state stored somewhere safe and locked during changes. Terraform points at an S3 backend with DynamoDB locking, or at HCP Terraform (formerly Terraform Cloud). Since the license change, many teams run OpenTofu, the open-source fork, with the same backends and no vendor tie.
Pulumi defaults to Pulumi Cloud for state and locking, which is free for individuals, but you can also self-manage state in S3, Azure Blob, or GCS. The operational concerns are identical in both worlds: never edit state by hand, always lock, and back it up.
Terraform's registry is the larger ecosystem by a wide margin, with thousands of providers and a huge library of community modules for nearly anything you can name. That maturity matters when you hit an obscure SaaS API and someone has already built the provider.
Pulumi bridges most Terraform providers automatically, so coverage is close in practice, but the native, hand-tuned providers and the community module depth are smaller. For mainstream clouds you will not notice a gap. For long-tail integrations, Terraform still has the edge.
HCL is quick to pick up and hard to misuse. A platform team can hand a .tf file to a data engineer or an SRE and expect a correct change. The ceiling is lower, but so is the floor.
Pulumi rewards teams that already live in a language. A TypeScript shop gets autocomplete, refactoring tools, and shared libraries for free, and the barrier between application code and infrastructure code drops. The risk is that infrastructure inherits your codebase's habits, good and bad, and that a non-programmer on the team is now locked out of the config.
Both encrypt secrets in state rather than leaving them in plaintext. Terraform marks values sensitive to keep them out of logs and pairs with Vault or cloud secret managers for the actual storage. Pulumi has first-class secret encryption built in: mark a config value as secret and it is encrypted in state and in the config file automatically, using a per-stack key. Pulumi's default experience here is a bit smoother, though a disciplined Terraform setup with Vault reaches the same place.
Pick Terraform or OpenTofu when: your team spans many roles and skill levels, you value a readable and constrained config, you need the widest provider coverage, or you want to avoid depending on a single language ecosystem. It is the safe default for most organizations.
Pick Pulumi when: your infrastructure is genuinely dynamic, you have a strong developer culture that wants real tests and abstractions, your team already writes TypeScript, Python, Go, or C# daily, or you are building reusable platform components that benefit from real language features.
Watch out for: choosing Pulumi mainly to avoid learning HCL. The language is the easy part. The hard part is understanding the cloud, and both tools demand that equally.
For the wider landscape, see our roundup of the best Infrastructure-as-Code tools. If you are already committed to real code, the Pulumi vs AWS CDK comparison is worth a read too.
For most teams, most of the time, we would start with Terraform or OpenTofu. The constraints of HCL are a form of protection, the ecosystem is unmatched, and the readability keeps infrastructure open to your whole team rather than just the programmers. It is the choice you rarely regret.
We reach for Pulumi when the infrastructure itself is complex enough that real loops, conditionals, and unit tests pay for themselves, and when the team is clearly a software team first. In that setting Pulumi is not just viable, it is the better tool. The mistake is treating this as a religious war. Both are excellent, both track state, both provision the same clouds. Match the tool to your team, not to the internet's opinion.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
The metrics stack you self-host is free software plus a real ops bill. Datadog hands you everything and mails you the invoice. Here's how we pick.
Static keys leak and live forever. Short-lived credentials from STS and Vault expire on their own — here's the token-exchange machinery and the TTL math that make it work.
Explore more articles in this category
The IaC landscape fractured after the Terraform license change. This is the map to what each tool is actually best at, and how to choose without regret.
Terragrunt keeps large Terraform setups DRY and orchestrated, but small teams often pay its learning curve for little return.
A practical comparison of Kubernetes-native continuous reconciliation against the classic CLI-driven, state-file IaC model for platform teams.
Evergreen posts worth revisiting.