Find and Delete Empty or Abandoned CloudWatch Log Groups

A row of open, empty metal filing cabinet drawers in a quiet office archive

Photo by Andrew Sharp on Unsplash

To delete empty CloudWatch log groups safely, list them with DescribeLogGroups and check storedBytes, then call DescribeLogStreams ordered by LastEventTime with a limit of 1 to see when each group last received an event. Groups with no data, no events for 90 days, or named after a Lambda function that no longer exists are candidates. Delete them with DeleteLogGroup, which removes their events permanently.

Every Lambda function, ECS service, VPC flow log and API Gateway stage you ever created left a log group behind, and CloudWatch Logs keeps every one of them until someone deletes it. Deleting a Lambda function doesn’t delete /aws/lambda/<name>. After a few years the console list is thousands of names long, most of them silent, and a few still hold hundreds of gigabytes nobody reads.

This example is for platform engineers who want to clean up that list without guessing. The script finds empty, orphaned and abandoned groups in every Region you pass, shows how much each stores and what that costs, and can delete empty CloudWatch log groups only when you add --apply. Groups that are active but keep data forever are a different problem: the script to set CloudWatch log retention for all log groups handles those, so this one doesn’t touch retention.

Which log groups are safe to delete?

Verdict How the script decides With --apply
EMPTY storedBytes is 0, the group is older than --days and nothing was written in that window Deleted
ORPHANED Named /aws/lambda/<name>, no live function uses it (default name or custom LoggingConfig.LogGroup), and quiet for --days Deleted
ABANDONED Still stores data, but no event for --days Deleted only with --include-data
EDGE REPLICA Lambda@Edge group, /aws/lambda/us-east-1.<name>, which lives in the Regions where the function ran Never
PROTECTED deletionProtectionEnabled is true, which blocks every delete until someone turns it off Never

The Lambda@Edge case matters because those groups exist in Regions where no function with that name is listed, so a naive “no matching function” check would flag them. The CloudFront documentation explains that Lambda@Edge adds the us-east-1. prefix and writes the logs in the Region closest to where the function ran.

What do abandoned log groups cost?

An empty log group costs nothing, so deleting it is housekeeping: a shorter list, fewer false leads when you debug, and fewer names for tools to scan. The money is in groups that stopped receiving events but still hold data. As of September 2026, CloudWatch Logs charges $0.03 per GB-month for archived log storage in US East (N. Virginia), and the free tier covers 5 GB a month shared between ingestion, archive storage and Logs Insights scans. Before deleting a group that still holds data, you can check what’s in it; the guide to query log groups with CloudWatch Logs Insights and AWS SDK v3 shows how to query it from TypeScript.

Worked example: a development VPC flow log that was switched off six months ago still holds 612 GB with no retention policy. That’s 612 × $0.03 = $18.36 a month, or $220.32 a year, for data nobody has opened. Ten groups like that are $2,203 a year. Check what CloudWatch costs you overall with the script to get this month’s AWS CloudWatch cost with Cost Explorer; ingestion is often the bigger line, and deleting old groups doesn’t change it.

What does the script do?

  1. Lists the log groups Lambda still usespaginateListFunctions collects /aws/lambda/<FunctionName> and any custom LoggingConfig.LogGroup, so a function that logs to a shared group isn’t missed.
  2. Lists every log grouppaginateDescribeLogGroups returns storedBytes, creationTime, retentionInDays, metricFilterCount and deletionProtectionEnabled. It doesn’t use logGroupNamePattern, because that filter drops storedBytes from the response.
  3. Finds the newest eventOne DescribeLogStreams call per group with orderBy: "LastEventTime", descending: true and limit: 1. The API allows 25 of these calls per second; the SDK retries throttled calls.
  4. Classifies and pricesEach group gets a verdict from the table above and a storage cost at $0.03 per GB-month.
  5. Deletes only on requestWithout --apply it prints what it would delete. With it, DeleteLogGroup runs for EMPTY and ORPHANED groups, and for ABANDONED groups only if you also pass --include-data.

Prerequisites

Which IAM permissions does it need?

log-group-cleanup-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadLogGroupsAndFunctions",
      "Effect": "Allow",
      "Action": [
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams",
        "lambda:ListFunctions"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DeleteOnlyWithApply",
      "Effect": "Allow",
      "Action": "logs:DeleteLogGroup",
      "Resource": "arn:aws:logs:*:123456789012:log-group:*"
    }
  ]
}

Replace the account ID. Leave out the second statement for a report-only role; AWS’s ReadOnlyAccess managed policy already covers the first. If you change the script, the IAM policy generator for TypeScript code lists the actions the new version calls, and the checklist to review a generated IAM policy for least privilege helps you narrow the resources.

The script to delete empty CloudWatch log groups

find-empty-cloudwatch-log-groups.ts

// find-empty-cloudwatch-log-groups.ts
// Lists CloudWatch log groups that are empty, abandoned (no events for N days) or left behind by deleted
// Lambda functions, with stored size and a monthly storage estimate.
// Report only by default. --apply deletes EMPTY and ORPHANED groups; --include-data also deletes ABANDONED
// groups that still hold data. Deleting a log group permanently deletes its log events.
// Usage: npx tsx find-empty-cloudwatch-log-groups.ts [--regions us-east-1,eu-west-1] [--days 90] [--csv groups.csv] [--apply] [--include-data]
import { writeFileSync } from "node:fs";
import {
  CloudWatchLogsClient,
  DeleteLogGroupCommand,
  DescribeLogStreamsCommand,
  paginateDescribeLogGroups,
  type LogGroup,
} from "@aws-sdk/client-cloudwatch-logs";
import { LambdaClient, paginateListFunctions } from "@aws-sdk/client-lambda";

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 regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Number(flag("--days") ?? 90);
const csvPath = flag("--csv");
const apply = args.includes("--apply");
const includeData = args.includes("--include-data");

const STORAGE_PER_GB_MONTH = 0.03; // USD, archived log storage in us-east-1 (checked September 2026)
const GIB = 1024 ** 3;
const DAY_MS = 86_400_000;

type Verdict = "EMPTY" | "ORPHANED" | "ABANDONED" | "PROTECTED" | "EDGE REPLICA" | "active";

interface Row {
  Region: string;
  LogGroup: string;
  StoredMB: number;
  LastEvent: string;
  AgeDays: number;
  Retention: string;
  MetricFilters: number;
  StoragePerMonth: string;
  Verdict: Verdict;
  Action: string;
}

/** Log group names that live Lambda functions write to: the default /aws/lambda/<name> or a custom LoggingConfig group. */
async function lambdaLogGroups(region: string): Promise<Set<string>> {
  const lambda = new LambdaClient({ region });
  const names = new Set<string>();
  for await (const page of paginateListFunctions({ client: lambda }, {})) {
    for (const fn of page.Functions ?? []) {
      if (fn.FunctionName) names.add(`/aws/lambda/${fn.FunctionName}`);
      if (fn.LoggingConfig?.LogGroup) names.add(fn.LoggingConfig.LogGroup);
    }
  }
  return names;
}

/** Time of the most recent event in any stream of the group, or undefined when the group has no streams. */
async function lastEventTime(logs: CloudWatchLogsClient, logGroupName: string): Promise<number | undefined> {
  const res = await logs.send(new DescribeLogStreamsCommand({
    logGroupName,
    orderBy: "LastEventTime",
    descending: true,
    limit: 1,
  }));
  const stream = res.logStreams?.[0];
  return stream?.lastEventTimestamp ?? stream?.creationTime;
}

function classify(g: LogGroup, lastEvent: number | undefined, lambdaGroups: Set<string>, now: number): Verdict {
  const name = g.logGroupName ?? "";
  const stored = g.storedBytes ?? 0;
  const ageDays = (now - (g.creationTime ?? now)) / DAY_MS;
  const quietDays = lastEvent === undefined ? ageDays : (now - lastEvent) / DAY_MS;
  if (g.deletionProtectionEnabled) return "PROTECTED";
  if (/^\/aws\/lambda\/[a-z]{2}(-gov)?-[a-z]+-\d\./.test(name)) return "EDGE REPLICA"; // Lambda@Edge: /aws/lambda/us-east-1.<name>
  if (name.startsWith("/aws/lambda/") && !lambdaGroups.has(name) && quietDays >= days) return "ORPHANED";
  if (stored === 0 && ageDays >= days && quietDays >= days) return "EMPTY";
  if (quietDays >= days) return "ABANDONED";
  return "active";
}

async function scanRegion(region: string): Promise<Row[]> {
  const logs = new CloudWatchLogsClient({ region });
  const lambdaGroups = await lambdaLogGroups(region);
  const now = Date.now();
  const rows: Row[] = [];
  for await (const page of paginateDescribeLogGroups({ client: logs, pageSize: 50 }, {})) {
    for (const g of page.logGroups ?? []) {
      const name = g.logGroupName ?? "";
      const lastEvent = await lastEventTime(logs, name); // DescribeLogStreams allows 25 requests per second
      const verdict = classify(g, lastEvent, lambdaGroups, now);
      const stored = g.storedBytes ?? 0;
      const deletable = verdict === "EMPTY" || verdict === "ORPHANED" || (includeData && verdict === "ABANDONED");
      let action = deletable ? "would delete (--apply)" : "-";
      if (apply && deletable) {
        try {
          await logs.send(new DeleteLogGroupCommand({ logGroupName: name }));
          action = "DELETED";
        } catch (err) {
          action = `delete failed: ${err instanceof Error ? err.name : String(err)}`;
        }
      }
      rows.push({
        Region: region,
        LogGroup: name,
        StoredMB: Math.round((stored / 1024 ** 2) * 10) / 10,
        LastEvent: lastEvent === undefined ? "never" : new Date(lastEvent).toISOString().slice(0, 10),
        AgeDays: Math.floor((now - (g.creationTime ?? now)) / DAY_MS),
        Retention: g.retentionInDays ? `${g.retentionInDays}d` : "never expire",
        MetricFilters: g.metricFilterCount ?? 0,
        StoragePerMonth: `$${((stored / GIB) * STORAGE_PER_GB_MONTH).toFixed(2)}`,
        Verdict: verdict,
        Action: action,
      });
    }
  }
  return rows;
}

function toCsv(rows: Row[]): string {
  const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
  const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
  return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
  const flagged = rows.filter((r) => r.Verdict !== "active");
  if (flagged.length === 0) {
    console.log(`No empty, orphaned or abandoned log groups in ${regions.join(", ")} (${rows.length} checked).`);
    return;
  }
  console.table(flagged);
  const abandonedBytes = flagged
    .filter((r) => r.Verdict === "ABANDONED" || r.Verdict === "ORPHANED")
    .reduce((sum, r) => sum + r.StoredMB * 1024 ** 2, 0);
  console.log(`${flagged.length} of ${rows.length} log groups flagged. Orphaned and abandoned groups hold ` +
    `${(abandonedBytes / GIB).toFixed(1)} GB: about $${((abandonedBytes / GIB) * STORAGE_PER_GB_MONTH).toFixed(2)} a month in storage.`);
  if (!apply) console.log("Report only. Re-run with --apply to delete EMPTY and ORPHANED groups.");
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
}

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

How do you run it?

Terminal

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

# 1. Report only, two Regions, keep a CSV for the owners
AWS_PROFILE=readonly npx tsx find-empty-cloudwatch-log-groups.ts --regions us-east-1,eu-west-1 --csv log-groups.csv

# 2. Delete EMPTY and ORPHANED groups quiet for 180+ days
AWS_PROFILE=cleanup npx tsx find-empty-cloudwatch-log-groups.ts --regions us-east-1 --days 180 --apply

Sample output

Output

┌─────────┬─────────────┬───────────────────────────────────┬──────────┬──────────────┬─────────┬────────────────┬───────────────┬─────────────────┬────────────────┬──────────────────────────┐
│ (index) │ Region      │ LogGroup                          │ StoredMB │ LastEvent    │ AgeDays │ Retention      │ MetricFilters │ StoragePerMonth │ Verdict        │ Action                   │
├─────────┼─────────────┼───────────────────────────────────┼──────────┼──────────────┼─────────┼────────────────┼───────────────┼─────────────────┼────────────────┼──────────────────────────┤
│ 0       │ 'us-east-1' │ '/aws/lambda/image-resizer-v1'    │ 39526.4  │ '2025-08-14' │ 1020    │ 'never expire' │ 0             │ '$1.16'         │ 'ORPHANED'     │ 'would delete (--apply)' │
│ 1       │ 'us-east-1' │ '/aws/lambda/pr-1432-preview'     │ 20.5     │ '2025-09-22' │ 380     │ 'never expire' │ 0             │ '$0.00'         │ 'ORPHANED'     │ 'would delete (--apply)' │
│ 2       │ 'us-east-1' │ '/aws/lambda/us-east-1.edge-auth' │ 307.2    │ '2026-05-31' │ 500     │ 'never expire' │ 0             │ '$0.01'         │ 'EDGE REPLICA' │ '-'                      │
│ 3       │ 'us-east-1' │ '/ecs/poc-recommender'            │ 0        │ 'never'      │ 245     │ 'never expire' │ 0             │ '$0.00'         │ 'EMPTY'        │ 'would delete (--apply)' │
│ 4       │ 'us-east-1' │ '/aws/vpc/flow-logs-dev'          │ 627097.6 │ '2026-03-22' │ 1400    │ 'never expire' │ 0             │ '$18.37'        │ 'ABANDONED'    │ '-'                      │
│ 5       │ 'us-east-1' │ '/prod/payments-audit'            │ 98406.4  │ '2026-05-01' │ 1500    │ '2557d'        │ 0             │ '$2.88'         │ 'PROTECTED'    │ '-'                      │
└─────────┴─────────────┴───────────────────────────────────┴──────────┴──────────────┴─────────┴────────────────┴───────────────┴─────────────────┴────────────────┴──────────────────────────┘
6 of 8 log groups flagged. Orphaned and abandoned groups hold 651.0 GB: about $19.53 a month in storage.
Report only. Re-run with --apply to delete EMPTY and ORPHANED groups.

Names and sizes are illustrative. image-resizer-v1 was replaced two years ago and its 38.6 GB of logs outlived it. pr-1432-preview is the typical preview-environment leftover. The flow-log group is the expensive one at $18.37 a month; it’s ABANDONED rather than ORPHANED, so the script won’t delete it without --include-data. edge-auth and payments-audit are reported and left alone. Only flagged groups are printed; the CSV has all of them.

What should you check before deleting?

  • Compliance holds. Audit, payment and security logs often have a required retention period. If a group might be one of those, export it to S3 first; the comparison of S3 storage class costs for backups shows where long-lived archives are cheapest. Then turn on deletion protection for the ones you keep. Groups you keep for compliance often need a key you control as well; the script to check CloudWatch log groups for KMS encryption shows which ones still rely on the default encryption.
  • Metric filters and subscriptions. A non-zero MetricFilters column means an alarm may depend on the group. A quiet group with a metric filter can explain alarms that sit in INSUFFICIENT_DATA, which the script to find CloudWatch alarms stuck in INSUFFICIENT_DATA reports.
  • Resources that will write again. If a Lambda function still exists and its execution role allows logs:CreateLogGroup, the group comes back on the next invocation, without the retention you set. Monthly and quarterly jobs can look abandoned in a 30-day window, so keep --days at 90 or more.
  • Owners. Tags on the Lambda function, ECS service or VPC tell you who to ask; the script to find untagged AWS resources shows what nobody has claimed.

Old log groups usually travel with other leftovers from the same projects. The scripts to delete old and unused Lambda function versions and find and delete unused CloudWatch dashboards clean up the rest of that trail.

Troubleshooting

  • A group shows LastEvent “never”. It has no log streams at all: something created it, often an infrastructure-as-code template or a service setting, and nothing ever wrote to it. If it’s recent, the resource may simply not have logged yet.
  • A group you just wrote to shows an old date. The API reference says lastEventTimestamp updates on an eventual consistency basis, typically within an hour of ingestion. That’s why the window is measured in days.
  • ThrottlingException in a large account. The SDK retries it, but thousands of groups take time at 25 calls per second. Run one Region at a time, or raise retries as shown in the guide to configure retry and timeout settings in AWS SDK for JavaScript v3.
  • AccessDeniedException on delete. An SCP or a permission boundary may block logs:DeleteLogGroup. The steps to troubleshoot AWS IAM access denied errors show how to find which policy denied it.

Ask ChatWithCloud instead

For a quick answer without installing anything, ask ChatWithCloud “Which log groups in us-east-1 have no events in the last 90 days, and how much data do they store?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result; how ChatWithCloud runs AWS SDK code on your machine shows the loop. It uses one profile and Region per session and runs changes without a confirmation step, so connect ChatWithCloud to a read-only AWS profile and do the deleting with the script. More cleanup scripts are on the AWS practical examples hub.

Frequently asked questions

Does deleting a Lambda function delete its log group?

No. The Lambda documentation says log groups aren’t deleted automatically when you delete a function. Delete the group yourself or set a retention period so the events expire.

Do empty CloudWatch log groups cost money?

No. CloudWatch Logs charges for ingested and stored data, and an empty group has neither. Deleting empty groups is about a clean, searchable list, not savings.

Can I recover a deleted log group?

No. DeleteLogGroup deletes the group and permanently deletes all its archived log events. Export anything you might need to S3 first.

How do I delete many CloudWatch log groups at once?

There’s no batch delete API, so call DeleteLogGroup once per group, as the script does. Run it without --apply first and review the list.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud