AWS CDK vs CloudFormation: How Developers Define Cloud Infrastructure in Python

If you've ever stared at a 2,000-line CloudFormation YAML file and thought "there has to be a better way", AWS CDK is the answer — it lets you define cloud infrastructure using real programming languages like Python, with loops, functions, and abstractions, while still deploying through CloudFormation under the hood.

TL;DR

DimensionAWS CloudFormationAWS CDK
LanguageYAML / JSON (declarative)Python, TypeScript, Java, Go, C# (imperative)
Abstraction LevelRaw AWS resource definitionsHigh-level constructs (L1 → L3)
ReusabilityNested stacks, macros (complex)Python classes, pip packages
Deployment EngineCloudFormation directlySynthesizes to CloudFormation, then deploys
IDE SupportLimited (schema hints)Full autocomplete, type checking, linting
TestingManual / cfn-lintUnit tests with pytest, assertions on synth output
Learning CurveLow (if you know YAML)Low for developers, requires CDK CLI

The Core Mental Model: CDK is a Compiler

Think of AWS CDK exactly like a compiler in a traditional software stack. You write high-level Python code (your "source"), and CDK synthesizes it into a CloudFormation template (the "machine code"). CloudFormation then executes that template against the AWS APIs to provision real resources.

Analogy: Writing raw CloudFormation YAML is like writing x86 assembly — precise and powerful, but tedious and error-prone at scale. AWS CDK is like writing Python — you work at a higher abstraction level, and the compiler (CDK) handles the translation to low-level instructions (CloudFormation JSON) that the machine (AWS) understands.

How CDK Works Under the Hood

graph LR A[Python CDK App] -->|cdk synth| B[Cloud Assembly cdk.out] B -->|CloudFormation Template JSON| C[CloudFormation Service] D[Lambda Code / Docker Assets] -->|cdk deploy uploads assets| E[Bootstrap S3 / ECR] E --> C C -->|Provisions Resources| F[AWS Resources] F --> G[S3 Bucket] F --> H[Lambda Function] F --> I[VPC / RDS / etc]
  1. You write Python code using CDK constructs (e.g., aws_cdk.aws_s3.Bucket).
  2. cdk synth runs your Python app, which builds an in-memory construct tree and serializes it to a CloudFormation template JSON file inside a cdk.out/ directory (the "Cloud Assembly").
  3. cdk deploy takes that synthesized template and calls the CloudFormation API to create or update a Stack.
  4. CloudFormation provisions the actual AWS resources (S3 buckets, Lambda functions, VPCs, etc.) via AWS service APIs.
  5. CDK Toolkit (Bootstrap): For assets like Lambda code or Docker images, CDK first uploads them to a bootstrapped S3 bucket and ECR repository in your account before deployment.

The Three Levels of CDK Constructs

CDK's power comes from its layered construct library. Understanding these three levels is fundamental to using CDK effectively.

graph TD L3[L3 Pattern - Full Architecture e.g. API plus Lambda plus DynamoDB] L2[L2 Curated Construct - Intent-based with defaults e.g. s3.Bucket] L1[L1 CfnResource - 1to1 CloudFormation e.g. s3.CfnBucket] CFN[CloudFormation Template JSON] L3 -->|composes| L2 L2 -->|wraps| L1 L1 -->|synthesizes to| CFN style L3 fill:#4a90d9,color:#fff style L2 fill:#5ba85a,color:#fff style L1 fill:#e8a838,color:#fff style CFN fill:#d9534f,color:#fff

L1 — CloudFormation Resource (Cfn* classes)

Direct 1:1 mapping to a CloudFormation resource. Every property you'd write in YAML is available as a constructor argument. These are auto-generated from the CloudFormation resource specification.

# L1: Raw CloudFormation resource — full control, maximum verbosity
from aws_cdk import aws_s3 as s3

bucket = s3.CfnBucket(
    self, "MyL1Bucket",
    bucket_name="my-l1-bucket",
    versioning_configuration=s3.CfnBucket.VersioningConfigurationProperty(
        status="Enabled"
    )
)

L2 — Curated Construct (Intent-based)

The most commonly used level. L2 constructs wrap L1 resources with sensible defaults, helper methods, and security best practices baked in. They expose intent rather than raw configuration.

# L2: High-level construct — sensible defaults, grant() methods, event hooks
from aws_cdk import aws_s3 as s3
from aws_cdk import aws_lambda as lambda_

bucket = s3.Bucket(
    self, "MyL2Bucket",
    versioned=True,
    encryption=s3.BucketEncryption.S3_MANAGED,
    block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
)

my_function = lambda_.Function(
    self, "MyFunction",
    runtime=lambda_.Runtime.PYTHON_3_12,
    handler="index.handler",
    code=lambda_.Code.from_asset("lambda"),
)

# L2 magic: generates a least-privilege IAM policy automatically
bucket.grant_read(my_function)

L3 — Patterns (aws-solutions-constructs or custom)

Opinionated, multi-resource patterns that encode a complete architectural pattern. For example, an "API Gateway → Lambda → DynamoDB" pattern as a single construct. These can be from the official aws-solutions-constructs library or your own reusable Python classes.

# L3: Custom reusable pattern — encapsulates an entire microservice
from aws_cdk import Stack
from aws_cdk import aws_lambda as lambda_
from aws_cdk import aws_dynamodb as dynamodb
from aws_cdk import aws_apigateway as apigw
from constructs import Construct

class MicroservicePattern(Construct):
    """Reusable L3 pattern: API GW -> Lambda -> DynamoDB"""
    def __init__(self, scope: Construct, id: str, service_name: str) -> None:
        super().__init__(scope, id)

        table = dynamodb.Table(
            self, f"{service_name}Table",
            partition_key=dynamodb.Attribute(
                name="id",
                type=dynamodb.AttributeType.STRING
            ),
            billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
        )

        handler = lambda_.Function(
            self, f"{service_name}Handler",
            runtime=lambda_.Runtime.PYTHON_3_12,
            handler="index.handler",
            code=lambda_.Code.from_asset(f"services/{service_name}"),
            environment={"TABLE_NAME": table.table_name},
        )

        table.grant_read_write_data(handler)

        self.api = apigw.LambdaRestApi(
            self, f"{service_name}Api",
            handler=handler,
        )

Project Structure: A Real Python CDK App

🔽 Click to expand: Full CDK Python project layout and app entry point
my_cdk_app/
├── app.py                  # CDK App entry point
├── cdk.json                # CDK toolkit configuration
├── requirements.txt        # Python dependencies
├── my_cdk_app/
│   ├── __init__.py
│   └── my_stack.py         # Stack definition
├── lambda/
│   └── index.py            # Lambda function code
└── tests/
    └── unit/
        └── test_my_stack.py # CDK unit tests
# app.py — The CDK App entry point
import aws_cdk as cdk
from my_cdk_app.my_stack import MyStack

app = cdk.App()

MyStack(
    app, "MyStack",
    env=cdk.Environment(
        account="123456789012",
        region="us-east-1"
    )
)

app.synth()
# my_cdk_app/my_stack.py — Stack definition
import aws_cdk as cdk
from aws_cdk import (
    aws_s3 as s3,
    aws_lambda as lambda_,
    aws_iam as iam,
)
from constructs import Construct

class MyStack(cdk.Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # S3 Bucket with encryption and versioning
        bucket = s3.Bucket(
            self, "DataBucket",
            versioned=True,
            encryption=s3.BucketEncryption.S3_MANAGED,
            block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
            removal_policy=cdk.RemovalPolicy.DESTROY,  # For dev only
        )

        # Lambda function
        processor = lambda_.Function(
            self, "DataProcessor",
            runtime=lambda_.Runtime.PYTHON_3_12,
            handler="index.handler",
            code=lambda_.Code.from_asset("lambda"),
            environment={
                "BUCKET_NAME": bucket.bucket_name,
            },
            timeout=cdk.Duration.seconds(30),
        )

        # Least-privilege: read-only access to the bucket
        bucket.grant_read(processor)

        # Output the bucket name
        cdk.CfnOutput(self, "BucketName", value=bucket.bucket_name)

Unit Testing CDK Stacks with pytest

One of CDK's biggest advantages over raw CloudFormation is testability. You can assert on the synthesized CloudFormation template using the aws_cdk.assertions module — no deployment required.

# tests/unit/test_my_stack.py
import aws_cdk as cdk
from aws_cdk import assertions
from my_cdk_app.my_stack import MyStack

def test_s3_bucket_created_with_versioning():
    app = cdk.App()
    stack = MyStack(app, "TestStack")
    template = assertions.Template.from_stack(stack)

    # Assert an S3 bucket exists with versioning enabled
    template.has_resource_properties("AWS::S3::Bucket", {
        "VersioningConfiguration": {
            "Status": "Enabled"
        }
    })

def test_lambda_has_bucket_env_var():
    app = cdk.App()
    stack = MyStack(app, "TestStack")
    template = assertions.Template.from_stack(stack)

    # Assert Lambda has the BUCKET_NAME environment variable
    template.has_resource_properties("AWS::Lambda::Function", {
        "Environment": {
            "Variables": assertions.Match.object_like({
                "BUCKET_NAME": assertions.Match.any_value()
            })
        }
    })

Key CDK CLI Commands

CommandWhat It Does
cdk init app --language pythonScaffold a new CDK Python project
cdk bootstrap aws://123456789012/us-east-1Provision CDK toolkit resources (S3, ECR, IAM) in your account/region — required once per environment
cdk synthSynthesize CloudFormation template to cdk.out/ without deploying
cdk diffShow infrastructure changes before deploying (like terraform plan)
cdk deploySynthesize and deploy the stack via CloudFormation
cdk destroyDelete the CloudFormation stack and its resources
cdk lsList all stacks in the app

When to Use CDK vs Raw CloudFormation

Use CDK When...Use Raw CloudFormation When...
Your team is developer-centric (Python/TypeScript)Your team is ops-centric and prefers declarative YAML
You need reusable infrastructure components (internal libraries)You need a simple, self-contained template with no build step
You want unit tests on infrastructure logicYou're integrating with tools that consume raw CFN templates directly
You're building complex, multi-stack applicationsYou need maximum portability with zero toolchain dependencies

Glossary

TermDefinition
ConstructThe basic building block of a CDK app. Represents a cloud component at L1, L2, or L3 abstraction.
StackA unit of deployment in CDK, maps 1:1 to a CloudFormation Stack.
Synthesis (synth)The process of executing your CDK app to produce a CloudFormation template and Cloud Assembly.
Cloud AssemblyThe output of cdk synth — a directory (cdk.out/) containing CloudFormation templates, assets, and deployment manifests.
BootstrapOne-time setup that provisions CDK-managed resources (S3 bucket, IAM roles) in an AWS account/region to support CDK deployments.

Next Steps

  • 📖 AWS CDK v2 Developer Guide — official starting point
  • 📦 CDK Python API Reference — all L1/L2 construct properties
  • 🧩 AWS Solutions Constructs — vetted L3 patterns
  • 🧪 Start with cdk init app --language python, write a simple S3 + Lambda stack, run cdk synth, and inspect the generated CloudFormation JSON in cdk.out/ — this single exercise will solidify the mental model.

Related Posts

Comments

Popular posts from this blog

EC2 No Internet Access in Custom VPC: Attaching an Internet Gateway and Fixing Route Tables

IAM User vs. IAM Role: Why Your EC2 Instance Should Never Use a User

EC2 SSH Connection Timeout: The Exact Security Group Rules You Need to Fix It