DEV Community

Cover image for Clean Up Lambda Versions: Prune and S3 Reference Mode
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Clean Up Lambda Versions: Prune and S3 Reference Mode

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.prune with automatic: true is 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}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-.

EventBridge triggers the cleanup Lambda weekly; it reads versions and aliases, deletes via DeleteFunction and logs

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),
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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/);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
npm install
npx vitest run
serverless deploy --stage dev
serverless invoke --function cleanup --stage dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
aws s3api put-bucket-lifecycle-configuration \
  --bucket <your-deployment-bucket-name> \
  --lifecycle-configuration file://lifecycle.json
Enter fullscreen mode Exit fullscreen mode

You get the bucket name from serverless info --verbose. Make sure the rule only contains NoncurrentVersionExpiration. A regular Expiration rule would also delete current objects by age, which is exactly the code your running versions need.

Common mistakes when cleaning up

The most common mistake is an integration that points directly to a version number instead of an alias. An event source mapping, a Step Functions definition or an API Gateway integration with :42 at the end of the ARN breaks as soon as version 42 is deleted. So before the first real run, search your templates for ARNs ending in a number and switch them to aliases.

Another classic: prune runs in the wrong stage or the wrong Region. Without --stage and --region, the framework uses the defaults from serverless.yml. Many people also forget layers. With includeLayers: true, prune cleans up layer versions too, but keeps every layer version still attached to a version of a function in the same service. It can't detect layers used by other services or accounts.

Finally, something obvious that still happens a lot: the dry run runs, but nobody reads the output. Take the five minutes for that list before you set DRY_RUN to false.

My take

I think automatic cleanup after every deploy is the right default, and I'd like the framework to enable it out of the box one day. Three versions are enough for a rollback, everything beyond that is archive, and that's what Git is for. With SnapStart for Python or .NET, cleanup hygiene even becomes a cost question.

I'm more careful with S3 reference mode. The benefit is real: no more quota, your own encryption, replication to a second Region. But it shifts responsibility to you. If you already have lifecycle rules like “delete everything older than 30 days” on deployment buckets, review them before you switch. For most teams, I would stay with the default COPY, prune properly, and only switch when the quota really hurts or compliance requirements demand it.

FAQ

Can I delete a Lambda version that an alias points to?

No. The Lambda API refuses to delete a version as long as an alias references it. You have to move the alias to another version or delete it first.

Does serverless prune delete $LATEST?

No. $LATEST isn't a published version and always stays. Even with -n 0, prune only deletes published versions.

How large is Lambda code storage?

According to the current quotas page, 300 GB of Lambda-managed storage per Region and account, and it can't be increased. You get more room with S3 reference mode, where the code lives in your own bucket.

Do I still need serverless-prune-plugin?

Prune is built into Serverless Framework v4, including custom.prune with automatic and number. The plugin remains relevant for older framework versions.

Your next step

Run aws lambda get-account-settings in your most important Region today and see how much code storage is used. Then run serverless prune -n 3 --dryRun in a service of your choice. I'm curious: how many versions has your oldest function piled up?

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)