DEV Community

우병수
우병수

Posted on • Originally published at techdigestor.com

5 Serverless Frameworks I've Actually Deployed Python on AWS With (And One I Stopped Using)

TL;DR: Let me paint a picture you've probably lived through. You write a Python Lambda that works perfectly on your machine.

📖 Reading time: ~32 min

What's in this article

  1. The Real Problem: Deploying Python Lambdas Without Losing Your Mind
  2. Quick Comparison: The 30-Second Version
  3. 1. AWS SAM (Serverless Application Model) — The One You Should Probably Start With
  4. 2. Serverless Framework (v3) — Still the Best Multi-Cloud Story
  5. 3. AWS Chalice — Best If You're Already Thinking in Flask
  6. 4. Zappa — The Django/Flask Lift-and-Shift Tool
  7. 5. Pulumi (Python SDK) — Infrastructure and Function Code in the Same Language
  8. Pulumi isn't a Lambda deployment tool — and that's exactly why it's on this list

The Real Problem: Deploying Python Lambdas Without Losing Your Mind

Let me paint a picture you've probably lived through. You write a Python Lambda that works perfectly on your machine. Then deployment day arrives: you zip the function, upload it via the console, realize you forgot an environment variable, go back to the console, add it, re-test, hit a permissions error because the execution role doesn't have the right policy attached, spend 40 minutes in IAM, finally get it running, then realize psycopg2 won't load because the binary you bundled was compiled on macOS and Lambda runs Amazon Linux. Start over. I've lost entire afternoons to this loop.

The raw workflow is genuinely painful. You're juggling zip archives, manually attaching IAM roles, pasting ARNs between browser tabs, and hoping your requirements.txt doesn't include anything with a C extension. Native libraries like cryptography, Pillow, and numpy will silently break at runtime if they're not built for the Lambda execution environment — and the error messages rarely tell you that's the actual problem. On top of that, the moment you need an API Gateway, a DynamoDB table, and an SQS trigger to go along with your function, you're staring down a 400-line CloudFormation template before a single line of business logic gets written.

What you actually need from a framework comes down to three things. First, local dev parity — being able to invoke your function locally with a realistic event payload before touching AWS at all. Second, dependency bundling that actually handles native libs, ideally by compiling them inside a Docker container that matches the Lambda runtime. Third, infrastructure that doesn't require you to become a CloudFormation expert just to wire up a REST endpoint. A one-command deploy that handles packaging, IAM, API Gateway config, and environment variables in one shot is the baseline expectation, not a luxury.

The five frameworks I'm covering — Serverless Framework, AWS SAM, Zappa, Chalice, and Pulumi — I've run in actual production services, not just toy projects. Each one has a different philosophy about how much it wants to hide from you. Serverless Framework gives you fine-grained control but demands YAML fluency. Chalice is the fastest path from a Flask app to a Lambda endpoint but gets out of its depth quickly. Zappa is magic until it isn't. SAM is verbose but trustworthy. Pulumi is the one I reach for when the infrastructure itself is the complex part. The rest of this guide ranks them by a single criterion: how much did they stay out of my way when all I wanted to do was ship Python code.

For context on the tooling ecosystem around these frameworks — particularly if you're using AI-assisted coding to generate your handler functions, event schemas, or IAM policy JSON — check out the Best AI Coding Tools in 2026 (thorough Guide). A good AI coding tool paired with a solid deploy framework cuts the feedback loop from "idea" to "running in Lambda" down to something that doesn't make you want to quit and go do Django on a VPS.

One thing to call out before diving in: none of these frameworks eliminate the need to understand IAM basics. Every one of them will eventually fail silently because a permission is missing, and if you don't know how to read a CloudTrail deny event or construct a least-privilege policy, you'll be stuck regardless of which tool you're using. The frameworks handle the scaffolding — the fundamentals are still on you.

Quick Comparison: The 30-Second Version

Here's the table first, then I'll tell you where each framework will actually hurt you:

Framework

Config Language

Cold Start Overhead

Local Dev Support

AWS-Specific or Agnostic

Learning Curve

Serverless Framework

YAML

Minimal — thin wrapper

Good (serverless-offline)

Agnostic (AWS best supported)

Low–Medium

AWS SAM

YAML (CloudFormation dialect)

Minimal — it's just CloudFormation

Strong (sam local invoke)

AWS-only, full stop

Medium (CloudFormation tax)

AWS CDK

Python / TypeScript / etc.

Minimal — synthesizes to CF

Weak (no built-in local runner)

AWS-only

Medium–High

Zappa

JSON / zappa_settings.json

Higher — bigger packages by default

Poor (limited local emulation)

AWS-only

Low (if you know Django/Flask)

Chalice

Python decorators + JSON config

Minimal — very lean packages

Good (chalice local)

AWS-only (opinionated)

Low

Now for the metric nobody blogs about: dependency bundling with native packages. If your requirements.txt has psycopg2, Pillow, or anything that compiles C extensions, you will hit the wall. These packages build against your local OS. If you're on a Mac and you deploy to Lambda's Amazon Linux 2 runtime, you're shipping the wrong binary. Every single framework handles this differently, and the docs bury the solution three pages deep.

  • Serverless Framework with serverless-python-requirements plugin: add dockerizePip: true to your config and it builds inside a Lambda-compatible Docker container automatically. This is the cleanest solution I've found. The catch — Docker has to be running and the first build is slow.
  • SAM: sam build --use-container does the same thing natively. It's well-documented and works reliably. This is actually one area where SAM beats everything else out of the box.
  • CDK: you need a BundlingOptions block with a Docker image. It works, but you're writing more boilerplate than you want to just to install psycopg2-binary correctly.
  • Zappa: automatically uses psycopg2-binary as a workaround, but if you need the full psycopg2 (some production setups do), you're manually dealing with a Lambda layer or a custom build script.
  • Chalice: the same Docker-based Lambda layer approach, but the documentation assumes you'll figure it out. I spent half a day on this the first time.

My honest recommendation: use psycopg2-binary in dev, switch to a proper Lambda layer with the compiled binary for production. Every framework above supports Lambda layers, and isolating your native dependencies there beats fighting bundling configs every deploy.

One more thing before you pick a tool — all five frameworks are free or open source. Serverless Framework introduced a paid cloud product (Serverless Console / Dashboard), but the core CLI is still open source under the MIT license. CDK, SAM, and Chalice are AWS open source projects with no licensing cost. Zappa is MIT-licensed community software. Your actual costs come entirely from AWS: Lambda invocations, API Gateway requests, CloudWatch logs, and whatever else your functions touch. Check each framework's own site for enterprise support tiers if your team needs that, but for most projects the bill you're watching is your AWS bill, not the framework's.

The table makes Chalice look like an obvious winner on simplicity, and for pure Lambda + API Gateway work it genuinely is. But the moment you need something outside that narrow scope — Step Functions, EventBridge rules, complex IAM — you'll feel the walls closing in. CDK looks scary from the table but if your team already writes Python, defining infrastructure in actual Python with real IDE autocomplete is genuinely better than debugging indented YAML at midnight. That tradeoff is the real story here, not the feature matrix.

1. AWS SAM (Serverless Application Model) — The One You Should Probably Start With

If you're starting a greenfield Python project on AWS Lambda, SAM is where I'd point you first — not because it's the flashiest tool, but because it's the one that fights you the least. AWS built it, which means IAM roles, API Gateway, DynamoDB streams, SQS, and SNS integrations are all expressed in the same CloudFormation dialect the platform already understands. No translation layer, no "adapter" between your config and what AWS actually deploys. What you write is what gets created.

Getting It Running

Install is straightforward:

# macOS
brew install aws-sam-cli

# or pip
pip install aws-sam-cli

# then scaffold a new project
sam init
Enter fullscreen mode Exit fullscreen mode

sam init launches an interactive prompt — pick Python 3.12, choose a starter template (hello-world works fine), and you get a working project in under a minute. The scaffold includes a template.yaml, a sample Lambda handler, and a test events folder. That last part matters more than it sounds.

What a Real template.yaml Looks Like

Here's an actual minimal setup I use: a Python Lambda that handles HTTP requests via API Gateway and also processes messages from an SQS queue, with a dead-letter queue attached:

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

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

Resources:
  OrdersFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: orders/
      Handler: app.lambda_handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref OrdersTable
        - SQSPollerPolicy:
            QueueName: !GetAtt OrdersQueue.QueueName
      Events:
        CreateOrder:
          Type: Api
          Properties:
            Path: /orders
            Method: post
        ProcessQueue:
          Type: SQS
          Properties:
            Queue: !GetAtt OrdersQueue.Arn
            BatchSize: 10

  OrdersQueue:
    Type: AWS::SQS::Queue
    Properties:
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt OrdersDLQ.Arn
        maxReceiveCount: 3

  OrdersDLQ:
    Type: AWS::SQS::Queue

  OrdersTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: order_id
        Type: String
Enter fullscreen mode Exit fullscreen mode

That's roughly 50 lines and it's already doing real work: HTTP endpoint, SQS consumer, DynamoDB table, DLQ, and scoped IAM policies via SAM's managed policy templates. Notice DynamoDBCrudPolicy and SQSPollerPolicy — those are SAM policy templates that expand into the correct IAM statements automatically. You don't have to write out Action: dynamodb:GetItem, dynamodb:PutItem... by hand.

Local Testing That Actually Works

This is the thing that made me stick with SAM. Local invocation is genuinely reliable:

# invoke with a static event file
sam local invoke OrdersFunction --event events/create_order.json

# spin up a local HTTP server that mimics API Gateway
sam local start-api
Enter fullscreen mode Exit fullscreen mode

sam local start-api starts a local server on port 3000 and routes requests through your Lambda handler exactly as API Gateway would. I've caught several Gateway-specific bugs this way — things like base64-encoded bodies, missing headers in the event object — before they ever touched a deployed environment. Some other frameworks claim local testing support but silently skip the API Gateway event wrapping. SAM gets it right because it uses the same Lambda runtime emulator AWS built for their own internal use.

The Dependency Gotcha That Will Catch You

The thing that caught me off guard the first time I added cryptography to a project: SAM's default sam build compiles dependencies on your local machine. If you're on an M2 Mac and your Lambda runtime is x86_64, you'll deploy binaries that silently fail at runtime. The fix is one flag:

sam build --use-container
Enter fullscreen mode Exit fullscreen mode

This pulls the official Lambda build image and compiles everything inside a container that matches the Lambda execution environment. It adds 30–60 seconds to build time, but it's non-negotiable if you have any package with native extensions — numpy, pandas, cryptography, psycopg2-binary, anything with a C extension. Docker has to be running locally. I now default to --use-container on every project and never think about it again.

The Real Con: YAML Bloat

I'm not going to pretend the YAML situation is fine. The template above is already 50 lines and it's a toy project. Add three more Lambda functions, an EventBridge rule, a Cognito authorizer on the API, and environment-specific parameter overrides, and you're looking at 300+ lines of YAML where a mis-indented block silently deploys the wrong config. SAM doesn't have great tooling for splitting templates across files either — you can use AWS::Include transforms to pull in fragments from S3, but it's awkward.

My honest take: SAM is the right starting point for teams that want to stay close to CloudFormation and understand exactly what's being deployed. If you find yourself fighting the YAML and wishing for something more programmatic, that's when CDK starts making sense — but CDK has its own learning curve, and SAM will get you to production faster on a first project. Reach for SAM when your team knows AWS well and wants full control. Consider something else when your infrastructure logic starts needing actual loops and conditionals.

2. Serverless Framework (v3) — Still the Best Multi-Cloud Story

The plugins ecosystem is what keeps me coming back to Serverless Framework despite newer alternatives. No other tool has serverless-python-requirements, which solves the single most annoying AWS Lambda problem — packaging Python dependencies that include native binaries (think psycopg2, cryptography, anything with C extensions). The plugin spins up a Docker container that matches the Lambda runtime, installs your requirements inside it, and zips everything correctly. You don't have to think about it. One line in your config and it just works.

Getting started is straightforward, though yes, you need Node.js installed to use a Python deployment tool — this bothers people, then they get over it:

npm install -g serverless
serverless create --template aws-python3 --path my-service
cd my-service
npm install --save-dev serverless-python-requirements
Enter fullscreen mode Exit fullscreen mode

Here's a serverless.yml that actually represents what a real production function looks like — not the stripped-down hello-world version from the docs:

service: my-api

frameworkVersion: '3'

provider:
  name: aws
  runtime: python3.11
  region: eu-west-1
  memorySize: 512
  timeout: 29
  environment:
    DB_HOST: ${ssm:/my-app/db-host}
    ENVIRONMENT: ${opt:stage, 'dev'}
  vpc:
    securityGroupIds:
      - sg-0abc123def456789a
    subnetIds:
      - subnet-0123456789abcdef0
      - subnet-abcdef0123456789a
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - ssm:GetParameter
          Resource: "arn:aws:ssm:eu-west-1:*:parameter/my-app/*"

plugins:
  - serverless-python-requirements
  - serverless-offline

custom:
  pythonRequirements:
    dockerizePip: true
    slim: true
    strip: false

functions:
  api:
    handler: handler.main
    layers:
      - arn:aws:lambda:eu-west-1:123456789012:layer:my-shared-layer:4
    events:
      - httpApi:
          path: /users/{id}
          method: GET
Enter fullscreen mode Exit fullscreen mode

The thing that changed my local development workflow was serverless-offline. Run serverless offline --reloadHandler and you get a local HTTP server that reloads your handler on every file change without restarting the whole server. This flag specifically is underDocumented — without --reloadHandler, you have to kill and restart the process every time you touch your Python file. With it, you edit, save, hit the endpoint again. The latency difference between this and waiting for actual Lambda deploys to test is dramatic, especially when you're iterating on API logic.

The rough edge I'd warn you about upfront: v3 made the Serverless Dashboard a first-class feature, and the CLI constantly reminds you to log in, link your service, and enable their monitoring. The output is genuinely noisy about it. You'll see prompts on deploy, on invocation, sometimes just... randomly. You can ignore all of it — the framework still works completely offline without any account — but it clutters your terminal output in CI and irritates the team until someone figures out you can set org and app in serverless.yml to suppress some of it, or just pipe through grep -v filters in your pipeline. It's a business decision baked into an open-source tool, and it shows.

When Serverless Framework beats AWS SAM

If you're deploying to both AWS and GCP or Azure — even occasionally, even just one function — Serverless Framework is the only framework on this list that handles it without switching tools entirely. SAM is AWS-only by design. But even in pure-AWS shops, the plugin argument is real. Need dead letter queues configured a specific way? Plugin. Need to split a monorepo into independently deployable services? serverless-compose. Need to auto-invalidate CloudFront on deploy? Plugin. Before building any non-trivial CloudFormation custom resource, search the Serverless Framework plugin registry first — the thing you need has almost certainly already been built and maintained by someone who hit the same wall.

3. AWS Chalice — Best If You're Already Thinking in Flask

If you've spent any time with Flask, the first time you open a Chalice app.py you'll do a double-take. The decorator syntax is nearly identical, and that's entirely intentional. Amazon built Chalice to lower the on-ramp for Python developers who already think in terms of routes and request handlers. Install it, scaffold a project, and you're writing Lambda functions that feel like a web app — not a YAML specification.

pip install chalice
chalice new-project myapp
cd myapp
chalice local

That last command spins up a local dev server on port 8000. No Docker, no SAM CLI, no LocalStack required. You edit code, it hot-reloads, you curl it. For a quick internal API this is genuinely fast to iterate on.

What the Code Actually Looks Like

Here's a realistic example under 40 lines — a REST endpoint, an S3 event handler, and the IAM policy that Chalice generates automatically:

from chalice import Chalice
import boto3

app = Chalice(app_name='myapp')
s3 = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('users')

@app.route('/users/{user_id}', methods=['GET'])
def get_user(user_id):
result = table.get_item(Key={'user_id': user_id})
if 'Item' not in result:
return {'error': 'User not found'}, 404
return result['Item']

@app.route('/users', methods=['POST'])
def create_user():
body = app.current_request.json_body
table.put_item(Item=body)
return {'status': 'created'}

@app.on_s3_event(bucket='my-uploads-bucket',
events=['s3:ObjectCreated:*'])
def handle_upload(event):
key = event.key
# process the uploaded file
obj = s3.get_object(Bucket='my-uploads-bucket', Key=key)
content = obj['Body'].read()
# do something with content
print(f"Processing {key}, size: {len(content)} bytes")

That @app.on_s3_event decorator wires up the S3 trigger, creates the Lambda permission, and configures the bucket notification — all from one line of Python. Compare that to the CloudFormation or Terraform you'd write for the same thing and you'll understand why Chalice has fans.

The Auto-IAM Feature Is Genuinely Underrated

This is the part I think most people skip past in the docs. Chalice inspects your source code, finds every boto3 call you make, and generates a least-privilege IAM policy from those calls. Run chalice gen-policy and it outputs the JSON it's going to use. In the example above, it sees dynamodb.Table.get_item, dynamodb.Table.put_item, and s3.get_object — so it generates a policy with exactly those three actions on exactly those resources. No wildcard s3:*, no dynamodb:*. I've seen teams spend two days on least-privilege Lambda IAM policies manually. Chalice does it in seconds. The caveat: it only catches static boto3 usage. If you're constructing resource names dynamically at runtime, it won't see those calls and you'll need to supplement with a policy.json override.

The Wall I Hit

Around the third week on a project, I needed a custom Cognito authorizer on specific routes and a Lambda inside a private VPC. That's where Chalice starts fighting you. VPC config goes into .chalice/config.json:

{
"version": "2.0",
"app_name": "myapp",
"stages": {
"prod": {
"subnet_ids": ["subnet-abc123"],
"security_group_ids": ["sg-xyz789"],
"lambda_functions": {
"get_user": {
"reserved_concurrency": 10
}
}
}
}
}

That part is fine. The problem comes when you need a custom Lambda authorizer that references another Lambda function — Chalice doesn't have a clean abstraction for it. You end up generating the CloudFormation template with chalice package and hand-editing it, at which point you've largely defeated the purpose of using Chalice. WebSocket APIs aren't supported at all. If your product roadmap includes real-time features, don't start here.

Verdict: Pick Chalice for the Right Scope

Chalice is my first reach for internal data APIs, ETL triggers, and webhook receivers. The kind of thing where a senior dev can ship a working Lambda-backed API in an afternoon. The auto-IAM generation alone saves real time and prevents the "I'll just add * for now" mistake that haunts security reviews later. But if you need custom authorizers, WebSocket support, or you're building something that other teams will extend in unpredictable directions, use AWS SAM or CDK from the start. Chalice's escape hatch is manual CloudFormation editing — and the moment you're doing that regularly, you've outgrown the tool.

4. Zappa — The Django/Flask Lift-and-Shift Tool

I migrated a Django admin panel to Lambda in 4 hours using Zappa. The same job using raw SAM or Serverless Framework would have taken two days minimum — because I would have had to decompose every view into individual Lambda functions and rebuild the routing logic. Zappa's entire value proposition is that it doesn't ask you to do that. It wraps your existing WSGI app in a Lambda-compatible shim, wires up API Gateway in front of it, and calls it a day. If you already have a working Django or Flask app, that is genuinely powerful.

The setup is about as frictionless as it gets for a serverless tool. Install the package, run the interactive init, deploy:

pip install zappa
zappa init
zappa deploy production
Enter fullscreen mode Exit fullscreen mode

zappa init asks you a handful of questions — S3 bucket for deployment artifacts, AWS region, your Django settings module — and writes a zappa_settings.json for you. Here's a real config I've used for a Django app with an RDS Postgres backend and S3-hosted static files:

{
  "production": {
    "django_settings": "myproject.settings.production",
    "s3_bucket": "my-zappa-deployments-prod",
    "aws_region": "us-east-1",
    "runtime": "python3.10",
    "timeout_seconds": 30,
    "memory_size": 512,
    "environment_variables": {
      "DATABASE_URL": "postgres://user:pass@my-rds-endpoint.rds.amazonaws.com:5432/mydb",
      "DJANGO_SECRET_KEY": "your-secret-key-here",
      "AWS_STORAGE_BUCKET_NAME": "my-static-files-bucket"
    },
    "vpc_config": {
      "SubnetIds": ["subnet-xxxxxxxx", "subnet-yyyyyyyy"],
      "SecurityGroupIds": ["sg-xxxxxxxx"]
    },
    "keep_warm": false,
    "binary_support": true,
    "cors": false
  }
}
Enter fullscreen mode Exit fullscreen mode

The vpc_config block is mandatory if your RDS instance lives inside a VPC — which it should. The thing that caught me off guard the first time was that Lambda functions outside a VPC can't reach RDS instances that are inside one. You also need to make sure your Lambda's security group has outbound access to the RDS security group on port 5432. Zappa doesn't tell you this clearly; you find out when your app deploys fine but every database query times out.

Now for the honest problem: cold starts with a full Django app regularly hit 3–5 seconds on the first request after the function has been idle. This isn't Zappa's fault — it's a fundamental Lambda constraint with large deployment packages. A Django app with its dependencies can easily produce a 50MB+ package, and Lambda has to unzip, initialize, and run your app's startup code from scratch. Zappa won't help you mitigate this. There's no built-in provisioned concurrency support, no guidance on trimming your dependency tree, no tree-shaking. The keep_warm flag used to help by pinging the function on a schedule, but AWS deprecated the CloudWatch Events approach it relied on. You can set up your own EventBridge ping, but at that point you're doing manual plumbing that other frameworks handle better. For a Django admin panel that internal users hit during business hours, I could live with it. For a customer-facing API endpoint, those cold start numbers would be a blocker.

Before you build anything serious on Zappa, check the GitHub repo. Activity has slowed considerably. Python 3.11 and 3.12 packaging issues sat open for months, and some are still unresolved depending on your dependency stack. The project is community-maintained at this point, not backed by a vendor with skin in the game. That's not a dealbreaker for simple use cases, but I wouldn't stake a production system with aggressive SLAs on it today. The cleaner modern alternative is Mangum — an ASGI adapter — paired with FastAPI and SAM or the Serverless Framework. Mangum is actively maintained, works cleanly with Python 3.12, and FastAPI's startup time is a fraction of Django's. If you're starting a new project rather than migrating an existing WSGI app, skip Zappa entirely and start there. Zappa's sweet spot is exactly one scenario: you have a working Django or Flask app, you need it on Lambda fast, and you're not willing to refactor it right now.

5. Pulumi (Python SDK) — Infrastructure and Function Code in the Same Language

Pulumi isn't a Lambda deployment tool — and that's exactly why it's on this list

Every other framework here wraps your Lambda deployment in some opinionated abstraction. Pulumi does something different: it lets you define your entire AWS infrastructure using real Python — actual classes, loops, conditionals, type hints, and IDE autocomplete. The first time I opened a Pulumi stack and realized I could for-loop over a list of queues instead of copy-pasting 80 lines of YAML, I understood why people switch to it and don't look back.

Getting Started

Install is a single command:

curl -fsSL https://get.pulumi.com | sh
Enter fullscreen mode Exit fullscreen mode

Then scaffold a new AWS Python project:

pulumi new aws-python
Enter fullscreen mode Exit fullscreen mode

This drops you into a __main__.py file where your infrastructure is just Python code. No template syntax. No !Sub intrinsic functions. No indentation errors that only surface at deploy time.

A Real Example: Lambda + SQS + EventSourceMapping

Here's what defining a Lambda wired to an SQS queue actually looks like — no YAML involved:

import pulumi
import pulumi_aws as aws

# SQS Queue
queue = aws.sqs.Queue(
    "my-job-queue",
    visibility_timeout_seconds=300,
)

# IAM Role for Lambda
role = aws.iam.Role(
    "lambda-exec-role",
    assume_role_policy="""{
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": {"Service": "lambda.amazonaws.com"},
            "Action": "sts:AssumeRole"
        }]
    }""",
)

aws.iam.RolePolicyAttachment(
    "lambda-sqs-policy",
    role=role.name,
    policy_arn="arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole",
)

# Lambda Function
fn = aws.lambda_.Function(
    "job-processor",
    runtime=aws.lambda_.Runtime.PYTHON3D12,
    code=pulumi.AssetArchive({".": pulumi.FileArchive("./app")}),
    handler="handler.main",
    role=role.arn,
    timeout=300,
    environment=aws.lambda_.FunctionEnvironmentArgs(
        variables={"QUEUE_URL": queue.url},
    ),
)

# Wire SQS → Lambda
aws.lambda_.EventSourceMapping(
    "sqs-trigger",
    event_source_arn=queue.arn,
    function_name=fn.arn,
    batch_size=10,
)
Enter fullscreen mode Exit fullscreen mode

The thing that caught me off guard initially was how pulumi_aws handles resource dependencies automatically. Because fn references role.arn (a Pulumi Output[str], not a plain string), Pulumi builds the dependency graph for you. No DependsOn declarations, no !Ref gymnastics.

Why I Added It to My Stack

I hit the tipping point on a project involving Lambda + RDS Proxy + Secrets Manager + a VPC with private subnets + four IAM roles with different trust policies. At that complexity level, debugging CloudFormation YAML is genuinely miserable — you're staring at a 600-line template trying to figure out which !GetAtt you typo'd. With Pulumi, my IDE flags type errors before I even deploy. I can write a helper function that creates a standard "Lambda-with-VPC-config" resource bundle and call it three times. That kind of reuse is impossible in pure YAML.

The Costs You Need to Know Before Committing

Pulumi has a steeper learning curve than SAM. You need to understand the concept of Output[T] — values that are only resolved after deployment — and how to work with them using .apply(). New Pulumi users almost always hit a wall here when they try to do print(queue.url) and get back a Pulumi Output object instead of a string. It's not hard to learn, but it's a concept SAM doesn't ask you to think about at all.

The state backend question also needs an answer before you go to production. Pulumi stores stack state somewhere — either their hosted service (Pulumi Cloud, free tier covers individual developers; check pulumi.com/pricing for current limits on team features) or self-managed in an S3 bucket. The S3 approach works fine, but you need to set it up intentionally:

pulumi login s3://your-state-bucket
Enter fullscreen mode Exit fullscreen mode

If you forget to configure this and a teammate runs pulumi up pointing at the wrong state file, you'll have a bad afternoon.

When NOT to Use Pulumi

Solo developer, straightforward project, one or two Lambda functions, no complex networking? Skip it. SAM will get you deployed in 20 minutes and Pulumi won't add anything except overhead. The IaC-as-Python tradeoff only pays off when your infrastructure is complex enough that managing it in markup is actively slowing you down. If you're not there yet, you'll know when you get there — it's usually the third time you've copy-pasted the same IAM role block into a YAML template and changed two characters.

When to Pick What: Match the Tool to Your Situation

Match the Tool to Your Situation

The honest answer is that four of these five frameworks can technically do the same job. The real question is how much friction you want to absorb upfront versus later. I've watched teams pick Pulumi on day one for a two-Lambda project and spend a week writing infrastructure code before deploying anything. I've also watched teams ship a Zappa app in an afternoon and then spend three weeks fighting cold starts in production. The framework you pick isn't just a technical decision — it's a bet on what pain you're willing to deal with.

Starting Fresh? Start with AWS SAM

If you're spinning up a new Python Lambda project from zero, AWS SAM is the right default. Not because it's the most powerful tool in this list — it isn't — but because the local testing actually works reliably. Run sam local invoke and you get a real Lambda execution environment locally via Docker. Run sam local start-api and you get a local HTTP endpoint that behaves like API Gateway. That alone saves hours of the deploy-check-logs-fix-redeploy loop that kills productivity in the first week of a new Lambda project.

sam init --runtime python3.12 --name my-api
cd my-api
sam build
sam local start-api
Enter fullscreen mode Exit fullscreen mode

The AWS docs for SAM are genuinely good — not "good for AWS docs" good, actually good. And when something breaks, you'll find StackOverflow answers and GitHub issues that are less than two years old, which is more than I can say for some of the others here.

You Have an Existing Flask or Django App → Zappa, but Eyes Open

Zappa's value proposition is blunt: point it at your existing WSGI app, run zappa deploy production, and you're serverless. For a Django app this often works on the first or second try. The gotcha that nobody tells you upfront is cold starts. A full Django app with a large dependency tree can hit 3–5 second cold starts if you're not on a provisioned concurrency plan. That's fine for an internal admin panel used during business hours. It's not fine for a customer-facing checkout flow.

My actual recommendation: deploy it, put a load test on it for 30 minutes, look at your p95 cold start latency in CloudWatch, and then decide whether you want to refactor into smaller Lambdas or pay for provisioned concurrency. Don't pre-optimize. Zappa gives you the fastest path from "existing app" to "running on Lambda" — use that to validate whether Lambda even makes sense for your workload before you commit to a refactor.

Internal APIs with Flask-Like Syntax → Chalice

Chalice is the one I recommend least often publicly but use more than I admit. If you're building an internal API — something used by your own frontend, your own CLI tools, your own data pipelines — and you want Flask-like routing with zero configuration overhead, Chalice is genuinely pleasant to work with. The decorator syntax is clean:

from chalice import Chalice
app = Chalice(app_name='internal-api')

@app.route('/reports/{report_id}')
def get_report(report_id):
    return {'id': report_id, 'data': fetch_report(report_id)}
Enter fullscreen mode Exit fullscreen mode

The limits bite you when clients are external and unpredictable. Chalice has opinions about request/response shapes, and it auto-generates IAM policies that can be too permissive or too restrictive depending on how complex your boto3 calls are. Because you control all the clients on an internal API, you can work within those constraints without it ever becoming a problem.

You Need a Specific Plugin or Multi-Cloud → Serverless Framework v3

The Serverless Framework's plugin ecosystem is the thing SAM and Chalice can't match. If someone on your team says "we need serverless-offline" or "we're deploying to both AWS and GCP," that's your signal. The one thing you will absolutely need to configure manually for Python is serverless-python-requirements. Don't skip this — without it, your node_modules analogy breaks down and you end up either bloating your deployment package or missing dependencies at runtime.

plugins:
  - serverless-python-requirements

custom:
  pythonRequirements:
    dockerizePip: true
    slim: true
    strip: false
Enter fullscreen mode Exit fullscreen mode

Set dockerizePip: true from the start, even if you think you don't need it. I've been burned more than once by cryptography or psycopg2 compiling fine locally on a Mac and then exploding in Lambda's Amazon Linux 2 environment.

Complex Infrastructure with 10+ Interacting Resources → Pulumi

YAML infrastructure definition breaks down in a specific, predictable way: when you have a Lambda that needs to know the ARN of an SQS queue, which triggers another Lambda, which writes to a DynamoDB table with a specific GSI, which feeds a Step Functions workflow — at that point your CloudFormation or serverless.yml becomes a maze of !Ref and !GetAtt that nobody can read after a month. Pulumi lets you express that as actual Python:

queue = aws.sqs.Queue("process-queue", visibility_timeout_seconds=300)

fn = aws.lambda_.Function("processor",
    runtime="python3.12",
    environment=aws.lambda_.FunctionEnvironmentArgs(
        variables={"QUEUE_URL": queue.url}
    )
)
Enter fullscreen mode Exit fullscreen mode

The investment is real — you're writing more code upfront, and the Pulumi state backend adds operational overhead. But once you cross roughly 10 resources that need to reference each other, the YAML alternative starts producing subtle bugs where a resource update doesn't propagate correctly and you spend an afternoon debugging infrastructure instead of your actual application.

The One Thing None of Them Solve: Compiled C Extensions on arm64

Pick any framework on this list and you'll hit the same wall if you use libraries like numpy, Pillow, cryptography, or anything that wraps a C extension: packaging for arm64 Lambda requires building on arm64 Linux. Your M2 MacBook is arm64 but it's not Amazon Linux. Your CI runner is probably x86. This is not a framework problem — it's a Python/Lambda problem that every framework punts on.

The only reliable fix I've found is building inside a Docker container that matches the Lambda execution environment:

docker run --rm \
  -v "$PWD":/var/task \
  -w /var/task \
  public.ecr.aws/lambda/python:3.12-arm64 \
  pip install -r requirements.txt -t package/ --platform manylinux2014_aarch64 \
  --implementation cp --only-binary=:all: --upgrade
Enter fullscreen mode Exit fullscreen mode

Add this to your build pipeline before you even think about which framework to use. Every framework here either has a flag to enable Docker-based packaging or accepts a pre-built layer/package directory. Sort this out on day one and it's a non-issue. Discover it in week three and you'll be rewriting your build pipeline under deadline pressure.

Gotchas Nobody Writes About

The 250MB Lambda deployment package limit sounds generous until you actually try to ship a data science workload. NumPy alone is around 70MB uncompressed. Add pandas and you're already past 100MB. Throw in scipy, and you're looking at 180–200MB before your actual application code even enters the picture. I've seen teams spend two days debugging a cryptic deployment error that was just a bloated package. The fix isn't switching frameworks — none of the five frameworks magically compress your dependencies. Lambda Layers are the actual solution. Extract your heavy scientific dependencies into a separate layer, publish it once, and reference it across functions. Here's the practical pattern:

# Build your layer locally (match the runtime exactly — more on that below)
pip install numpy pandas scipy -t python/lib/python3.12/site-packages/
zip -r scientific-layer.zip python/
aws lambda publish-layer-version \
  --layer-name scientific-deps \
  --zip-file fileb://scientific-layer.zip \
  --compatible-runtimes python3.12
Enter fullscreen mode Exit fullscreen mode

Then reference that layer ARN in whichever framework config you're using. SAM uses Layers under your function resource. Serverless Framework uses a layers key. The framework is just plumbing here — the constraint is Lambda's architecture.

IAM Behavior Is Completely Different Across All Five

This is the one that will hurt you in production if you don't understand it upfront. Chalice auto-generates IAM policies by analyzing your code — if you call s3.get_object, Chalice figures that out and adds the permission. That sounds convenient, and it is, until you're doing something dynamic where the static analysis misses a resource. SAM puts IAM squarely on you: you write the Policies block in your template.yaml or use SAM policy templates. Serverless Framework defaults to a single shared role for all functions, which is a security smell for anything beyond toy projects — the serverless-iam-roles-per-function plugin fixes this and should be the first plugin you add to any real project. Zappa's execute_via_sns setting changes how async tasks execute and affects what IAM trust relationships you need. Pulumi is just Python, which means your IAM is explicit Python objects — verbose but completely auditable. Before you deploy anything, know which model you're operating in. Chasing a permission error in production at 2am because you didn't realize Serverless was using a shared role that lacks the specific permission one function needs is not a fun experience.

# serverless.yml — add this plugin and ditch the shared role model
plugins:
  - serverless-iam-roles-per-function

functions:
  processOrder:
    handler: handler.process_order
    iamRoleStatements:
      - Effect: Allow
        Action:
          - dynamodb:PutItem
        Resource: !GetAtt OrdersTable.Arn
Enter fullscreen mode Exit fullscreen mode

Python 3.12 on Lambda Is Not the Same as 3.12 on Your MacBook

The packaging differences between Python 3.11 and 3.12 on the Lambda execution environment are subtle but real. Some C extension compilation behaviors changed. The way certain packages handle shared library bundling shifted. I've had builds that passed locally and in CI targeting 3.11 fail silently in the 3.12 runtime — not crash loudly, just produce wrong output from a binary dependency that wasn't compiled for the right environment. The fix is boring but non-negotiable: your CI pipeline needs to build against the exact runtime using the official AWS base image.

# In your CI pipeline (GitHub Actions example)
- name: Build dependencies
  run: |
    docker run --rm \
      -v "$PWD":/var/task \
      public.ecr.aws/lambda/python:3.12 \
      pip install -r requirements.txt -t package/
Enter fullscreen mode Exit fullscreen mode

Do not test locally with whatever Python version Homebrew or pyenv handed you. The public.ecr.aws/lambda/python:3.12 image is what Lambda actually runs. Use it in CI. This applies equally across all five frameworks — none of them protect you from a runtime mismatch.

Environment Variable Management Will Fragment Your Team Fast

Each framework has its own pattern for local environment variable injection, and if you don't pick one and document it explicitly, your team will invent three different patterns within a month. SAM uses env.json files passed with --env-vars during local invoke. Serverless Framework uses .env files with the serverless-dotenv-plugin. Chalice bakes environment config into .chalice/config.json under each stage key. Here's what each looks like in practice:

  • SAM: sam local invoke MyFunction --env-vars env.json where env.json contains {"MyFunction": {"DB_URL": "..."}}
  • Serverless + dotenv plugin: add useDotenv: true to your serverless.yml, then maintain .env.dev, .env.prod etc.
  • Chalice: under .chalice/config.json, set "stages": {"dev": {"environment_variables": {"DB_URL": "..."}}}
  • Pulumi: use pulumi config set --secret DB_URL value and reference with pulumi.Config().require_secret("DB_URL")

Pick the framework's native approach, write it down in your README with a concrete example, and make it part of onboarding. The hidden cost isn't the config syntax — it's the three hours a new developer spends figuring out why their local environment isn't loading secrets when they joined a project mid-sprint and nobody told them which pattern the team settled on.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.

Top comments (0)