AWS CDK vs Terraform: A Practical Comparison in 2026
I use both. Terraform for multi-cloud, CDK when it’s pure AWS and the team knows TypeScript. That’s the short answer. But the long answer has a lot more nuance, and I’ve earned that nuance the hard way — including one migration that nearly broke a team’s shipping cadence for two months.
This isn’t a “which one is better” post. I don’t think that question makes sense without context. What I can tell you is where each tool shines, where each one will bite you, and how to pick the right one for your situation in 2026. I’ve shipped production infrastructure with both, maintained both in anger, and migrated between them. Here’s what I’ve learned.
The Fundamental Difference
CDK and Terraform solve the same problem — defining cloud infrastructure in code — but they come at it from completely different angles.
Terraform uses HCL, a declarative domain-specific language. You describe what you want, and Terraform figures out how to get there. CDK uses general-purpose programming languages (TypeScript, Python, Java, Go, C#) and synthesizes down to CloudFormation templates. You write imperative code that produces declarative output.
That distinction matters more than people think. With Terraform, what you see is what you get. The HCL file is the infrastructure definition. With CDK, there’s a compilation step — your TypeScript becomes CloudFormation JSON, and CloudFormation is what actually talks to AWS. That extra layer of abstraction is both CDK’s greatest strength and its most frustrating weakness.
Here’s the same Lambda function in both tools.
Terraform (HCL):
resource "aws_lambda_function" "api" {
function_name = "api-handler"
runtime = "nodejs20.x"
handler = "index.handler"
filename = "lambda.zip"
role = aws_iam_role.lambda.arn
timeout = 30
memory_size = 256
}
resource "aws_iam_role" "lambda" {
name = "api-handler-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
CDK (TypeScript):
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
const api = new NodejsFunction(this, 'ApiHandler', {
runtime: Runtime.NODEJS_20_X,
entry: 'src/index.ts',
timeout: Duration.seconds(30),
memorySize: 256,
});
Look at the difference. CDK’s NodejsFunction construct handles the IAM role, the assume role policy, the basic execution policy attachment, and even the bundling of your TypeScript source code. Three resources in Terraform, one line in CDK. That’s the power of L2 and L3 constructs — they encode AWS best practices and handle the boilerplate.
But here’s the thing: when that Lambda needs a weird IAM permission or a non-standard configuration, you’re digging through CDK construct source code on GitHub trying to figure out which property maps to which CloudFormation attribute. With Terraform, the mapping to the AWS API is direct and obvious. There’s less magic, which means less confusion when things go sideways.
Developer Experience
CDK’s developer experience is genuinely excellent if your team lives in TypeScript or Python. Autocomplete, type checking, inline documentation — your IDE becomes an infrastructure encyclopedia. You catch typos and invalid configurations at compile time, not at deploy time. That feedback loop is tight and it’s addictive.
I remember the first time I used CDK after years of Terraform. I typed bucket. and my IDE showed me every method available — grantRead, grantWrite, addEventNotification. I didn’t have to look up a single thing. For a team of application developers who already think in TypeScript, CDK feels like home.
Terraform’s developer experience has improved a lot, though. The VS Code extension is solid now, HCL has decent autocomplete, and terraform validate catches most structural issues. But it’s never going to match a real programming language with a real type system. HCL is purpose-built and that’s both its strength (simplicity, readability) and its limitation (no loops that feel natural, no real abstractions beyond modules).
Where CDK stumbles is the bootstrap requirement and the CloudFormation dependency. Every account and region needs bootstrapping before you can deploy anything. That’s an extra step that trips up new teams. And when a deploy fails, you’re debugging CloudFormation stack events, not your TypeScript code. The error messages come from CloudFormation, and they’re often cryptic. “Resource handler returned message: null” — thanks, very helpful.
Terraform’s error messages aren’t poetry either, but at least they point directly at the resource and attribute that failed. The plan-apply workflow is also more transparent. terraform plan shows you exactly what’s going to change, resource by resource. CDK has cdk diff, which is decent, but it’s showing you CloudFormation changeset differences, not your source code differences. There’s a translation layer you have to mentally parse. I covered the full list of CDK commands in a separate post if you want the reference.
Multi-Cloud: Where Terraform Wins Outright
This one isn’t even close. If you need to manage infrastructure across AWS, Azure, and GCP — or even AWS plus a handful of SaaS providers — Terraform is the answer. CDK is AWS-only. Full stop. There’s CDKTF (CDK for Terraform), which lets you write CDK-style code that targets Terraform providers, but it adds another abstraction layer on top of an already complex stack. I’ve tried it. It’s clever, but it’s not mature enough for production multi-cloud work in my experience.
Terraform’s provider ecosystem is massive. AWS, Azure, GCP, Cloudflare, Datadog, PagerDuty, GitHub — you name it, there’s probably a provider. And they all work the same way: resources, data sources, plan, apply. Once you know Terraform’s workflow, picking up a new provider takes an afternoon, not a week.
This is actually what drove the most painful migration I’ve been through.
The Migration That Humbled Me
We had a platform running entirely on AWS. CDK in TypeScript, about 40 stacks, well-structured. The team loved it. Then the business decided we needed to run a subset of services on Azure for a key client’s compliance requirements. Not a full multi-cloud strategy — just “these three services also need to exist in Azure.”
My first instinct was CDKTF. Write CDK, target Terraform providers, keep the team in TypeScript. We spiked it for two weeks. The Azure provider support through CDKTF was workable but rough. Construct libraries were thin. Debugging meant bouncing between CDK synth output, generated HCL, and Terraform state. Three layers of abstraction between our code and the actual infrastructure. When something broke, figuring out where it broke was miserable.
So we made the call: migrate the affected services to pure Terraform. Not the whole platform — just the pieces that needed to be multi-cloud. This meant rewriting about 12 CDK stacks into Terraform modules.
The rewrite itself wasn’t the hard part. Translating CDK constructs to Terraform resources is mechanical work. The hard part was the state. CDK stacks have their state in CloudFormation. Terraform has its own state files. You can’t just point Terraform at existing CloudFormation-managed resources and say “take over.” You have to import every single resource into Terraform state, one by one, while making sure CloudFormation doesn’t try to delete them when you remove the CDK stacks.
We did it in stages. First, we wrote the Terraform configs to match the existing infrastructure exactly. Then we imported resources into Terraform state using terraform import. Then we ran terraform plan obsessively until it showed zero changes — meaning Terraform’s view of the world matched reality. Only then did we carefully remove the CDK stacks with retain policies on the resources so CloudFormation wouldn’t nuke them on the way out.
It took six weeks. During that time, both teams were nervous about touching anything in those services. Deployments slowed to a crawl. We had a spreadsheet tracking which resources were “CDK-managed,” “Terraform-managed,” and “in transition.” It was not fun.
The lesson: pick your IaC tool with an eye toward where you might be in two years, not just where you are today. If there’s even a whisper of multi-cloud on the roadmap, start with Terraform. Migrating later is expensive. I’ve written about IaC best practices more broadly, and tool selection is the single most impactful decision in that list.
State Management
State is where these tools diverge sharply in philosophy.
Terraform manages its own state. You’re responsible for storing it, locking it, backing it up, and not corrupting it. That’s a real operational burden, but it also gives you complete control. I use S3 with DynamoDB locking for every project — I covered the full setup in my Terraform state management guide. The state file is yours. You can inspect it, manipulate it, split it, merge it. When things go wrong, you have tools: terraform state mv, terraform state rm, terraform import.
CDK delegates state to CloudFormation. You don’t manage state files. CloudFormation tracks what it deployed, and CDK talks to CloudFormation. This is simpler in the common case — you never worry about state locking or remote backends. But when things go wrong, you have less control. A CloudFormation stack stuck in UPDATE_ROLLBACK_FAILED? That’s a support ticket or a very careful manual intervention. You can’t just edit a state file and move on.
Here’s a Terraform remote backend config that I use on every project:
terraform {
backend "s3" {
bucket = "mycompany-tfstate"
key = "prod/api/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-lock"
}
}
With CDK, there’s no equivalent setup. You bootstrap the account, and CloudFormation handles the rest. That’s either a feature or a limitation depending on how much control you want.
For teams that don’t want to think about state at all, CDK wins. For teams that need fine-grained control over state — splitting it by component, managing cross-stack references, doing targeted applies — Terraform wins. I split my Terraform state by environment and component as a rule. Networking, compute, database, monitoring — all separate state files. I talked about why in the state management post.
Testing Infrastructure Code
Testing is where CDK has a genuine, structural advantage. Because your infrastructure is defined in a real programming language, you can write real unit tests against it. CDK’s assertions module lets you inspect the synthesized CloudFormation template and assert on resource properties, counts, and relationships.
import { Template } from 'aws-cdk-lib/assertions';
test('Lambda has correct timeout', () => {
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::Lambda::Function', {
Timeout: 30,
MemorySize: 256,
});
});
That test runs in milliseconds. No cloud resources, no credentials, no network calls. You’re testing the output of your CDK code — the CloudFormation template — which is exactly what gets deployed. It’s fast, deterministic, and catches real bugs.
Terraform’s testing story has improved significantly. The terraform test framework (introduced in 1.6, matured since) lets you write tests in HCL:
run "lambda_config" {
command = plan
assert {
condition = aws_lambda_function.api.timeout == 30
error_message = "Lambda timeout should be 30 seconds"
}
assert {
condition = aws_lambda_function.api.memory_size == 256
error_message = "Lambda memory should be 256MB"
}
}
This works, and it’s a big step forward from the days when testing Terraform meant running Terratest in Go and spinning up real infrastructure. But CDK’s testing is still more natural. Writing assertions in TypeScript with Jest feels like writing application tests. Writing assertions in HCL feels like writing more infrastructure code. Neither is wrong, but one is more familiar to most developers.
For integration testing — actually deploying infrastructure and validating it — both tools are roughly equivalent. You deploy, you test, you tear down. The tooling around this is more mature in the Terraform ecosystem (Terratest, kitchen-terraform), but CDK’s integ-tests construct is catching up.
Modules and Reusability
Both tools have strong module stories, but they work differently.
Terraform modules are directories of HCL files with input variables and outputs. They’re versioned, shareable, and composable. The public Terraform Registry has thousands of community modules. You can also host private modules in S3, Git repos, or Terraform Cloud. I wrote about module design patterns in detail — the key insight is that good modules are opinionated about structure but flexible about configuration.
module "api" {
source = "git::https://github.com/myorg/terraform-aws-lambda.git?ref=v2.1.0"
function_name = "api-handler"
runtime = "nodejs20.x"
handler = "index.handler"
timeout = 30
}
CDK’s equivalent is constructs. L1 constructs map 1:1 to CloudFormation resources. L2 constructs add sensible defaults and convenience methods. L3 constructs (patterns) compose multiple resources into higher-level abstractions. You can publish constructs to npm, PyPI, or any package registry your language supports.
import { HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha';
import { HttpLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha';
const api = new HttpApi(this, 'Api');
api.addRoutes({
path: '/items',
methods: [HttpMethod.GET],
integration: new HttpLambdaIntegration('GetItems', handler),
});
The construct model is more powerful in theory — you have a full programming language, so you can encode complex logic, validation, and defaults that HCL modules can’t express. In practice, I find Terraform modules easier to understand at a glance. A module’s interface is its variables and outputs, laid out in plain text. A construct’s interface is a class with properties, methods, and inheritance — more powerful, but more to parse mentally.
Team Adoption and the Learning Curve
This is where the decision often gets made, and it’s not about technology — it’s about people.
If your team is full of application developers who write TypeScript or Python daily, CDK will feel natural. They already know the language, the tooling, the testing patterns. The learning curve is “how does AWS work” not “how does this tool work.” I’ve seen frontend teams pick up CDK and start shipping infrastructure within a week. That’s remarkable.
If your team is ops-heavy, or if you have dedicated platform engineers, Terraform is usually the better fit. HCL is simple enough that anyone can read it, even without deep programming experience. The plan-apply workflow is intuitive. And the skills transfer across cloud providers — learn Terraform once, use it everywhere.
The worst scenario I’ve seen is forcing CDK on a team that doesn’t know TypeScript. Now they’re learning a programming language and AWS and CloudFormation and CDK constructs all at once. That’s four learning curves stacked on top of each other. I watched a team struggle with this for three months before they switched to Terraform and were productive within two weeks.
The second-worst scenario: forcing Terraform on a team of TypeScript developers who find HCL limiting and frustrating. They’ll fight the tool instead of using it. They’ll write Terraform that looks like it wishes it were TypeScript — over-engineered modules with complex for_each expressions and deeply nested dynamic blocks. That’s not good Terraform. Let them use CDK.
So Which One Should You Pick?
Here’s my decision framework. It’s not complicated:
Pick CDK if:
- You’re all-in on AWS with no multi-cloud plans
- Your team already writes TypeScript or Python
- You value high-level abstractions and sensible defaults
- You want tight integration with AWS services
- Testing infrastructure code is a priority
Pick Terraform if:
- Multi-cloud is on the table, now or in the future
- Your team is ops-focused or mixed-discipline
- You want explicit control over every resource
- You need fine-grained state management
- You’re managing infrastructure across many providers (cloud + SaaS)
Pick both if:
- Different teams have different needs (this is more common than people admit)
- You’re primarily AWS but have a few multi-cloud edges
I run both in production today. My core AWS platform uses CDK because the team is strong in TypeScript and we get a lot of leverage from L2 constructs. Our monitoring stack uses Terraform because it spans AWS CloudWatch, Datadog, and PagerDuty. Our networking layer uses Terraform because I want explicit, readable configs for the stuff that everything else depends on.
There’s no single right answer. There’s only the right answer for your team, your stack, and where you’re headed. Pick the tool that makes your team productive and your infrastructure maintainable. That’s what matters.
For more on making these kinds of decisions, I wrote about infrastructure as code best practices that apply regardless of which tool you choose — version control patterns, review processes, testing strategies, and the organizational habits that separate good IaC from “we have YAML in Git and call it a day.”