Cleaning up old Lambda versions is one of those chores nobody thinks about until a deployment suddenly fails with an error or the SnapStart bill turns out higher than expected. Every deployment with the Serverless Framework publishes a new version of every function by default, and none of them goes away on its own. After a few hundred deployments, a lot of old code sits around that nobody calls anymore.
Two things put this topic on my desk right now. The Serverless Framework v4 now ships with a built-in prune command that used to require a plugin. On top of that, Lambda can now read your code directly from your own S3 bucket instead of copying it into Lambda-managed storage. Both change how I plan cleanup today. In this article I show you three ways to do it, from a one-liner in serverless.yml to your own cleanup function for everything that isn't deployed with the framework.
In short
Old Lambda versions eat into your storage quota, and with SnapStart for Python and .NET they also cost money. In Serverless Framework v4,
custom.prunewithautomatic: trueis enough; for functions from CDK or the console, a small cleanup Lambda does the job. The biggest pitfall is the new S3 reference mode: if you delete a code object that a version still needs, the function only breaks at the next cold start.
What you will learn in this article
- Why Lambda versions pile up and what they cost you
- How to measure used code storage with the AWS CLI
- How to clean up old Lambda versions with serverless prune after every deploy
- A cleanup Lambda in TypeScript that protects aliases and canary versions
- What to watch out for when deleting in S3 reference mode
Why Lambda versions pile up
A Lambda version is a frozen snapshot of your function: code and configuration, immutable, with an incrementing number. Next to it sits $LATEST, the editable working copy. An alias such as live is a named pointer to a specific version and can split traffic across two versions if needed, for example for a canary release.
According to its documentation, the Serverless Framework creates a new version on every deploy. It never deletes them by itself, because it can't know whether someone still calls an old version directly. AWS CDK behaves similarly as soon as you use fn.currentVersion for aliases. With ten functions and five deployments a day, you quickly end up with more than a thousand versions per month.
What old versions cost you
First, storage: every version counts against your account's Lambda-managed code storage. The current quotas page lists 300 GB per Region and explicitly says it can't be increased. Older posts, including the AWS blog post announcing S3 reference mode, still mention 75 GB. Once you hit the limit, new deployments fail with CodeStorageExceededException. That typically happens exactly when you urgently need to ship a fix.
Second, money, and this one is easy to miss: with SnapStart for Python and .NET you pay for caching the snapshot as long as the version is active, for at least three hours. So every old version with SnapStart keeps costing you, even if nobody ever calls it. Java isn't affected by this pricing model. I covered how to set up SnapStart for Python in SnapStart for Python.
Third, clarity: a console with 400 versions per function helps nobody, and tools that list versions get noticeably slower.
🎬 Animation: prune removes old Lambda versions – this animated diagram only runs on my site: watch it on sascha-ms.de
How much storage do your Lambda versions use?
Before you delete anything, check where you stand. The AWS CLI gives you the total for the whole Region:
aws lambda get-account-settings --region eu-central-1 \ --query 'AccountUsage.{TotalCodeSize: TotalCodeSize, FunctionCount: FunctionCount}' TotalCodeSize is the used storage in bytes, FunctionCount the number of functions. Looking at a single function is more interesting. The following command lists every version with its size:
aws lambda list-versions-by-function \ --function-name buchladen-dev-bestellungen \ --query 'Versions[].[Version, CodeSize, LastModified]' \ --output table If you see long lists with identical CodeSize values, you've found your candidate. For a quick overview across all functions, the dashboard in the Lambda console also shows the used code storage.
Clean up Lambda versions with serverless prune
For everything you deploy with Serverless Framework v4, this is the easiest way. You used to need the community plugin serverless-prune-plugin by Clay Gregory; according to the docs, the feature is now built in. In the release notes, the command shows up around version 4.40, so update to a recent 4.x release first.
Step 1: Check the prerequisites
You need Serverless Framework v4, AWS credentials that allow listing and deleting Lambda versions, and an existing project. Check your version with serverless --version. If you're still on v3, my guide Migrate to Serverless Framework v4 will help.
Step 2: Dry run
First, let the framework show you what would happen without deleting anything:
serverless prune -n 3 --dryRun --verbose --stage dev --region eu-central-1 The output lists the versions per function that would be removed. Versions an alias points to never show up, no matter how old they are. With -n 3, the three newest versions stay.
Step 3: Automatically after every deploy
If the result looks right, add the rule to your serverless.yml for good:
# serverless.yml (excerpt) custom: prune: automatic: true # clean up after every deploy number: 3 # keep the three newest versions includeLayers: true # prune layer versions, too From now on, the framework cleans up after every successful deploy. If IAM permissions are missing, the docs say it logs a warning and the deploy itself still counts as successful. So check the deploy log after the first run. If you deploy through a pipeline, for example as in Serverless Framework with GitHub Actions via OIDC, the pipeline role needs lambda:ListVersionsByFunction, lambda:ListAliases and lambda:DeleteFunction.
How many versions should you keep?
I usually keep three. That's enough for a quick rollback via alias to the previous version and leaves some buffer in case two deployments go wrong in a row. If you use no aliases and no qualified invocations at all, you can skip versions entirely with provider.versionFunctions: false. For SnapStart functions, the Serverless docs even recommend this setting explicitly: the superseded version is then deleted on deploy, and you don't pay for snapshots nobody uses.
A cleanup Lambda for functions outside the framework
Many accounts contain functions from CDK stacks, from SAM or even from the console. serverless prune only knows the functions of its own service. For everything else, I build a small scheduled function that runs once a week. The example is a fictional bookshop whose functions all start with buchladen-.
Flow of the cleanup Lambda: schedule, check, delete and report
The logic is deliberately defensive. The function only touches functions whose name starts with a prefix, starts in dry-run mode, and protects every version that something still points to. That includes aliases, the second version of a weighted alias (canary) and versions with provisioned concurrency, meaning pre-warmed instances.
Step 1: The selection logic as a pure function
The decision which versions may go lives in a function without any AWS calls. That way you can test it in milliseconds:
// src/select.ts export interface VersionInfo { version: string; // "1", "2", ... or "$LATEST" codeSize: number; // bytes } export interface SelectOptions { keep: number; // how many of the newest published versions stay protectedVersions: Set<string>; // e.g. versions an alias points to } // Pure function: decides which versions may go. No AWS calls, easy to test. export function selectVersionsToDelete( versions: VersionInfo[], { keep, protectedVersions }: SelectOptions, ): VersionInfo[] { if (!Number.isInteger(keep) || keep < 1) { throw new Error(`keep must be an integer >= 1, got ${keep}`); } const published = versions .filter((v) => v.version !== '$LATEST') .sort((a, b) => Number(b.version) - Number(a.version)); const newest = new Set(published.slice(0, keep).map((v) => v.version)); return published.filter( (v) => !newest.has(v.version) && !protectedVersions.has(v.version), ); } The numeric sort matters. If you sort version numbers as text, 10 ends up before 6, and in the worst case you delete your newest version.
Step 2: The handler
The handler walks through all functions in the Region, collects versions and protected versions, and deletes the rest with DeleteFunction and a Qualifier. The paginators from AWS SDK v3 take care of paging through long lists:
// src/handler.ts import { DeleteFunctionCommand, LambdaClient, paginateListAliases, paginateListFunctions, paginateListProvisionedConcurrencyConfigs, paginateListVersionsByFunction, } from '@aws-sdk/client-lambda'; import { selectVersionsToDelete, type VersionInfo } from './select.js'; const lambda = new LambdaClient({}); const KEEP = Number(process.env.KEEP_VERSIONS ?? '3'); const DRY_RUN = process.env.DRY_RUN !== 'false'; // safe default: only report const PREFIX = process.env.FUNCTION_PREFIX ?? ''; // Collects all versions that something still points to. async function protectedVersionsOf(functionName: string): Promise<Set<string>> { const result = new Set<string>(); for await (const page of paginateListAliases({ client: lambda }, { FunctionName: functionName })) { for (const alias of page.Aliases ?? []) { if (alias.FunctionVersion) result.add(alias.FunctionVersion); // Weighted aliases (canary) route traffic to a second version. for (const v of Object.keys(alias.RoutingConfig?.AdditionalVersionWeights ?? {})) { result.add(v); } } } for await (const page of paginateListProvisionedConcurrencyConfigs( { client: lambda }, { FunctionName: functionName }, )) { for (const cfg of page.ProvisionedConcurrencyConfigs ?? []) { const qualifier = cfg.FunctionArn?.split(':').pop() ?? ''; if (/^\d+$/.test(qualifier)) result.add(qualifier); } } return result; } async function versionsOf(functionName: string): Promise<VersionInfo[]> { const versions: VersionInfo[] = []; for await (const page of paginateListVersionsByFunction( { client: lambda }, { FunctionName: functionName }, )) { for (const v of page.Versions ?? []) { versions.push({ version: v.Version ?? '$LATEST', codeSize: v.CodeSize ?? 0 }); } } return versions; } export const handler = async () => { let functionsChecked = 0; let versionsDeleted = 0; let bytesFreed = 0; for await (const page of paginateListFunctions({ client: lambda }, {})) { for (const fn of page.Functions ?? []) { const name = fn.FunctionName; if (!name || !name.startsWith(PREFIX)) continue; functionsChecked++; const candidates = selectVersionsToDelete(await versionsOf(name), { keep: KEEP, protectedVersions: await protectedVersionsOf(name), }); for (const v of candidates) { console.log(JSON.stringify({ action: DRY_RUN ? 'would-delete' : 'delete', function: name, version: v.version, codeSize: v.codeSize })); if (!DRY_RUN) { await lambda.send(new DeleteFunctionCommand({ FunctionName: name, Qualifier: v.version })); } versionsDeleted++; bytesFreed += v.codeSize; } } } const summary = { dryRun: DRY_RUN, functionsChecked, versionsDeleted, mbFreed: Math.round(bytesFreed / 1024 / 1024) }; console.log(JSON.stringify(summary)); return summary; }; Step 3: Tests
The tests check exactly the places where a mistake would be expensive:
// test/select.test.ts import { describe, expect, it } from 'vitest'; import { selectVersionsToDelete } from '../src/select.js'; const versions = ['$LATEST', '1', '2', '3', '4', '5', '6', '10'].map((version) => ({ version, codeSize: 1000, })); describe('selectVersionsToDelete', () => { it('keeps the newest versions and never touches $LATEST', () => { const result = selectVersionsToDelete(versions, { keep: 3, protectedVersions: new Set() }); expect(result.map((v) => v.version)).toEqual(['4', '3', '2', '1']); }); it('sorts numerically, so 10 counts as newer than 6', () => { const result = selectVersionsToDelete(versions, { keep: 1, protectedVersions: new Set() }); expect(result.map((v) => v.version)).not.toContain('10'); }); it('keeps versions an alias points to', () => { const result = selectVersionsToDelete(versions, { keep: 3, protectedVersions: new Set(['2']) }); expect(result.map((v) => v.version)).toEqual(['4', '3', '1']); }); it('rejects keep values below 1', () => { expect(() => selectVersionsToDelete(versions, { keep: 0, protectedVersions: new Set() })).toThrow(/keep must be/); }); }); After npm install, npx tsc --noEmit and npx vitest run, all four tests should pass. They run without AWS access because the selection logic makes no calls.
Step 4: Deploy with the Serverless Framework
The infrastructure fits into a short serverless.yml. The delete permission is limited to the prefix; listing needs Resource: '*' because ListFunctions doesn't support resource-level restrictions:
# serverless.yml service: version-cleanup frameworkVersion: '4' params: default: prefix: buchladen- # only functions with this prefix are cleaned up dryRun: 'true' # start with reporting only prod: prefix: buchladen-prod- dryRun: 'false' provider: name: aws runtime: nodejs22.x architecture: arm64 region: eu-central-1 memorySize: 256 timeout: 300 iam: role: statements: - Effect: Allow Action: - lambda:ListFunctions - lambda:ListVersionsByFunction - lambda:ListAliases - lambda:ListProvisionedConcurrencyConfigs Resource: '*' - Effect: Allow Action: lambda:DeleteFunction Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${param:prefix}* custom: prune: automatic: true # this service cleans up after itself, too number: 3 functions: cleanup: handler: src/handler.handler environment: KEEP_VERSIONS: '3' DRY_RUN: ${param:dryRun} FUNCTION_PREFIX: ${param:prefix} events: - schedule: rate: cron(0 3 ? * MON *) # every Monday 03:00 UTC npm install npx vitest run serverless deploy --stage dev serverless invoke --function cleanup --stage dev After the first manual invocation, CloudWatch Logs shows one line per candidate with "action":"would-delete" and a summary at the end with the number of versions and the megabytes that would be freed. Only when that list looks plausible do you deploy with --stage prod and real deletion.
Cost and security
The cleanup Lambda runs for a few seconds once a week, which usually keeps it within the free tier. The Lambda API does throttle control-plane calls, though, so a run can take longer with thousands of versions. The 300-second timeout gives you room, and the SDK retries throttled calls automatically. Also, never grant the function lambda:DeleteFunction on *. Otherwise a typo in the prefix may end up deleting whole functions that belong to other teams.
Clean up Lambda versions in S3 reference mode
Recently, Lambda gained the ability to read the code of .zip functions straight from an S3 bucket you own. AWS calls this “self-managed S3 code storage”; the setting is called S3ObjectStorageMode with the values COPY (default) and REFERENCE. In reference mode, Lambda no longer copies anything into its own storage, so the code no longer counts against the quota. Container images aren't affected, they live in Amazon ECR anyway.
In the Serverless Framework, you turn this on at the deployment bucket. The bucket must be versioned because Lambda points to one specific object version:
# serverless.yml (excerpt) provider: name: aws deploymentBucket: versioning: true # required for reference mode codeStorageMode: reference # default is copy custom: prune: automatic: true number: 3 includeArtifacts: true # delete markers on orphaned artifacts Why cleanup gets trickier here
In reference mode, Lambda regularly reads the code object from your bucket again. If it's gone or no longer readable, the function moves to the Inactive state according to the docs. The AWS blog puts it even more bluntly: new cold starts fail if the object was deleted, the bucket policy changed, or the KMS key was disabled. The nasty part is the delay. You delete today, and the error only shows up hours later.
The Serverless docs draw two conclusions from this. In reference mode, the automatic post-deploy deletion of old deployment artifacts is turned off, and you should never hard-delete individual object versions. The safe path is serverless prune -n 3 --includeArtifacts: prune removes the old Lambda versions and places delete markers on artifacts that no version depends on anymore. A lifecycle rule that only removes noncurrent object versions then reclaims the storage:
{ "Rules": [ { "ID": "RemoveOldCodeVersions", "Status": "Enabled", "Filter": { "Prefix": "serverless/" }, "NoncurrentVersionExpiration": { "NoncurrentDays": 30 } } ] } aws s3api put-bucket-lifecycle-configuration \ --bucket <your-deployment-bucket-name> \ --lifecycle-configuration file://lifecycle.json You get th...




