Back to Journal
DevOps

Infrastructure as Code: Typescript vs Python in 2025

An in-depth comparison of Typescript and Python for Infrastructure as Code, with benchmarks, cost analysis, and practical guidance for choosing the right tool.

Muneer Puthiya Purayil 16 min read

Infrastructure as Code: TypeScript vs Python in 2025

TypeScript and Python are the two most popular general-purpose languages for Infrastructure as Code. Both have excellent Pulumi support, both work with AWS CDK and CDKTF, and both offer ecosystems that extend far beyond infrastructure provisioning. But they make different bets on type safety, developer ergonomics, and runtime characteristics that compound across large projects.

This is not an abstract language comparison. It's a practical analysis of how TypeScript and Python handle the specific challenges of defining, testing, and deploying cloud infrastructure at scale in 2025.

The Fundamental Tradeoff

TypeScript catches more errors at compile time. Python lets you write less code and iterate faster. Everything else flows from this core difference.

typescript
1// TypeScript — the compiler prevents this
2const subnet = new aws.ec2.Subnet("sub", {
3 vpcId: 42, // Error: Type 'number' is not assignable to type 'Input<string>'
4 cidrBlock: "10.0.1.0/24",
5 availabilityZone: "us-east-1a",
6});
7 
python
1# Python — this runs without error, fails at deployment
2subnet = aws.ec2.Subnet("sub",
3 vpc_id=42, # No error until pulumi preview
4 cidr_block="10.0.1.0/24",
5 availability_zone="us-east-1a",
6)
7 

Python with mypy --strict catches this specific case, but TypeScript's type checking is on by default and covers more edge cases. The question is whether those extra catches justify TypeScript's additional syntax.

Code Volume: Real Comparisons

Let's see the same infrastructure patterns in both languages to get a feel for the code density difference.

VPC with Multi-AZ Subnets

TypeScript (32 lines):

typescript
1import * as pulumi from "@pulumi/pulumi";
2import * as aws from "@pulumi/aws";
3 
4const env = pulumi.getStack();
5const azs = aws.getAvailabilityZones({ state: "available" });
6 
7const vpc = new aws.ec2.Vpc(`${env}-vpc`, {
8 cidrBlock: "10.0.0.0/16",
9 enableDnsHostnames: true,
10 tags: { Name: `${env}-vpc` },
11});
12 
13const publicSubnets = azs.then((available) =>
14 available.names.slice(0, 3).map(
15 (az, i) =>
16 new aws.ec2.Subnet(`${env}-pub-${az}`, {
17 vpcId: vpc.id,
18 cidrBlock: `10.0.${i}.0/24`,
19 availabilityZone: az,
20 mapPublicIpOnLaunch: true,
21 tags: { Name: `${env}-pub-${az}`, Tier: "public" },
22 })
23 )
24);
25 
26const privateSubnets = azs.then((available) =>
27 available.names.slice(0, 3).map(
28 (az, i) =>
29 new aws.ec2.Subnet(`${env}-priv-${az}`, {
30 vpcId: vpc.id,
31 cidrBlock: `10.0.${i + 10}.0/24`,
32 availabilityZone: az,
33 tags: { Name: `${env}-priv-${az}`, Tier: "private" },
34 })
35 )
36);
37 

Python (30 lines):

python
1import pulumi
2import pulumi_aws as aws
3 
4env = pulumi.get_stack()
5azs = aws.get_availability_zones(state="available")
6 
7vpc = aws.ec2.Vpc(f"{env}-vpc",
8 cidr_block="10.0.0.0/16",
9 enable_dns_hostnames=True,
10 tags={"Name": f"{env}-vpc"},
11)
12 
13public_subnets = [
14 aws.ec2.Subnet(f"{env}-pub-{az}",
15 vpc_id=vpc.id,
16 cidr_block=f"10.0.{i}.0/24",
17 availability_zone=az,
18 map_public_ip_on_launch=True,
19 tags={"Name": f"{env}-pub-{az}", "Tier": "public"},
20 )
21 for i, az in enumerate(azs.names[:3])
22]
23 
24private_subnets = [
25 aws.ec2.Subnet(f"{env}-priv-{az}",
26 vpc_id=vpc.id,
27 cidr_block=f"10.0.{i + 10}.0/24",
28 availability_zone=az,
29 tags={"Name": f"{env}-priv-{az}", "Tier": "private"},
30 )
31 for i, az in enumerate(azs.names[:3])
32]
33 

Nearly identical line counts. Python's list comprehensions are slightly more concise than TypeScript's .map() with arrow functions. The difference is negligible for simple resource definitions.

