Find Unused Lambda Functions With No Invocations

Rows of empty office desks with chairs pushed in under dim lights

Photo by Igor Omilaev on Unsplash

To find unused Lambda functions, list them with ListFunctions and sum the CloudWatch Invocations metric for each over 90 days with GetMetricData (up to 500 functions per call). Functions with zero invocations are candidates. Before deleting one, check its event source mappings, function URL and resource-based policy, because DeleteFunction removes the function but not the triggers pointing at it.

Every account that has used Lambda for a few years has a long tail: the prototype from a hackathon, the migration helper that ran once, the -v2 function that replaced a -v1 nobody deleted. They cost little on their own, which is exactly why they survive, but each one is an old runtime, an execution role and sometimes a secret in an environment variable that nobody is watching.

This example is for engineers cleaning up a Lambda estate. The TypeScript script uses the AWS SDK for JavaScript v3 to find unused Lambda functions over a window you choose, then checks what is still wired to each one, how many versions and how much code storage it holds, and whether SnapStart is still billing for it. It reports by default and deletes only functions you name, and only if the same run found them unused. To see recent traffic instead, the example to get Lambda invocation counts for the last 24 hours lists your busiest functions.

What does an unused Lambda function cost?

With no invocations there are no request or duration charges, and Lambda’s pricing page lists no charge for storing .zip code. The costs sit around the function:

Cost or risk What to know
SnapStart cache (Python, .NET) $0.0000015046 per GB-second for each published version with SnapStart, billed while the version is active, invoked or not. No SnapStart charge for Java.
Provisioned concurrency Billed per GB-second whether or not requests arrive.
Log group storage $0.03 per GB-month for old logs that nothing expires.
Container image functions The image stays in Amazon ECR and is billed as ECR storage.
Code storage quota 300 GB per Region for .zip functions and layers, counting every version. It can’t be increased.
Security surface Deprecated runtimes, broad execution roles, secrets in environment variables, public function URLs.

Prices for US East (N. Virginia) as of September 2026, from the AWS Price List API (Lambda publication dated 19 September 2026, CloudWatch 22 September 2026); see the AWS Lambda pricing page for current rates. The storage limit is on the Lambda quotas page.

Worked example. A retired Python 3.13 function with 1,024 MB of memory and SnapStart on still has 3 published versions. A 30-day month is 2,592,000 seconds:

  • Per version: 1 GB × 2,592,000 s × $0.0000015046 = $3.90
  • 3 versions: $11.70 a month, or about $140 a year, for a function nobody calls.

Most unused functions cost far less than that. The bigger reasons to delete them are the quota, which old versions fill faster than you expect, and the attack surface. The examples to find unused Lambda provisioned concurrency and find Lambda functions on deprecated runtimes cover the two most common overlaps.

How do you decide a Lambda function is unused?

The Invocations metric counts the requests Lambda billed: successful runs and runs that ended in a function error. Queried with only the FunctionName dimension, it aggregates every version and alias. A zero sum over a long enough window is the signal. Three caveats:

  • Pick the window from the calendar, not the default. 30 days misses monthly jobs; 90 days misses quarterly and year-end jobs. CloudWatch keeps hourly data for 455 days, which is the script’s upper limit for --days.
  • Throttled calls don’t count. If every request is throttled, for example because reserved concurrency is set to 0, Invocations stays at zero while callers keep trying. Check the Throttles metric for those functions.
  • Lambda@Edge reports elsewhere. Metrics for Lambda@Edge functions are stored in the Region closest to where each copy ran, so zero in us-east-1 proves nothing. Skip them here.

The script adds two more signals. LastModified protects functions deployed recently (--min-age, default 30 days) that simply haven’t had traffic yet. And State: Inactive, which Lambda sets when a function has been idle long enough for it to reclaim resources, is a useful hint that nobody has called it for a while.

What does the script do?

  1. Lists functionspaginateListFunctions returns the unpublished ($LATEST) configuration of every function in the Region, 50 per page.
  2. Sums invocationspaginateGetMetricData requests the daily Sum of Invocations per function over --days, 500 functions per call.
  3. Checks what is still wiredFor each function with zero invocations: ListEventSourceMappings (SQS, Kinesis, DynamoDB and other pollers), ListFunctionUrlConfigs, and GetPolicy for services allowed to invoke it, such as S3 buckets and EventBridge rules.
  4. Measures what it holdsListVersionsByFunction adds up CodeSize across versions, ListAliases counts aliases, and GetFunctionConfiguration reads State, which ListFunctions doesn’t return.
  5. ClassifiesUNUSED: delete candidate, IDLE BUT WIRED: check triggers first, or too new, and notes SnapStart on non-Java runtimes.
  6. Optionally deletes--apply --names a,b calls DeleteFunction only for named functions that this run classified as delete candidates.

Prerequisites

  • Node.js 18 or later, tsx, @aws-sdk/client-lambda and @aws-sdk/client-cloudwatch.
  • Run it per Region; functions and metrics are Regional.
  • Lambda’s control-plane APIs share a limit of 15 requests per second (GetPolicy has its own 15), so the client uses maxAttempts: 8 and the SDK retries TooManyRequestsException with backoff. Large accounts take a few minutes.

Which IAM permissions does it need?

unused-lambda-functions-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListFunctionsAndMetrics",
      "Effect": "Allow",
      "Action": ["lambda:ListFunctions", "lambda:ListEventSourceMappings", "cloudwatch:GetMetricData"],
      "Resource": "*"
    },
    {
      "Sid": "InspectFunctions",
      "Effect": "Allow",
      "Action": [
        "lambda:ListFunctionUrlConfigs",
        "lambda:GetPolicy",
        "lambda:ListVersionsByFunction",
        "lambda:ListAliases",
        "lambda:GetFunctionConfiguration"
      ],
      "Resource": "arn:aws:lambda:*:111122223333:function:*"
    },
    {
      "Sid": "ApplyOnlyToNamedFunctions",
      "Effect": "Allow",
      "Action": "lambda:DeleteFunction",
      "Resource": "arn:aws:lambda:us-east-1:111122223333:function:old-webhook"
    }
  ]
}

Leave out the last statement for report-only runs; AWS’s ReadOnlyAccess managed policy should also cover the report. To extend the script, paste it into the IAM policy generator for TypeScript code for a starting policy.

The script to find unused Lambda functions

find-unused-lambda-functions.ts

// find-unused-lambda-functions.ts
// Finds Lambda functions with zero Invocations in CloudWatch over the last N days, then checks what is still
// wired to each one (event source mappings, function URLs, resource-based policy triggers), how many versions
// and aliases it has, how much code storage those versions use, and whether SnapStart is on. Report only,
// unless you pass --apply with an explicit list of function names that the same run found unused.
// Usage: npx tsx find-unused-lambda-functions.ts [--region us-east-1] [--days 90] [--min-age 30]
//        npx tsx find-unused-lambda-functions.ts --apply --names old-webhook,test-thumbnailer
import {
  DeleteFunctionCommand,
  GetFunctionConfigurationCommand,
  GetPolicyCommand,
  LambdaClient,
  paginateListAliases,
  paginateListEventSourceMappings,
  paginateListFunctionUrlConfigs,
  paginateListFunctions,
  paginateListVersionsByFunction,
  type FunctionConfiguration,
} from "@aws-sdk/client-lambda";
import { CloudWatchClient, paginateGetMetricData, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const days = Number(flag("--days") ?? "90");
const minAge = Number(flag("--min-age") ?? "30"); // skip functions changed in the last N days
const apply = args.includes("--apply");
const names = (flag("--names") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (!(days >= 1 && days <= 455) || !(minAge >= 0)) {
  console.error("--days must be 1-455 (CloudWatch keeps hourly data for 455 days) and --min-age 0 or more");
  process.exit(1);
}
if (apply && names.length === 0) {
  console.error("--apply needs --names fn-a,fn-b (the script never deletes every unused function at once)");
  process.exit(1);
}

const lambda = new LambdaClient({ region, maxAttempts: 8 }); // Lambda control-plane APIs share 15 requests/second
const cloudwatch = new CloudWatchClient({ region });
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const DAY = 86_400_000;

interface Usage {
  total: number;
}
const logGroups = new Map<string, string>();

// One Invocations query per function (daily sums), 500 functions per GetMetricData call
async function invocations(fns: FunctionConfiguration[]): Promise<Map<string, Usage>> {
  const end = new Date(); // include today, so a function called this morning isn't reported
  const start = new Date(end.getTime() - days * DAY);
  const usage = new Map<string, Usage>();
  for (let i = 0; i < fns.length; i += 500) {
    const batch = fns.slice(i, i + 500);
    const queries: MetricDataQuery[] = batch.map((f, j) => ({
      Id: `f${j}`,
      MetricStat: {
        Metric: {
          Namespace: "AWS/Lambda",
          MetricName: "Invocations",
          Dimensions: [{ Name: "FunctionName", Value: f.FunctionName ?? "" }],
        },
        Period: 86_400,
        Stat: "Sum",
      },
    }));
    for await (const page of paginateGetMetricData({ client: cloudwatch }, { MetricDataQueries: queries, StartTime: start, EndTime: end })) {
      for (const r of page.MetricDataResults ?? []) {
        const name = batch[Number((r.Id ?? "f-1").slice(1))]?.FunctionName;
        if (!name) continue;
        const u = usage.get(name) ?? { total: 0 };
        u.total += (r.Values ?? []).reduce((a, b) => a + b, 0);
        usage.set(name, u);
      }
    }
  }
  return usage;
}

// Who could still call it: service principals and source ARNs from the resource-based policy
async function policyTriggers(fn: string): Promise<string[]> {
  try {
    const { Policy } = await lambda.send(new GetPolicyCommand({ FunctionName: fn }));
    const doc = JSON.parse(Policy ?? "{}") as {
      Statement?: { Principal?: string | Record<string, unknown>; Condition?: Record<string, Record<string, unknown>> }[];
    };
    return (doc.Statement ?? []).map((s) => {
      const p = typeof s.Principal === "string" ? s.Principal : String(s.Principal?.Service ?? s.Principal?.AWS ?? "?");
      // Condition keys are case-insensitive: find aws:SourceArn under ArnLike or ArnEquals
      const source = Object.values(s.Condition ?? {})
        .flatMap((c) => Object.entries(c))
        .find(([k]) => k.toLowerCase() === "aws:sourcearn")?.[1];
      return source ? `${p} (${String(source).split(":").pop()})` : p;
    });
  } catch (err) {
    if (err instanceof Error && err.name === "ResourceNotFoundException") return []; // no policy at all
    throw err;
  }
}

interface Wiring {
  mappings: string[];
  url: string;
  triggers: string[];
  versions: number;
  aliases: number;
  codeBytes: number;
  state: string;
}

async function wiring(fn: FunctionConfiguration): Promise<Wiring> {
  const name = fn.FunctionName ?? "";
  const mappings: string[] = [];
  for await (const page of paginateListEventSourceMappings({ client: lambda }, { FunctionName: name })) {
    for (const m of page.EventSourceMappings ?? []) {
      mappings.push(`${(m.EventSourceArn ?? "").split(":")[2] ?? "?"} mapping ${m.State ?? "?"}`);
    }
  }
  const urls: string[] = [];
  for await (const page of paginateListFunctionUrlConfigs({ client: lambda }, { FunctionName: name })) {
    urls.push(...(page.FunctionUrlConfigs ?? []).map((u) => u.AuthType ?? "?"));
  }
  let versions = 0;
  let codeBytes = 0;
  for await (const page of paginateListVersionsByFunction({ client: lambda }, { FunctionName: name })) {
    for (const v of page.Versions ?? []) {
      versions += 1;
      codeBytes += v.CodeSize ?? 0; // every version keeps its own copy of the code
    }
  }
  let aliases = 0;
  for await (const page of paginateListAliases({ client: lambda }, { FunctionName: name })) aliases += page.Aliases?.length ?? 0;
  const config = await lambda.send(new GetFunctionConfigurationCommand({ FunctionName: name }));
  return {
    mappings,
    url: urls.length ? `URL ${urls.join("/")}` : "",
    triggers: await policyTriggers(name),
    versions,
    aliases,
    codeBytes,
    state: config.State ?? "?",
  };
}

interface Row {
  Function: string;
  Modified: string;
  Versions: number;
  CodeMB: number;
  State: string;
  StillWired: string;
  Verdict: string;
}

async function report(): Promise<Row[]> {
  const fns: FunctionConfiguration[] = [];
  for await (const page of paginateListFunctions({ client: lambda }, {})) fns.push(...(page.Functions ?? []));
  const usage = await invocations(fns);
  const rows: Row[] = [];
  for (const fn of fns) {
    const name = fn.FunctionName ?? "?";
    const u = usage.get(name) ?? { total: 0 };
    if (u.total > 0) continue; // used in the window: not a candidate
    const modified = new Date(fn.LastModified ?? 0);
    const tooNew = Date.now() - modified.getTime() < minAge * DAY;
    let w: Wiring;
    try {
      w = await wiring(fn);
    } catch (err) {
      console.error(`${name}: ${errText(err)}`);
      continue;
    }
    const wired = [...w.mappings, w.url, ...w.triggers].filter(Boolean);
    const snapStart = fn.SnapStart?.ApplyOn === "PublishedVersions" && !(fn.Runtime ?? "").startsWith("java");
    let verdict = "UNUSED: delete candidate";
    if (tooNew) verdict = `too new (changed < ${minAge}d ago)`;
    else if (wired.length > 0) verdict = "IDLE BUT WIRED: check triggers first";
    if (snapStart && !tooNew) verdict += "; SnapStart cache still billed";
    logGroups.set(name, fn.LoggingConfig?.LogGroup ?? `/aws/lambda/${name}`);
    rows.push({
      Function: name,
      Modified: modified.toISOString().slice(0, 10),
      Versions: w.versions,
      CodeMB: Math.round((w.codeBytes / 1_048_576) * 10) / 10,
      State: w.state,
      StillWired: wired.join(", ") || "-",
      Verdict: verdict,
    });
  }
  rows.sort((a, b) => b.CodeMB - a.CodeMB);
  console.table(rows);
  const candidates = rows.filter((r) => r.Verdict.startsWith("UNUSED"));
  const mb = candidates.reduce((a, r) => a + r.CodeMB, 0);
  console.log(`${fns.length} functions in ${region}; ${rows.length} with no invocations in ${days} days; ` +
    `${candidates.length} delete candidates holding ${mb.toFixed(1)} MB of code storage.`);
  return rows;
}

async function main(): Promise<void> {
  const rows = await report();
  if (!apply) {
    console.log("Report only: nothing was changed.");
    return;
  }
  for (const name of names) {
    const row = rows.find((r) => r.Function === name);
    if (!row || !row.Verdict.startsWith("UNUSED")) {
      console.error(`${name}: skipped, this run didn't classify it as an unused delete candidate`);
      process.exitCode = 1;
      continue;
    }
    try {
      await lambda.send(new DeleteFunctionCommand({ FunctionName: name })); // all versions and aliases go too
      console.log(`${name}: deleted. Log group ${logGroups.get(name)}, layers and the execution role are left in place.`);
    } catch (err) {
      console.error(`${name}: ${errText(err)}`);
      process.exitCode = 1;
    }
  }
}

main().catch((err) => {
  console.error(errText(err));
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-lambda @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node

# Report: no invocations in 90 days, ignoring functions changed in the last 30 days
AWS_PROFILE=readonly npx tsx find-unused-lambda-functions.ts --region us-east-1 --days 90 --min-age 30

# A year-long window before deleting anything
AWS_PROFILE=readonly npx tsx find-unused-lambda-functions.ts --region us-east-1 --days 400

# Delete two functions the report listed as delete candidates
AWS_PROFILE=lambda-admin npx tsx find-unused-lambda-functions.ts --region us-east-1 --days 400 \
  --apply --names old-webhook,report-generator

Sample output

Output

┌─────────┬─────────────────────┬──────────────┬──────────┬────────┬────────────┬─────────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ (index) │ Function            │ Modified     │ Versions │ CodeMB │ State      │ StillWired                          │ Verdict                                                  │
├─────────┼─────────────────────┼──────────────┼──────────┼────────┼────────────┼─────────────────────────────────────┼──────────────────────────────────────────────────────────┤
│ 0       │ 'report-generator'  │ '2025-11-02' │ 3        │ 143    │ 'Active'   │ '-'                                 │ 'UNUSED: delete candidate; SnapStart cache still billed' │
│ 1       │ 'old-webhook'       │ '2025-11-02' │ 1        │ 3.2    │ 'Inactive' │ '-'                                 │ 'UNUSED: delete candidate'                               │
│ 2       │ 'legacy-s3-resizer' │ '2025-11-02' │ 1        │ 3.2    │ 'Active'   │ 's3.amazonaws.com (legacy-uploads)' │ 'IDLE BUT WIRED: check triggers first'                   │
│ 3       │ 'queue-drainer'     │ '2025-11-02' │ 1        │ 3.2    │ 'Active'   │ 'sqs mapping Enabled'               │ 'IDLE BUT WIRED: check triggers first'                   │
│ 4       │ 'new-feature'       │ '2026-09-24' │ 1        │ 3.2    │ 'Active'   │ '-'                                 │ 'too new (changed < 30d ago)'                            │
└─────────┴─────────────────────┴──────────────┴──────────┴────────┴────────────┴─────────────────────────────────────┴──────────────────────────────────────────────────────────┘
6 functions in us-east-1; 5 with no invocations in 90 days; 2 delete candidates holding 146.2 MB of code storage.
Report only: nothing was changed.

Function names and sizes are illustrative; the output came from a run against mocked SDK clients. report-generator is the worked example: no calls in 90 days, three 48 MB versions, and SnapStart on a Python runtime, so it’s billing every hour. old-webhook is Inactive, which fits. legacy-s3-resizer and queue-drainer have had no invocations, but an S3 bucket and an SQS mapping still point at them: find out whether those sources are dead too before deleting anything. new-feature was deployed 5 days ago and is left alone. orders-api had traffic and doesn’t appear.

What should you check before deleting a Lambda function?

DeleteFunction without a qualifier deletes the function with all its versions and aliases, and a deleted function can’t be recovered. It deliberately leaves everything outside the function alone:

  • Event source mappings stay until you call DeleteEventSourceMapping. The script refuses to delete a function that still has one.
  • Triggers in other services (S3 event notifications, EventBridge rules, API Gateway integrations, SNS subscriptions) must be removed in those services, or they will fail on every event.
  • The log group (/aws/lambda/<name> unless the function uses a custom one) keeps its logs; the example to find and delete empty or abandoned CloudWatch log groups cleans those up afterwards.
  • Layers stay published. Once nothing references them, the example to find unused Lambda layer versions removes old ones.
  • The execution role stays in IAM. The example to find unused IAM roles with RoleLastUsed catches it a few weeks later.
  • Container images stay in ECR; the example to find unused ECR repositories covers them.

A safe sequence: download the deployment package (GetFunction returns a link to it) or confirm it’s in source control, then set reserved concurrency to 0 so every invocation is throttled, and watch Throttles for two weeks. If anything was still calling it, you’ll see it there and can undo the throttle in seconds. If nothing was, delete it. If you throttle several at once, the script to check Lambda reserved concurrency across the account lists every function still set to 0, so none is forgotten. For a function you want to keep but slim down, the example to delete old and unused Lambda function versions frees storage without touching $LATEST.

Troubleshooting

  • Every function is reported as unused. The profile probably lacks cloudwatch:GetMetricData in that Region, or you’re pointing at the wrong Region. The script treats missing data as zero.
  • TooManyRequestsException after all retries. Another tool is using the same 15 requests per second. Run the script when deployments are quiet.
  • A function you know is used shows zero. Check Throttles, and whether it’s a Lambda@Edge function. Callers that invoke a specific version still count, because the FunctionName dimension aggregates all versions and aliases.
  • ResourceConflictException on delete. Another operation is in progress on the function, such as an update. Wait and run the delete again.

Ask ChatWithCloud instead

For a first pass, ask ChatWithCloud “Which Lambda functions in us-east-1 had no invocations in the last 90 days?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and answers from the metrics; the guide to ask AI about Lambda errors in your AWS account shows the same workflow for failing functions. It can be wrong and runs changes without a confirmation step, so never ask it to delete functions; use a read-only profile, as the ChatWithCloud security model recommends, and delete with the script’s explicit --names list.

Functions that survive the cleanup deserve a security pass too: the examples to find Lambda functions with over-privileged execution roles and find secrets in Lambda environment variables are the natural next runs.

Frequently asked questions

How do I find unused Lambda functions in AWS?

Sum the CloudWatch Invocations metric per function over a long window, such as 90 days or a year, and list the functions with zero. Then check each one’s event source mappings, function URL and resource-based policy before deleting.

Do unused Lambda functions cost money?

Usually very little: there are no request or duration charges without invocations. SnapStart on Python or .NET, provisioned concurrency, log storage and ECR images still bill, and every version counts toward the 300 GB code storage quota.

Does deleting a Lambda function delete its triggers and logs?

No. It deletes the function, its versions and aliases. Event source mappings, triggers configured in other services, the log group, layers and the execution role remain.

How far back can I check Lambda invocations?

CloudWatch keeps hourly metric data for 455 days (15 months). A function that hasn’t run for longer than that has no data at all, which the script also treats as unused.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud