Find Lambda Functions With Too Much Memory

Close-up of memory modules installed on a green circuit board

Photo by Vishnu Mohanan on Unsplash

To find Lambda functions with too much memory, run a CloudWatch Logs Insights query over each function’s REPORT records and compare the peak @maxMemoryUsed with the configured memory. A function whose peak stays far below its setting is oversized. Multiply the difference by its billed GB-seconds to estimate the saving, then confirm with a load test.

Lambda bills duration in GB-seconds: memory times run time. A function set to 1,024 MB that never uses more than 180 MB pays for the unused 844 MB on every invocation. This example is for engineers who want to find Lambda functions with too much memory across a whole Region, see how much each could save, and pick which ones are worth testing at a lower setting. Memory also multiplies the cost of provisioned concurrency, which bills per GB-second even when no requests arrive; the script to find unused Lambda provisioned concurrency flags configs nobody uses.

You’ll get a report-only TypeScript script for the AWS SDK for JavaScript v3 from our collection of AWS SDK v3 cost and cleanup examples. It never changes a function. If you haven’t queried Lambda logs before, the guide on how to investigate Lambda errors with CloudWatch covers Logs Insights basics; this page goes one step further and turns the REPORT line into a right-sizing report.

How does memory size change what a Lambda function costs?

As of September 2026, the AWS Lambda pricing page and the AWS Price List give these us-east-1 on-demand rates. Duration is rounded up to the nearest 1 ms, and the monthly free tier includes 400,000 GB-seconds and 1 million requests.

Architecture Duration price (first tier)
x86_64 $0.0000166667 per GB-second
arm64 $0.0000133334 per GB-second
Requests (both) $0.20 per 1 million

Worked example: orders-api runs 3 million times a month with an average billed duration of 120 ms, which is 3,000,000 × 0.12 = 360,000 seconds.

  • At 1,024 MB: 360,000 s × 1 GB = 360,000 GB-s × $0.0000166667 = $6.00 a month.
  • At 256 MB: 360,000 s × 0.25 GB = 90,000 GB-s × $0.0000166667 = $1.50 a month.

That’s $4.50 a month, if the duration stays the same. It often won’t: Lambda allocates CPU in proportion to memory, and at 1,769 MB a function has the equivalent of one vCPU. A CPU-bound function can run longer at a lower setting and cost the same or more. That’s why the script reports an upper bound and calls its verdict “test the lower setting”, not “change it”.

What does the script do?

  1. Lists functionspaginateListFunctions for memory, architecture, log group (the custom one from LoggingConfig, or /aws/lambda/<name>) and log format.
  2. Skips missing log groupsStartQuery fails if any named log group doesn’t exist, so it checks with paginateDescribeLogGroups first.
  3. Queries the reportsOne Logs Insights query per 50 log groups over --days (default 14). Text-format functions use @maxMemoryUsed and @billedDuration; JSON-format functions use the platform.report event’s record.metrics fields.
  4. Suggests a sizePeak plus --headroom (default 30%), rounded up to 64 MB, never below 128 MB. The headroom is this script’s rule of thumb, not an AWS recommendation.
  5. Estimates the savingBilled seconds × memory difference in GB × the architecture’s price, scaled to 30 days. It flags functions whose peak is under --ratio (default 0.5) of their setting.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • @aws-sdk/client-lambda and @aws-sdk/client-cloudwatch-logs.
  • Function logs still in CloudWatch Logs for the window you query. If log groups keep only a few days, pass a shorter --days.

Which IAM permissions does it need?

Everything is read-only. logs:StartQuery is scoped to log groups in one account and Region; replace 123456789012 and us-east-1.

lambda-memory-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListFunctionsAndLogGroups",
      "Effect": "Allow",
      "Action": ["lambda:ListFunctions", "logs:DescribeLogGroups", "logs:GetQueryResults"],
      "Resource": "*"
    },
    {
      "Sid": "QueryLambdaLogGroups",
      "Effect": "Allow",
      "Action": "logs:StartQuery",
      "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:*"
    }
  ]
}

Paste the script into the IAM policy generator for TypeScript SDK code to draft the same policy for your own variant.

The full script to find Lambda functions with too much memory

find-lambda-functions-too-much-memory.ts

// find-lambda-functions-too-much-memory.ts
// Compares each Lambda function's configured memory with the peak memory its invocations used,
// from the REPORT records in CloudWatch Logs (Logs Insights), and estimates the GB-seconds you'd save.
// Report only: it never changes a function.
// Usage: npx tsx find-lambda-functions-too-much-memory.ts [--days 14] [--ratio 0.5] [--headroom 0.3]
import { LambdaClient, paginateListFunctions } from "@aws-sdk/client-lambda";
import {
  CloudWatchLogsClient,
  GetQueryResultsCommand,
  StartQueryCommand,
  paginateDescribeLogGroups,
  type ResultField,
} from "@aws-sdk/client-cloudwatch-logs";

const args = process.argv.slice(2);
const num = (name: string, fallback: number): number => {
  const i = args.indexOf(name);
  const v = i >= 0 ? Number(args[i + 1]) : NaN;
  return Number.isFinite(v) ? v : fallback;
};
const days = num("--days", 14);
const ratio = num("--ratio", 0.5); // flag functions whose peak is below this share of configured memory
const headroom = num("--headroom", 0.3); // suggested size = peak + 30%, rounded up to 64 MB

// us-east-1 duration prices, first tier, from the AWS Price List (checked September 2026).
const PRICE_PER_GB_SECOND: Record<string, number> = { x86_64: 0.0000166667, arm64: 0.0000133334 };

const lambda = new LambdaClient({});
const logs = new CloudWatchLogsClient({});

interface Fn { name: string; memoryMB: number; arch: string; logGroup: string; format: string }
interface Usage { invocations: number; peakMB: number; p99MB: number; billedMs: number }

// Plain-text logs: REPORT lines with @memorySize and @maxMemoryUsed (bytes).
const TEXT_QUERY = `filter @type = "REPORT"
| stats count(*) as invocations, max(@maxMemoryUsed / 1000 / 1000) as peakMB,
  pct(@maxMemoryUsed / 1000 / 1000, 99) as p99MB, sum(@billedDuration) as billedMs by @log`;
// JSON logs: platform.report events with record.metrics in MB and ms.
const JSON_QUERY = `filter type = "platform.report"
| stats count(*) as invocations, max(record.metrics.maxMemoryUsedMB) as peakMB,
  pct(record.metrics.maxMemoryUsedMB, 99) as p99MB, sum(record.metrics.billedDurationMs) as billedMs by @log`;

async function listFunctions(): Promise<Fn[]> {
  const fns: Fn[] = [];
  for await (const page of paginateListFunctions({ client: lambda }, {})) {
    for (const f of page.Functions ?? []) {
      if (!f.FunctionName) continue;
      fns.push({
        name: f.FunctionName,
        memoryMB: f.MemorySize ?? 128,
        arch: f.Architectures?.[0] ?? "x86_64",
        logGroup: f.LoggingConfig?.LogGroup ?? `/aws/lambda/${f.FunctionName}`,
        format: f.LoggingConfig?.LogFormat ?? "Text",
      });
    }
  }
  return fns;
}

// StartQuery fails if any named log group doesn't exist, so keep only the ones that do.
async function existingLogGroups(names: string[]): Promise<Set<string>> {
  const found = new Set<string>();
  const prefixes = new Set(names.map((n) => (n.startsWith("/aws/lambda/") ? "/aws/lambda/" : n)));
  for (const prefix of prefixes) {
    for await (const page of paginateDescribeLogGroups({ client: logs }, { logGroupNamePrefix: prefix })) {
      for (const g of page.logGroups ?? []) if (g.logGroupName) found.add(g.logGroupName);
    }
  }
  return found;
}

async function runQuery(queryString: string, groups: string[]): Promise<Map<string, Usage>> {
  const out = new Map<string, Usage>();
  const end = Math.floor(Date.now() / 1000);
  const start = end - days * 86_400;
  for (let i = 0; i < groups.length; i += 50) { // up to 50 log groups per query
    const { queryId } = await logs.send(new StartQueryCommand({ logGroupNames: groups.slice(i, i + 50), startTime: start, endTime: end, queryString }));
    let status = "Running";
    let results: ResultField[][] = [];
    let scanned = 0;
    while (status === "Running" || status === "Scheduled") {
      await new Promise((r) => setTimeout(r, 2000));
      const res = await logs.send(new GetQueryResultsCommand({ queryId }));
      status = res.status ?? "Unknown";
      results = res.results ?? [];
      scanned = res.statistics?.bytesScanned ?? 0;
    }
    if (status !== "Complete") throw new Error(`Logs Insights query ended with status ${status}`);
    console.error(`query scanned ${(scanned / 1e9).toFixed(2)} GB`);
    for (const row of results) {
      const f = Object.fromEntries(row.map((c) => [c.field ?? "", c.value ?? ""]));
      const group = (f["@log"] ?? "").replace(/^\d+:/, ""); // "123456789012:/aws/lambda/name"
      out.set(group, { invocations: Number(f.invocations), peakMB: Number(f.peakMB), p99MB: Number(f.p99MB), billedMs: Number(f.billedMs) });
    }
  }
  return out;
}