Where the Gap Widens: Reusable Components

TypeScript component (45 lines):

typescript
1interface CacheClusterArgs {
2 vpcId: pulumi.Input<string>;
3 subnetIds: pulumi.Input<string>[];
4 nodeType?: string;
5 numCacheNodes?: number;
6 engineVersion?: string;
7 port?: number;
8}
9 
10class CacheCluster extends pulumi.ComponentResource {
11 public readonly endpoint: pulumi.Output<string>;
12 public readonly port: pulumi.Output<number>;
13 
14 constructor(name: string, args: CacheClusterArgs, opts?: pulumi.ComponentResourceOptions) {
15 super("custom:cache:RedisCluster", name, args, opts);
16 
17 const { vpcId, subnetIds, nodeType = "cache.r6g.large", numCacheNodes = 2, engineVersion = "7.0", port = 6379 } = args;
18 
19 const subnetGroup = new aws.elasticache.SubnetGroup(`${name}-subnets`, {
20 subnetIds,
21 }, { parent: this });
22 
23 const sg = new aws.ec2.SecurityGroup(`${name}-sg`, {
24 vpcId,
25 ingress: [{ protocol: "tcp", fromPort: port, toPort: port, cidrBlocks: ["10.0.0.0/8"] }],
26 egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
27 }, { parent: this });
28 
29 const cluster = new aws.elasticache.Cluster(`${name}-cluster`, {
30 engine: "redis",
31 engineVersion,
32 nodeType,
33 numCacheNodes,
34 subnetGroupName: subnetGroup.name,
35 securityGroupIds: [sg.id],
36 port,
37 }, { parent: this });
38 
39 this.endpoint = cluster.cacheNodes.apply((nodes) => nodes[0].address);
40 this.port = pulumi.output(port);
41 this.registerOutputs({ endpoint: this.endpoint, port: this.port });
42 }
43}
44 

Python component (42 lines):

python
1from dataclasses import dataclass, field
2from typing import Sequence, Optional
3import pulumi
4import pulumi_aws as aws
5 
6 
7class CacheCluster(pulumi.ComponentResource):
8 endpoint: pulumi.Output[str]
9 port: pulumi.Output[int]
10 
11 def __init__(
12 self,
13 name: str,
14 vpc_id: pulumi.Input[str],
15 subnet_ids: Sequence[pulumi.Input[str]],
16 node_type: str = "cache.r6g.large",
17 num_cache_nodes: int = 2,
18 engine_version: str = "7.0",
19 port: int = 6379,
20 opts: Optional[pulumi.ResourceOptions] = None,
21 ):
22 super().__init__("custom:cache:RedisCluster", name, None, opts)
23 
24 subnet_group = aws.elasticache.SubnetGroup(f"{name}-subnets",
25 subnet_ids=subnet_ids,
26 opts=pulumi.ResourceOptions(parent=self),
27 )
28 
29 sg = aws.ec2.SecurityGroup(f"{name}-sg",
30 vpc_id=vpc_id,
31 ingress=[{"protocol": "tcp", "from_port": port, "to_port": port, "cidr_blocks": ["10.0.0.0/8"]}],
32 egress=[{"protocol": "-1", "from_port": 0, "to_port": 0, "cidr_blocks": ["0.0.0.0/0"]}],
33 opts=pulumi.ResourceOptions(parent=self),
34 )
35 
36 cluster = aws.elasticache.Cluster(f"{name}-cluster",
37 engine="redis",
38 engine_version=engine_version,
39 node_type=node_type,
40 num_cache_nodes=num_cache_nodes,
41 subnet_group_name=subnet_group.name,
42 security_group_ids=[sg.id],
43 port=port,
44 opts=pulumi.ResourceOptions(parent=self),
45 )
46 
47 self.endpoint = cluster.cache_nodes.apply(lambda nodes: nodes[0].address)
48 self.port = pulumi.Output.from_input(port)
49 self.register_outputs({"endpoint": self.endpoint, "port": self.port})
50 

The TypeScript interface block is separate from the class, providing a clean API contract. Python uses constructor parameters with type hints, which is more compact but less discoverable for consumers. Both are roughly the same length.

Type Safety Deep Dive

TypeScript's Advantages

TypeScript catches configuration errors before any cloud API call:

typescript
1// Compile error — boolean where string expected
2new aws.s3.BucketV2("data", {
3 bucket: true, // Error
4});
5 
6// Compile error — unknown property
7new aws.ec2.Instance("web", {
8 instancetype: "t3.micro", // Error: Did you mean 'instanceType'?
9});
10 
11// Autocomplete shows all valid properties
12new aws.rds.Cluster("db", {
13 engine: "", // IDE autocomplete suggests: aurora-mysql, aurora-postgresql, etc.
14});
15 

TypeScript's structural typing also enables pattern-level validation:

typescript
1import { z } from "zod";
2 
3const EnvConfig = z.object({
4 vpcCidr: z.string().regex(/^\d+\.\d+\.\d+\.\d+\/\d+$/),
5 instanceType: z.enum(["t3.micro", "t3.small", "t3.medium", "t3.large"]),
6 minNodes: z.number().min(1).max(50),
7 maxNodes: z.number().min(1).max(200),
8});
9 
10type EnvConfigType = z.infer<typeof EnvConfig>;
11 
12function loadConfig(): EnvConfigType {
13 const config = new pulumi.Config();
14 return EnvConfig.parse({
15 vpcCidr: config.require("vpcCidr"),
16 instanceType: config.require("instanceType"),
17 minNodes: config.requireNumber("minNodes"),
18 maxNodes: config.requireNumber("maxNodes"),
19 });
20}
21 

Python's Type Checking with mypy

Python's type safety is opt-in but increasingly robust:

python
1import pulumi
2import pulumi_aws as aws
3from typing import Sequence
4 
5# mypy catches basic type errors
6vpc = aws.ec2.Vpc("vpc",
7 cidr_block=42, # Error: Argument "cidr_block" has incompatible type "int"; expected "str"
8)
9 
10# mypy also catches wrong Output types (with Pulumi stubs)
11def create_subnets(vpc_id: pulumi.Input[str], cidrs: Sequence[str]) -> list[aws.ec2.Subnet]:
12 return [
13 aws.ec2.Subnet(f"subnet-{i}", vpc_id=vpc_id, cidr_block=cidr)
14 for i, cidr in enumerate(cidrs)
15 ]
16 
17# This errors because we pass Output[str] where Sequence[str] expected
18create_subnets(vpc.id, vpc.cidr_block) # mypy error
19 

However, Python's type checking has gaps:

python
1# These pass mypy — both vpc_id and security_group.id are Output[str]
2subnet = aws.ec2.Subnet("sub",
3 vpc_id=security_group.id, # Semantically wrong, type-checks fine
4 cidr_block="10.0.1.0/24",
5)
6 

TypeScript has the same limitation, but the difference is that TypeScript type checking is always on by default, while Python requires explicit mypy configuration and CI integration.

Testing Approaches

TypeScript with Vitest

typescript
1import { describe, it, expect, beforeAll } from "vitest";
2import * as pulumi from "@pulumi/pulumi";
3 
4pulumi.runtime.setMocks({
5 newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
6 call: (args) => {
7 if (args.token === "aws:index/getAvailabilityZones:getAvailabilityZones") {
8 return { names: ["us-east-1a", "us-east-1b", "us-east-1c"] };
9 }
10 return {};
11 },
12});
13 
14describe("Networking Stack", () => {
15 let vpc: any;
16 let subnets: any[];
17 
18 beforeAll(async () => {
19 const infra = await import("../src/networking");
20 vpc = infra.vpc;
21 subnets = infra.privateSubnets;
22 });
23 
24 it("creates VPC with DNS enabled", async () => {
25 const dns = await new Promise((resolve) => vpc.enableDnsHostnames.apply(resolve));
26 expect(dns).toBe(true);
27 });
28 
29 it("creates 3 private subnets", () => {
30 expect(subnets).toHaveLength(3);
31 });
32 
33 it("places subnets in different AZs", async () => {
34 const azs = await Promise.all(
35 subnets.map((s) => new Promise((resolve) => s.availabilityZone.apply(resolve)))
36 );
37 expect(new Set(azs).size).toBe(3);
38 });
39});
40 

Python with pytest

python
1import pulumi
2import pytest
3 
4class TestMocks(pulumi.runtime.Mocks):
5 def new_resource(self, args):
6 return [f"{args.name}-id", args.inputs]
7 
8 def call(self, args):
9 if args.token == "aws:index/getAvailabilityZones:getAvailabilityZones":
10 return {"names": ["us-east-1a", "us-east-1b", "us-east-1c"]}
11 return {}
12 
13pulumi.runtime.set_mocks(TestMocks())
14 
15 
16@pulumi.runtime.test
17async def test_vpc_has_dns_enabled():
18 from infra.networking import vpc
19 dns = await vpc.enable_dns_hostnames
20 assert dns is True
21 
22 
23@pulumi.runtime.test
24async def test_creates_three_private_subnets():
25 from infra.networking import private_subnets
26 assert len(private_subnets) == 3
27 
28 
29@pulumi.runtime.test
30async def test_subnets_in_different_azs():
31 from infra.networking import private_subnets
32 azs = set()
33 for subnet in private_subnets:
34 az = await subnet.availability_zone
35 azs.add(az)
36 assert len(azs) == 3
37 

Both testing approaches work well. TypeScript uses Promise-based patterns; Python uses async/await with @pulumi.runtime.test. The Python version is slightly more readable due to assert syntax and the await keyword applying directly to Output properties.

CDK-Specific Testing

TypeScript with CDK assertions:

typescript
1import { Template, Match } from "aws-cdk-lib/assertions";
2 
3const template = Template.fromStack(stack);
4 
5template.hasResourceProperties("AWS::RDS::DBCluster", {
6 StorageEncrypted: true,
7 DeletionProtection: true,
8 BackupRetentionPeriod: 7,
9 Engine: "aurora-postgresql",
10});
11 
12template.resourceCountIs("AWS::EC2::Subnet", 6);
13 

Python with CDK assertions:

python
1from aws_cdk.assertions import Template, Match
2 
3template = Template.from_stack(stack)
4 
5template.has_resource_properties("AWS::RDS::DBCluster", {
6 "StorageEncrypted": True,
7 "DeletionProtection": True,
8 "BackupRetentionPeriod": 7,
9 "Engine": "aurora-postgresql",
10})
11 
12template.resource_count_is("AWS::EC2::Subnet", 6)
13 

Virtually identical APIs. CDK testing is one area where the language choice barely matters.

IDE and Developer Experience

TypeScript Advantages

  • Autocomplete everything: Property names, valid enum values, required vs optional fields
  • Inline errors: Red squiggles appear as you type, not after running a separate checker
  • Refactoring support: Rename a variable or interface property and all usages update
  • Import management: Auto-import suggestions when you type a resource class name

Python Advantages

  • REPL-driven exploration: Open python3, import pulumi_aws, explore APIs interactively
  • Less ceremony: No import * as aws from "@pulumi/aws" — just import pulumi_aws as aws
  • String interpolation: f-strings are cleaner than template literals for resource names
  • Dynamic typing for prototyping: Quickly sketch infrastructure without satisfying the type checker

Real Productivity Impact

In our experience across teams, TypeScript's IDE advantage saves roughly 10-15 minutes per hour of active IaC development. This comes from:

  • Not looking up property names in documentation (autocomplete provides them)
  • Catching typos immediately rather than at pulumi preview time
  • Navigating between resource definitions faster with jump-to-definition

Python's REPL advantage is most valuable during the exploration phase — figuring out which resources exist, what properties they accept. Once you know the shape of your infrastructure, TypeScript's IDE support provides more ongoing value.

Need a second opinion on your DevOps pipelines architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Ecosystem Comparison

Python's Unique Strengths

  • Data processing: Analyze infrastructure costs with pandas, generate reports
  • ML/AI workloads: Share Python types between ML pipeline definitions and infrastructure
  • boto3 depth: The most comprehensive AWS SDK, essential for custom automation
  • Scripting: Quick operational scripts that interact with infrastructure
python
1# Cost analysis alongside IaC — Python's sweet spot
2import boto3
3import pandas as pd
4from datetime import datetime, timedelta
5 
6ce = boto3.client("ce")
7end = datetime.now().strftime("%Y-%m-%d")
8start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
9 
10response = ce.get_cost_and_usage(
11 TimePeriod={"Start": start, "End": end},
12 Granularity="DAILY",
13 Metrics=["UnblendedCost"],
14 GroupBy=[{"Type": "TAG", "Key": "pulumi:stack"}],
15)
16 
17costs_by_stack = pd.DataFrame([
18 {"stack": g["Keys"][0] or "untagged", "cost": float(g["Metrics"]["UnblendedCost"]["Amount"])}
19 for day in response["ResultsByTime"]
20 for g in day["Groups"]
21]).groupby("stack")["cost"].sum().sort_values(ascending=False)
22 

TypeScript's Unique Strengths

  • Full-stack type sharing: Share interfaces between CDK stacks and Lambda handlers
  • npm ecosystem: Zod for validation, lodash for transforms, thousands of utilities
  • JSON manipulation: Native JSON support, no json.dumps/loads
  • AWS CDK first-class: CDK was built TypeScript-first, with the richest L2/L3 constructs
typescript
1// Shared types between CDK stack and Lambda handler
2// shared/types.ts
3export interface OrderEvent {
4 orderId: string;
5 customerId: string;
6 amount: number;
7 currency: "USD" | "EUR" | "GBP";
8}
9 
10// cdk/api-stack.ts — uses the type for environment config
11const handler = new nodejs.NodejsFunction(this, "OrderHandler", {
12 entry: "lambda/orders/handler.ts", // Same TypeScript, same types
13 environment: { TABLE_NAME: table.tableName },
14});
15 
16// lambda/orders/handler.ts — uses the same type
17import { OrderEvent } from "../../shared/types";
18 
19export const handler = async (event: APIGatewayProxyEvent) => {
20 const order: OrderEvent = JSON.parse(event.body!);
21 // TypeScript validates the shape at compile time
22};
23 

CI/CD Performance

MetricTypeScriptPython
Dependency installnpm ci: 8-15spip install: 15-30s
Type checkingtsc --noEmit: 3-8smypy: 5-15s
Unit testsvitest: 2-5spytest: 3-8s
pulumi preview2-3s startup2-3s startup
Docker image~150MB (Node.js)~200MB (Python + deps)
Reproducibilitypackage-lock.jsonrequirements.txt + pip freeze

TypeScript's dependency installation is faster (npm's caching is more aggressive). Python's pip install with compiled wheels can be slower, especially for packages with C extensions. Both are negligible compared to pulumi up execution time.

Python's reproducibility story is weaker by default — requirements.txt without pinned hashes can lead to different package versions across environments. Using pip-tools with requirements.inrequirements.txt or poetry.lock closes this gap.

Migration Considerations

From HCL to TypeScript

HCL's object syntax maps naturally to TypeScript objects:

hcl
1# HCL
2resource "aws_s3_bucket" "data" {
3 bucket = "my-data-bucket"
4 tags = {
5 Environment = "production"
6 }
7}
8 
typescript
1// TypeScript — direct structural mapping
2const data = new aws.s3.BucketV2("data", {
3 bucket: "my-data-bucket",
4 tags: { Environment: "production" },
5});
6 

From HCL to Python

HCL to Python also maps cleanly, with dictionaries replacing objects:

python
1# Python — equally direct
2data = aws.s3.BucketV2("data",
3 bucket="my-data-bucket",
4 tags={"Environment": "production"},
5)
6 

Both transitions from HCL are straightforward. The choice between TypeScript and Python for the migration target should be based on team expertise, not migration difficulty.

Performance at Scale

For projects with 500+ resources:

  • TypeScript: Pulumi's Node.js runtime handles large dependency graphs well. Memory usage stays under 500MB for typical stacks. The V8 JIT compiler handles the computational aspects efficiently.
  • Python: Memory usage can be higher due to Python's per-object overhead. For very large stacks (1000+ resources), Python may use 20-30% more memory than TypeScript for the same infrastructure. Startup time increases with provider import count but rarely exceeds 5 seconds.

Neither language is a bottleneck at typical IaC scale. If you're hitting performance limits, the solution is stack splitting, not language switching.

When to Choose Each

Choose TypeScript When:

  • Your application code is already TypeScript/JavaScript
  • You want the strongest IDE experience with zero configuration
  • You're sharing types between Lambda handlers and infrastructure definitions
  • Your team values compile-time safety over iteration speed
  • You're using AWS CDK (it's TypeScript-first)

Choose Python When:

  • Your team's primary language is Python (data, ML, backend)
  • You need integration with data processing or analytics workflows
  • Rapid prototyping and REPL-driven exploration matter more than type safety
  • You're building operational scripts alongside infrastructure definitions
  • Your IaC project is a subset of a larger Python monorepo

Conclusion

TypeScript and Python are closer in capability for IaC than the language flamewars suggest. Both work with Pulumi, CDKTF, and their respective CDKs. Both have adequate type checking (TypeScript natively, Python with mypy). Both have rich ecosystems that extend beyond infrastructure provisioning.

The meaningful differences are at the margins: TypeScript's IDE experience is measurably better — autocomplete, inline errors, and refactoring support save real time during development. Python's ecosystem is broader for data-adjacent workflows — cost analysis, operational scripting, and ML pipeline integration. TypeScript's type checking catches more errors by default; Python requires explicit tooling setup to approach the same level.

For teams choosing in 2025, the right answer is almost always "whichever language your team already uses." A Python team writing TypeScript IaC will fight the toolchain and produce worse infrastructure code than if they used Python. The same is true in reverse. The 10-15% productivity advantage of your team's primary language outweighs any inherent advantage either language has for IaC specifically.

FAQ

Need expert help?

Building with CI/CD pipelines?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026