SnapStart for Python has been around for a while, yet in Python projects on Lambda I still see the same reflex when cold starts get slow: more memory, Provisioned Concurrency or a cron job that keeps the function “warm”. A built-in tool sits right next to those options. It needs one line of configuration and a bit of discipline in your code.
In this article you build a small Python function with AWS CDK, switch on SnapStart, measure the effect in CloudWatch and learn where SnapStart would otherwise quietly introduce bugs. By the end you will know whether the feature pays off for your functions and what it costs.
In short
SnapStart runs the init code of your Python function when you publish a version, freezes memory as a snapshot and starts new environments from that image. It pays off mainly with heavy imports and high load. The biggest pitfall: anything meant to be random or unique that you create in the init phase is identical in every environment after a restore.
What you will learn in this article
- How SnapStart for Python works under the hood and when it pays off
- Setting up SnapStart with AWS CDK, a version and an alias
- Using runtime hooks from
snapshot_restore_pycorrectly - Measuring cold starts before and after with Logs Insights
- Avoiding typical mistakes with randomness, IDs, connections and costs
What is SnapStart for Python?
A cold start happens when Lambda needs a new execution environment: start the MicroVM, load the runtime, import your code and run everything at module level. In Python the last part is often the most expensive. If you import pandas, pydantic or a larger framework, you often wait much longer before the handler even runs.
SnapStart moves this work forward. As soon as you publish a function version, Lambda initializes your code once, takes a snapshot of the memory and disk of the Firecracker MicroVM, encrypts it and caches it. New environments then no longer start from zero; they are restored from this snapshot instead. According to the AWS documentation this works for Python 3.12 and later, Java 11 and later and .NET 8 and later. Node.js and Ruby are not included.
With SnapStart, the init phase moves to publishing the version.
The animated diagram shows the flow from deployment to the first invocation. Above all, the step in the middle matters: a snapshot always belongs to exactly one published version. New code means a new version and therefore a new snapshot.
🎬 Lifecycle of SnapStart for Python – this animated diagram only runs on my site: watch it on sascha-ms.de
When does SnapStart for Python pay off?
The honest answer: not always. SnapStart helps where the init phase dominates the cold start. A lean function that only loads json and boto3 gains little, because restoring and loading the runtime take time as well. A function that validates data models, compiles templates or loads a small model from S3 can benefit a lot. AWS also points out that SnapStart works best at scale; rarely invoked functions often do not see the same improvements.
Before you switch on SnapStart, it is therefore worth looking at the simple levers: fewer dependencies, late imports for rarely used paths, more memory (and therefore more CPU). For TypeScript I collected these levers in the guide to reducing Lambda cold starts, and most of them apply to Python just as well. I described how SnapStart works for Java in SnapStart for Spring Boot.
SnapStart or Provisioned Concurrency?
Both features fight cold starts, but in different ways. Provisioned Concurrency keeps a fixed number of environments ready all the time, while SnapStart makes every new environment faster. By the way, you cannot combine them.
| Criterion | SnapStart | Provisioned Concurrency |
|---|---|---|
| Principle | Restore from snapshot when scaling | Pre-warmed environments, always ready |
| Cold start | Shorter, but still there (restore) | Practically none with enough reserve |
| Cost model | Cache per version plus a fee per restore | Hourly price per provisioned environment |
| Good for | Fluctuating load, heavy init phase | Predictable peaks, strict latency goals |
| Configuration | One property plus version/alias | Plan capacity, often on a schedule |
If your load is predictable, scheduled Provisioned Concurrency can be the better choice. The article Scheduled Provisioned Concurrency shows how.
What SnapStart for Python costs
For Java, SnapStart comes at no extra charge. For Python and .NET, AWS bills two additional items: caching the snapshot per published version (based on allocated memory, with a minimum of three hours) and every restore from the snapshot. For example, the price list for US East (N. Virginia) shows USD 0.0000015046 per GB-second of cache and USD 0.0001397998 per GB restored. Other Regions can differ, so check the pricing page for your Region.
An example with my own assumptions: a function with 1 GB of memory, one active version, 30 days. The cache costs 2,592,000 GB-seconds × $0.0000015046 ≈ $3.90. With 20,000 restores per month you add 20,000 × 1 GB × $0.0001397998 ≈ $2.80. In total roughly $6.70 per month, on top of the normal cost for requests and duration. You can work those out easily with the Lambda cost calculator.
The real cost trap hides in the words “per version”. In fact, the cache is billed for every published version with SnapStart as long as it is active. The documentation describes automatic deletion after 14 days without invocations only for Java. So clean up old versions actively; more on that in the typical mistakes.
Prerequisites
- An AWS account and the AWS CLI v2 with credentials for a test environment
- Node.js 22 or later for AWS CDK and a one-time
cdk bootstrapin the target Region - Python 3.12 or later locally for the tests (the function runs on
python3.14) - A Region with SnapStart support; according to the documentation, all commercial Regions except Asia Pacific (New Zealand) and Asia Pacific (Taipei)
The example is deliberately small: a fictional city library that serves reading recommendations per genre and leaves out books a member has already borrowed. The catalog is a JSON file next to the code, the loans live in DynamoDB.
Step by step: SnapStart for Python with AWS CDK
1. Create the project
Create the folder structure and install the tools. For the tests you need boto3, pytest and the snapshot-restore-py library locally. The library already ships with the Lambda Python runtime, so you do not need to bundle it.
mkdir -p snapstart-python/{src/recommendations,tests,infra/bin,infra/lib} cd snapstart-python python3 -m venv .venv && source .venv/bin/activate pip install boto3 pytest snapshot-restore-py cd infra npm init -y npm install aws-cdk-lib constructs npm install -D aws-cdk typescript tsx @types/node Also create the file src/recommendations/catalog.json with a few books. Each entry has isbn, title, genre and loans (number of loans). All titles are made up.
[ {"isbn": "978-3-00-000001-1", "title": "Fog over the Harbour", "genre": "crime", "loans": 412}, {"isbn": "978-3-00-000002-8", "title": "The Silent Witness", "genre": "crime", "loans": 377}, {"isbn": "978-3-00-000003-5", "title": "Death in the Reading Room", "genre": "crime", "loans": 298}, {"isbn": "978-3-00-000004-2", "title": "The Last Ferry", "genre": "crime", "loans": 251}, {"isbn": "978-3-00-000005-9", "title": "Stars over Cassiopeia", "genre": "scifi", "loans": 389}, {"isbn": "978-3-00-000006-6", "title": "Signal from Nowhere", "genre": "scifi", "loans": 344}, {"isbn": "978-3-00-000007-3", "title": "The Colony at the Edge", "genre": "scifi", "loans": 205}, {"isbn": "978-3-00-000008-0", "title": "Baking Bread for Beginners", "genre": "nonfiction", "loans": 466}, {"isbn": "978-3-00-000009-7", "title": "Balcony Gardening", "genre": "nonfiction", "loans": 318}, {"isbn": "978-3-00-000010-3", "title": "Fix Your Own Bike", "genre": "nonfiction", "loans": 290} ] 2. The handler with runtime hooks
Now comes the most important part. Everything at module level runs in the init phase and ends up in the snapshot: the DynamoDB client, the loaded catalog and the precomputed genre index. That is exactly what we want. The ID of the execution environment, on the other hand, is only created after the restore; otherwise all environments would share it.
# src/recommendations/handler.py """Reading recommendations for a fictional city library – example for Lambda SnapStart with Python.""" import json import os import random import time import uuid from pathlib import Path import boto3 from snapshot_restore_py import register_after_restore, register_before_snapshot MAX_LIMIT = 10 def build_genre_index(books: list[dict]) -> dict[str, list[dict]]: """Groups the catalog by genre, most popular titles first.""" index: dict[str, list[dict]] = {} for book in sorted(books, key=lambda b: b["loans"], reverse=True): index.setdefault(book["genre"], []).append(book) return index # --- Init phase: runs once when the version is published and ends up in the snapshot --- dynamodb = boto3.resource("dynamodb") loans_table = dynamodb.Table(os.environ.get("LOANS_TABLE", "library-loans")) CATALOG = json.loads((Path(__file__).parent / "catalog.json").read_text(encoding="utf-8")) GENRE_INDEX = build_genre_index(CATALOG) # Secure randomness: reads OS entropy, which is fresh after a restore secure_rng = random.SystemRandom() # Unique per execution environment – so do NOT create it here in the init phase environment_id: str | None = None restored_at: float | None = None def recommend(genre: str, limit: int) -> list[dict]: """Returns the most popular titles of a genre.""" return [dict(book) for book in GENRE_INDEX.get(genre, [])[:limit]] def loaned_isbns(member_id: str) -> set[str]: """Reads the ISBNs the member has already borrowed.""" response = loans_table.query( KeyConditionExpression="memberId = :m", ExpressionAttributeValues={":m": member_id}, ProjectionExpression="isbn", ) return {item["isbn"] for item in response.get("Items", [])} @register_before_snapshot def warm_up() -> None: """Runs before the snapshot: exercise code paths once, without side effects.""" for genre in GENRE_INDEX: json.dumps(recommend(genre, MAX_LIMIT), ensure_ascii=False) @register_after_restore def refresh_after_restore() -> None: """Runs after every restore: recreate everything that must be unique.""" global environment_id, restored_at environment_id = str(uuid.uuid4()) restored_at = time.time() def parse_limit(raw: str | None) -> int: try: value = int(raw) if raw else 3 except ValueError: value = 3 return max(1, min(value, MAX_LIMIT)) def handler(event: dict, context) -> dict: global environment_id if environment_id is None: # Fallback for $LATEST without SnapStart (e.g. local tests) environment_id = str(uuid.uuid4()) params = event.get("queryStringParameters") or {} genre = (params.get("genre") or "crime").lower() limit = parse_limit(params.get("limit")) member_id = params.get("member") already_read = loaned_isbns(member_id) if member_id else set() candidates = [b for b in recommend(genre, MAX_LIMIT) if b["isbn"] not in already_read][:limit] secure_rng.shuffle(candidates) return { "statusCode": 200, "headers": {"Content-Type": "application/json; charset=utf-8"}, "body": json.dumps( {"genre": genre, "items": candidates, "environmentId": environment_id}, ensure_ascii=False, ), } Two decorators from snapshot_restore_py do the work. @register_before_snapshot runs once before Lambda takes the snapshot. Here, warm_up() runs through the recommendation logic so Python has executed all code paths once. The hook deliberately has no side effects: no database access, no order, no email. @register_after_restore runs after every restore and creates a fresh environment ID.
For randomness the handler uses random.SystemRandom(). This is a cryptographically secure generator that reads from operating system entropy. That entropy is fresh after a restore, whereas the state of the regular random module is stuck in the snapshot.
3. Write and run the tests
Because the decorators return the functions unchanged, you can call the hooks directly in tests. The DynamoDB access is replaced with unittest.mock, so the tests run without AWS access. The article Lambda unit tests with Vitest shows how to test Lambda handlers in general, using TypeScript.
# tests/test_handler.py import json import os import sys from pathlib import Path from unittest.mock import patch os.environ.setdefault("AWS_DEFAULT_REGION", "eu-central-1") sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src" / "recommendations")) import handler # noqa: E402 def call(params): return json.loads(handler.handler({"queryStringParameters": params}, None)["body"]) def test_recommends_most_popular_titles_of_a_genre(): body = call({"genre": "Crime", "limit": "2"}) assert body["genre"] == "crime" assert {b["title"] for b in body["items"]} == {"Fog over the Harbour", "The Silent Witness"} def test_limit_is_clamped(): assert len(call({"genre": "crime", "limit": "500"})["items"]) == 4 assert len(call({"genre": "crime", "limit": "abc"})["items"]) == 3 def test_skips_books_the_member_has_already_borrowed(): with patch.object(handler.loans_table, "query", return_value={"Items": [{"isbn": "978-3-00-000001-1"}]}): body = call({"genre": "crime", "limit": "2", "member": "M-1001"}) assert "Fog over the Harbour" not in {b["title"] for b in body["items"]} assert len(body["items"]) == 2 def test_warm_up_has_no_side_effects(): with patch.object(handler.loans_table, "query") as query: handler.warm_up() query.assert_not_called() def test_after_restore_creates_a_new_environment_id(): handler.refresh_after_restore() first = handler.environment_id handler.refresh_after_restore() assert handler.environment_id != first assert handler.restored_at is not None python -m pytest -q Expected result: 5 passed. If importing snapshot_restore_py fails, the library is missing from your virtual environment.
4. The stack with SnapStart and an alias
In the CDK stack, the property snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS switches the feature on. That alone is not enough, though, because SnapStart only affects published versions. That is why currentVersion creates a new version on every change, and the live alias always points to it.
// infra/lib/recommendations-stack.ts import * as path from 'node:path'; import { Annotations, CfnOutput, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as logs from 'aws-cdk-lib/aws-logs'; import { Construct } from 'constructs'; export class RecommendationsStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const loansTable = new dynamodb.TableV2(this, 'LoansTable', { partitionKey: { name: 'memberId', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'isbn', type: dynamodb.AttributeType.STRING }, removalPolicy: RemovalPolicy.DESTROY, // for this example only }); const recommendFn = new lambda.Function(this, 'RecommendationsFunction', { runtime: lambda.Runtime.PYTHON_3_14, handler: 'handler.handler', code: lambda.Code.fromAsset(path.join(__dirname, '../../src/recommendations')), memorySize: 1024, timeout: Duration.seconds(10), // SnapStart only applies to published versions snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS, environment: { LOANS_TABLE: loansTable.tableName }, logGroup: new logs.LogGroup(this, 'RecommendationsLogs', { retention: logs.RetentionDays.TWO_WEEKS, removalPolicy: RemovalPolicy.DESTROY, }), }); loansTable.grantReadData(recommendFn); // The warning “SnapStart only supports published versions” is handled here: // currentVersion publishes a new version on every change. Annotations.of(recommendFn).acknowledgeWarning( '@aws-cdk/aws-lambda:snapStartRequirePublish', 'Version is published via currentVersion and the live alias', ); // The alias always points to the latest published version. // Callers (API Gateway, EventBridge, CLI) must use the alias, not $LATEST. const liveAlias = Source:https://dev.to/sascha-ms/snapstart-for-python-measure-and-cut-lambda-cold-starts-1l21Related posts