async function main(): Promise<void> {
  const fns = await listFunctions();
  const existing = await existingLogGroups([...new Set(fns.map((f) => f.logGroup))]);
  const textGroups = [...new Set(fns.filter((f) => f.format !== "JSON" && existing.has(f.logGroup)).map((f) => f.logGroup))];
  const jsonGroups = [...new Set(fns.filter((f) => f.format === "JSON" && existing.has(f.logGroup)).map((f) => f.logGroup))];
  const usage = new Map([...(await runQuery(TEXT_QUERY, textGroups)), ...(await runQuery(JSON_QUERY, jsonGroups))]);
  const sharedGroups = new Set(fns.map((f) => f.logGroup).filter((g, i, all) => all.indexOf(g) !== i));

  const rows: { row: Record<string, string | number>; saving: number }[] = [];
  let totalSaving = 0;
  for (const f of fns) {
    const u = usage.get(f.logGroup);
    if (!u || !u.invocations) {
      rows.push({ saving: -1, row: { Function: f.name, MemoryMB: f.memoryMB, PeakMB: "-", UsedPct: "-", SuggestMB: "-", "Est$/30d": "-", Verdict: "no invocations in window" } });
      continue;
    }
    const used = u.peakMB / f.memoryMB;
    const suggest = Math.max(128, Math.ceil((u.peakMB * (1 + headroom)) / 64) * 64);
    // Upper bound: assumes duration stays the same at the lower setting, which CPU-bound code won't.
    const savedGbSeconds = (u.billedMs / 1000) * (Math.max(0, f.memoryMB - suggest) / 1024);
    const saving = savedGbSeconds * (PRICE_PER_GB_SECOND[f.arch] ?? PRICE_PER_GB_SECOND.x86_64) * (30 / days);
    let verdict = "ok";
    if (sharedGroups.has(f.logGroup)) verdict = "shared log group: check manually";
    else if (used >= 0.9) verdict = "near the limit: consider more memory";
    else if (used < ratio && suggest < f.memoryMB) {
      verdict = "OVERSIZED: test the lower setting";
      totalSaving += saving;
    }
    rows.push({ saving, row: { Function: f.name, MemoryMB: f.memoryMB, PeakMB: Math.round(u.peakMB), UsedPct: Math.round(used * 100), SuggestMB: suggest, "Est$/30d": saving.toFixed(2), Verdict: verdict } });
  }

  rows.sort((a, b) => b.saving - a.saving);
  console.table(rows.map((r) => r.row));
  console.log(`${fns.length} functions, last ${days} days. Oversized functions could save up to about $${totalSaving.toFixed(2)} per 30 days in duration charges (upper bound).`);
  console.log("Report only: no function configuration was changed.");
}

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

How do you run it?

Terminal

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

# Last 14 days, flag functions whose peak is under 50% of configured memory
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-lambda-functions-too-much-memory.ts

# A longer window and a more cautious suggestion (peak + 50%)
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-lambda-functions-too-much-memory.ts --days 30 --headroom 0.5

Logs Insights charges for data scanned: $0.005 per GB in us-east-1 as of September 2026, from the AWS Price List. The script prints the gigabytes each query scanned, so you can see what a longer window costs before you schedule it. Setting log retention with the script to set CloudWatch log retention for all log groups keeps both storage and scan sizes in check.

Sample output

Output

query scanned 3.84 GB
┌─────────┬──────────────────┬──────────┬────────┬─────────┬───────────┬──────────┬────────────────────────────────────────┐
│ (index) │ Function         │ MemoryMB │ PeakMB │ UsedPct │ SuggestMB │ Est$/30d │ Verdict                                │
├─────────┼──────────────────┼──────────┼────────┼─────────┼───────────┼──────────┼────────────────────────────────────────┤
│ 0       │ 'image-resizer'  │ 3008     │ 412    │ 14      │ 576       │ '41.87'  │ 'OVERSIZED: test the lower setting'    │
│ 1       │ 'orders-api'     │ 1024     │ 180    │ 18      │ 256       │ '4.50'   │ 'OVERSIZED: test the lower setting'    │
│ 2       │ 'report-builder' │ 2048     │ 1890   │ 92      │ 2496      │ '0.00'   │ 'near the limit: consider more memory' │
│ 3       │ 'webhook-relay'  │ 256      │ 88     │ 34      │ 128       │ '0.21'   │ 'OVERSIZED: test the lower setting'    │
│ 4       │ 'audit-writer'   │ 512      │ 301    │ 59      │ 448       │ '0.09'   │ 'ok'                                   │
│ 5       │ 'legacy-cron'    │ 1536     │ '-'    │ '-'     │ '-'       │ '-'      │ 'no invocations in window'             │
└─────────┴──────────────────┴──────────┴────────┴─────────┴───────────┴──────────┴────────────────────────────────────────┘
6 functions, last 14 days. Oversized functions could save up to about $46.58 per 30 days in duration charges (upper bound).
Report only: no function configuration was changed.

Function names and numbers are illustrative. image-resizer is the classic case: someone set 3,008 MB during an incident and never lowered it. If nobody knows who owns a function like that, the script to find untagged AWS resources with the Tagging API shows which functions are missing an Owner tag. report-builder is the opposite, peaking at 92% of its memory, so it’s a candidate for more memory, not less. legacy-cron had no invocations in 14 days; check whether it’s still needed, and if it is, get Lambda invocation counts for the last 24 hours to confirm its schedule.

How do you confirm a lower setting is safe?

Peak memory from logs tells you the floor; it doesn’t tell you the speed at each size. The open-source AWS Lambda Power Tuning project runs a Step Functions state machine that invokes your function at several memory settings and charts cost against speed, with cost, speed or balanced strategies. It invokes the real function in your account, with real side effects, so point it at a test stage or an idempotent payload. You can drive a single test call yourself with the example to invoke a Lambda function with AWS SDK v3 in TypeScript.

AWS Compute Optimizer can also recommend memory settings for Lambda functions once you opt in, but it supports only x86_64 functions. The log-based script covers arm64 functions too. The timeout is the other setting worth checking against real run times; the script to find Lambda functions with a timeout too high for their runtime compares it with p99 and maximum duration. Ephemeral storage is billed per GB-second too, for every MB above the free 512 MB, and the script to find Lambda functions paying for extra ephemeral storage prices it per function.

Troubleshooting

  • Every function shows “no invocations in window”. The log groups exist but the query found no reports. Check the Region, and check that retention hasn’t already removed the window you asked for.
  • JSON-format functions show odd numbers. With log-level filtering, a system log level of WARN drops successful report events, which are INFO, so only failed invocations are counted. Keep the system log level at INFO for this report.
  • “shared log group: check manually”. Several functions write to one custom log group, and a query grouped by log group can’t split their reports. Give each function its own log group, or check those functions one at a time.
  • LimitExceededException from StartQuery. Logs Insights allows up to 100 concurrent queries, including queries added to dashboards. The SDK retries this error a few times; if it persists, wait for other queries to finish and rerun.
  • MalformedQueryException. The query text was edited. Compare it with the one in the script.
  • A function on Lambda Managed Instances has no peak. Its platform.report events don’t include per-invocation memory metrics.

Memory is one of several Lambda cleanups. Old code versions pile up in the background too, and the example to delete old and unused Lambda function versions removes them, while the check to find Lambda functions on deprecated runtimes tells you which ones need a redeploy anyway, which is a good moment to change the memory setting. Architecture is the other setting worth reviewing while you’re there; the script to find Lambda functions not running on Graviton (arm64) checks runtimes and layers and estimates the duration saving.

Ask ChatWithCloud instead

You can ask ChatWithCloud “Which Lambda functions in us-east-1 used less than half their memory in the last two weeks?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result, the same way you can ask AI about Lambda errors in your AWS account. It uses one profile and Region per session and runs generated code without a confirmation step, so use a read-only profile; the ChatWithCloud security model explains what’s sent to the AI model. Change memory settings in your infrastructure code, not from a chat.

Frequently asked questions

How do I see how much memory my Lambda function actually uses?

Each invocation writes a REPORT record with Max Memory Used. In Logs Insights, @maxMemoryUsed holds it for text-format logs; for JSON-format logs, read record.metrics.maxMemoryUsedMB from platform.report events.

Does lowering Lambda memory always save money?

No. CPU scales with memory, so a CPU-bound function can take longer at a lower setting. Measure duration at the new size before you commit.

What is the smallest memory setting for Lambda?

128 MB. You can set any value from 128 MB to 10,240 MB in 1 MB increments.

How much headroom should I leave above peak memory?

There’s no AWS rule. This script uses 30% by default; raise it for functions whose input size varies a lot, such as file processing.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud