AWS SAM vs CDK: Choosing the Right IaC Tool for Serverless in 2025

·

7 min read

"Which one should I use: SAM or CDK?"

I've been asked this question approximately 847 times (give or take). The answer? It depends. But not in the frustrating consultant way-in the "each tool solves different problems" way.

Let's settle this once and for all.

The Landscape

Both AWS SAM (Serverless Application Model) and CDK (Cloud Development Kit) help you define infrastructure as code. Both generate CloudFormation templates. Both are maintained by AWS. But they serve different masters.

AWS SAM: Declarative YAML for serverless applications AWS CDK: Programmatic TypeScript/Python/Java for any AWS infrastructure

Think SAM as SQL, CDK as Python. One is domain-specific and optimized. The other is general-purpose and flexible.

Side-by-Side Comparison

Hello World API: SAM vs CDK

SAM (template.yaml):

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: python3.12
    Timeout: 30
    Environment:
      Variables:
        TABLE_NAME: !Ref UsersTable

Resources:
  HelloWorldApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Auth:
        DefaultAuthorizer: MyCognitoAuthorizer
        Authorizers:
          MyCognitoAuthorizer:
            UserPoolArn: !GetAtt UserPool.Arn

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: hello-world
      CodeUri: src/
      Handler: app.lambda_handler
      Events:
        GetUsers:
          Type: Api
          Properties:
            RestApiId: !Ref HelloWorldApi
            Path: /users
            Method: get
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref UsersTable

  UsersTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: users
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: userId
          AttributeType: S
      KeySchema:
        - AttributeName: userId
          KeyType: HASH

  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: my-app-users
      AutoVerifiedAttributes:
        - email

CDK (TypeScript):

import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as cognito from 'aws-cdk-lib/aws-cognito';

export class HelloWorldStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DynamoDB table
    const usersTable = new dynamodb.Table(this, 'UsersTable', {
      tableName: 'users',
      partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    });

    // Cognito User Pool
    const userPool = new cognito.UserPool(this, 'UserPool', {
      userPoolName: 'my-app-users',
      autoVerify: { email: true },
    });

    // Lambda function
    const helloFunction = new lambda.Function(this, 'HelloWorldFunction', {
      functionName: 'hello-world',
      runtime: lambda.Runtime.PYTHON_3_12,
      handler: 'app.lambda_handler',
      code: lambda.Code.fromAsset('src'),
      timeout: cdk.Duration.seconds(30),
      environment: {
        TABLE_NAME: usersTable.tableName,
      },
    });

    // Grant DynamoDB read permissions
    usersTable.grantReadData(helloFunction);

    // API Gateway with Cognito authorizer
    const api = new apigateway.RestApi(this, 'HelloWorldApi', {
      restApiName: 'hello-world-api',
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
      },
    });

    const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'Authorizer', {
      cognitoUserPools: [userPool],
    });

    const users = api.root.addResource('users');
    users.addMethod('GET', new apigateway.LambdaIntegration(helloFunction), {
      authorizer: auth,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    });

    // Outputs
    new cdk.CfnOutput(this, 'ApiUrl', {
      value: api.url,
      description: 'API Gateway URL',
    });
  }
}

Line count: SAM = 51 lines, CDK = 56 lines. Nearly identical for simple cases.

Feature Comparison Matrix

FeatureSAMCDKWinner
Learning CurveMinimal (YAML)Moderate (Programming)SAM
Local Testingsam local built-inRequires SAM CLISAM
Type SafetyNoneFull TypeScript supportCDK
ReusabilityLimited (nested stacks)High (constructs)CDK
Multi-ServiceServerless-focusedAll AWS servicesCDK
IDE SupportBasicExcellent autocompleteCDK
Custom LogicDifficultEasy (native code)CDK
Deployment SpeedFastSlower (synth step)SAM
DebuggingStraightforwardRequires understandingSAM
CommunityLarge, serverless-focusedGrowing, polyglotTie

When to Use SAM

✅ Perfect For:

1. Pure Serverless Applications

# SAM excels at this
Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        Api:
          Type: Api
        Schedule:
          Type: Schedule
          Properties:
            Schedule: rate(5 minutes)
        DynamoDB:
          Type: DynamoDB
          Properties:
            Stream: !GetAtt Table.StreamArn

2. Quick Prototypes

sam init --runtime python3.12 --app-template hello-world
cd sam-app
sam build && sam deploy --guided
# Done in 5 minutes

3. Teams New to IaC YAML is familiar, readable, and doesn't require programming knowledge.

4. Local Development

sam local start-api  # API Gateway locally
sam local invoke MyFunction  # Test single function
sam local start-lambda  # Lambda endpoint for testing

When to Use CDK

✅ Perfect For:

1. Complex Infrastructure

// Easy to build complex patterns
for (let i = 0; i < 10; i++) {
  new lambda.Function(this, `Function${i}`, {
    functionName: `processor-${i}`,
    runtime: lambda.Runtime.PYTHON_3_12,
    handler: 'index.handler',
    code: lambda.Code.fromAsset('lambda'),
    environment: {
      PARTITION: i.toString(),
      TABLE_NAME: tables[i % 5].tableName,  // Distribute across tables
    },
  });
}

2. Multi-Stack Applications

// Cross-stack references are trivial
export class NetworkStack extends cdk.Stack {
  public readonly vpc: ec2.Vpc;

  constructor(scope: cdk.App, id: string) {
    super(scope, id);
    this.vpc = new ec2.Vpc(this, 'VPC', { maxAzs: 2 });
  }
}

export class ApplicationStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, vpc: ec2.Vpc) {
    super(scope, id);

    const func = new lambda.Function(this, 'Function', {
      vpc,  // Reference from another stack
      // ...
    });
  }
}

// App
const networkStack = new NetworkStack(app, 'Network');
new ApplicationStack(app, 'App', networkStack.vpc);

3. Custom Abstractions

// Build reusable constructs
export class SecureLambdaFunction extends cdk.Construct {
  public readonly function: lambda.Function;

  constructor(scope: cdk.Construct, id: string, props: SecureLambdaProps) {
    super(scope, id);

    // Apply security best practices automatically
    this.function = new lambda.Function(this, 'Function', {
      ...props,
      environment: {
        ...props.environment,
        NODE_OPTIONS: '--enable-source-maps',
      },
      tracing: lambda.Tracing.ACTIVE,  // Always enable X-Ray
      logRetention: logs.RetentionDays.ONE_WEEK,
      deadLetterQueueEnabled: true,
      reservedConcurrentExecutions: props.maxConcurrency || 10,
    });

    // Add standard alarms
    this.function.metricErrors().createAlarm(this, 'ErrorAlarm', {
      threshold: 10,
      evaluationPeriods: 1,
    });
  }
}

// Usage
new SecureLambdaFunction(this, 'MyFunc', {
  handler: 'index.handler',
  runtime: lambda.Runtime.NODEJS_18_X,
});

4. Testing Infrastructure

// test/stack.test.ts
import { Template } from 'aws-cdk-lib/assertions';
import { HelloWorldStack } from '../lib/stack';

test('Lambda has correct runtime', () => {
  const app = new cdk.App();
  const stack = new HelloWorldStack(app, 'TestStack');
  const template = Template.fromStack(stack);

  template.hasResourceProperties('AWS::Lambda::Function', {
    Runtime: 'python3.12',
    Timeout: 30,
  });
});

test('DynamoDB has billing mode PAY_PER_REQUEST', () => {
  const template = Template.fromStack(stack);

  template.hasResourceProperties('AWS::DynamoDB::Table', {
    BillingMode: 'PAY_PER_REQUEST',
  });
});

Hybrid Approach: Best of Both Worlds

You don't have to choose exclusively:

Use CDK with SAM CLI

// Deploy with CDK
cdk synth
cdk deploy

// But test locally with SAM
sam local start-api -t ./cdk.out/MyStack.template.json

Embed SAM Resources in CDK

import * as sam from 'aws-cdk-lib/aws-sam';

// Use SAM's shorthand where it helps
const samFunction = new sam.CfnFunction(this, 'SAMFunction', {
  codeUri: 's3://mybucket/code.zip',
  handler: 'index.handler',
  runtime: 'python3.12',
  events: {
    Api: {
      type: 'Api',
      properties: {
        path: '/hello',
        method: 'get',
      },
    },
  },
});

Migration Guide: SAM to CDK

Step 1: Analyze Current SAM Template

# Identify resources
aws cloudformation describe-stack-resources --stack-name my-sam-stack

Step 2: Create CDK App

mkdir my-cdk-app && cd my-cdk-app
cdk init app --language typescript
npm install

Step 3: Convert Resource by Resource

SAM Function:

MyFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: src/
    Handler: app.handler
    Events:
      Api:
        Type: Api
        Properties:
          Path: /users
          Method: get

→ CDK Equivalent:

const api = new apigw.RestApi(this, 'Api');
const myFunction = new lambda.Function(this, 'MyFunction', {
  code: lambda.Code.fromAsset('src'),
  handler: 'app.handler',
  runtime: lambda.Runtime.PYTHON_3_12,
});

api.root.addResource('users').addMethod('GET', new apigw.LambdaIntegration(myFunction));

Step 4: Deploy Side-by-Side

# Keep SAM stack running
sam deploy

# Deploy CDK stack with different name
cdk deploy MyApp-Migrated

# Test CDK stack
# Cut over traffic
# Delete SAM stack

Decision Framework

START
  │
  │─ Is it pure serverless? (Lambda, API Gateway, DynamoDB)
  │   └─ YES → SAM
  │   
  │─ Do you need multiple AWS services? (VPC, RDS, ECS, etc.)
  │   └─ YES → CDK
  │
  │─ Do you need complex logic or loops?
  │   └─ YES → CDK
  │
  │─ Is team new to infrastructure?
  │   └─ YES → SAM
  │
  │─ Do you want type safety and IDE support?
  │   └─ YES → CDK
  │
  │─ Do you need extensive local testing?
      └─ YES → SAM (or CDK + SAM CLI)

Real-World Recommendations

Startup (0-10 engineers): Start with SAM. Simpler, faster iteration.

Scale-up (10-50 engineers): Migrate to CDK. Build reusable constructs.

Enterprise (50+ engineers): CDK with custom construct library. Enforce standards.

Solo developer: SAM for prototypes, CDK for production.

Performance & Costs

Both generate CloudFormation, so runtime performance is identical. Deployment differences:

MetricSAMCDK
Initial deploy~2 min~3 min (synth + deploy)
Update deploy~1 min~2 min
Local test startup<5 sec<5 sec (with SAM CLI)
Build complexityLowMedium

Conclusion

SAM: Faster to learn, perfect for pure serverless, excellent local testing.

CDK: More powerful, type-safe, reusable, better for complex architectures.

My recommendation? Start with SAM. When you hit its limitations (complex logic, multi-service, reusability needs), migrate to CDK.

Don't overthink it. Both are excellent tools. Pick one, build something, ship it.


What's your experience with SAM vs CDK? Share your migration stories!