Skip to main content
emnode / learn
Cost

Delete empty CloudWatch log groups

An empty log group costs almost nothing directly — but hundreds of them obscure what's actually logging, and most carry no retention waiting to bill silently.

11 min·10 sections·AWS

Last reviewed

Empty log groups: the basics

What does it mean for a log group to be 'empty'?

CloudWatch Logs charges on two meters that both depend on data actually existing: ingestion at roughly $0.50/GB when bytes are written, and storage at roughly $0.03/GB-month for as long as those bytes sit there. A log group holding zero bytes — storedBytes == 0, no log streams, or streams that aged out long ago — is therefore costing you essentially nothing on either meter. Be honest about that up front: the direct dollar saving from deleting one empty group is a rounding error.

So why are they flagged? Because they pile up. Every Lambda you ever deployed and deleted, every ECS task definition you iterated on, every CodeBuild project and one-off experiment leaves its /aws/lambda/... or /ecs/... group behind when the resource goes away — AWS does not garbage-collect them. A mature account routinely carries tens of thousands of log groups, the large majority empty husks of things that no longer exist. The signal is buried in the noise.

The check flags groups with no stored data and, usually, no retention policy set. The value of clearing them is hygiene, not dollars: a short, accurate inventory of what's actually logging is auditable; a list of 40,000 mostly-dead groups is not. And an empty group with retentionInDays = null is a silent trap — if anything ever writes to it, it starts hoarding data forever with no pruning.

In this lesson you'll learn why an empty CloudWatch log group costs essentially nothing yet still deserves cleanup, how to identify genuinely-dead groups by stored bytes and stream activity, and — critically — how to tell a dead group apart from one that's merely empty because retention just expired while the underlying resource is still alive. You'll see the AWS CLI commands to inventory and bulk-delete safely, and how setting an account-default retention stops new groups from repeating the pattern. This is a hygiene lesson, not a savings lesson, and it pairs directly with setting retention on the groups you keep.

Fun fact

40,000 groups, 39,000 of them empty

A platform team ran describe-log-groups across a mature production account and got 41,300 results. When they filtered to storedBytes == 0, just over 39,000 came back empty — almost all /aws/lambda/... groups left behind by functions deleted months or years earlier. The total stored-data cost of those 39,000 groups was, naturally, $0.00. But the 2,300 groups that did hold data were impossible to audit until the husks were cleared: nobody could tell which active services were missing retention, because the signal was drowned in 39,000 lines of dead inventory. The cleanup saved no money — and made the next month's retention audit take an afternoon instead of a week.

Clearing empty log groups in action

Nina runs platform infra at a SaaS company. The wastage dashboard flags 8,400 "empty" CloudWatch log groups with a headline saving of basically nothing — $0.00 in stored data. She almost skips it, then notices the real value: she can't run a clean retention audit until the dead groups are gone.

She lists every group with storedBytes == 0 and finds two kinds. The bulk are /aws/lambda/... groups whose functions no longer exist — genuinely dead, safe to delete. But a handful are empty for a different reason: the Lambda still exists and is invoked daily; the group is empty because a 1-day retention just pruned everything overnight. Deleting those would be harmless (Lambda recreates the group on next invocation) but pointless — and it'd briefly lose in-flight context.

She cross-checks each candidate against the live Lambda list before deleting, scripts the deletion of the ~7,900 confirmed-dead groups, and sets an account-level default retention so the next wave of auto-created groups isn't born immortal. The bill doesn't move. The inventory drops from 8,400 noise lines to about 500 real ones, and the retention audit she runs the following week is finally tractable.

First, list every log group holding zero stored bytes — the empty-husk candidates.

$ aws logs describe-log-groups --query "logGroups[?storedBytes==\`0\`].[logGroupName,retentionInDays]" --output table
----------------------------------------------------------------------
| DescribeLogGroups |
+----------------------------------------------------+---------------+
| /aws/lambda/deleted-image-resizer | None |
| /aws/lambda/old-stripe-webhook-v1 | None |
| /aws/lambda/poc-experiment-2024 | None |
| /ecs/retired-batch-worker | None |
| /aws/lambda/active-billing-cron | 1 |
+----------------------------------------------------+---------------+
# 7,900 of 8,400 have retentionInDays = None — born immortal, just empty for now.
# Note 'active-billing-cron': empty because 1-day retention pruned it, NOT dead.

Zero-byte log groups. The None retention column is the trap; the last row shows why empty != dead.

Confirm a candidate is genuinely dead — its Lambda no longer exists — then delete the group. Lambda would recreate the group on next invocation if it were still live, so this is safe.

$ aws lambda get-function --function-name deleted-image-resizer 2>&1 | head -1 && aws logs delete-log-group --log-group-name /aws/lambda/deleted-image-resizer
An error occurred (ResourceNotFoundException) when calling the
GetFunction operation: Function not found: deleted-image-resizer
# Function gone -> the group is a true husk. delete-log-group returns no output on success.
$ aws logs describe-log-groups --log-group-name-prefix /aws/lambda/deleted-image-resizer
{
"logGroups": []
}
# Gone. If a live Lambda ever needs it again, it auto-recreates the group on first log.

Verify the owning resource is gone before deleting. A live Lambda recreates its group automatically, so deleting a dead one is non-destructive.

Why empty groups exist and what 'empty' really meansdeep dive

A CloudWatch log group is just a metadata container; the actual bytes live in log streams underneath it. AWS bills on data: $0.50/GB ingested at write time and $0.03/GB-month to store the compressed result. A group reporting storedBytes == 0 has no billable data, so neither meter is running. Deleting it reclaims no money — it reclaims clarity. The reason the account fills up with them is that auto-creators (Lambda, ECS, API Gateway, CodeBuild, Step Functions) make a group on first log and AWS never deletes it when the underlying resource is removed.

There are two distinct ways a group ends up empty, and they demand different decisions. The first is genuinely dead: the Lambda or service that created it was deleted, so nothing will ever write to it again — safe to delete, and the group will not come back. The second is live-but-pruned: the resource still exists and runs, but a short retention window (say 1 day) cleared the streams overnight, so storedBytes momentarily reads 0. Deleting that group is harmless — a live Lambda recreates its group on the next invocation — but pointless, and you'd lose any in-flight stream metadata. The reliable discriminator is to check whether the owning resource still exists.

Note that storedBytes updates on a delay (it can lag actual ingestion by hours) and reflects compressed bytes, so a group that just received its first logs may briefly still report 0. For a high-confidence "dead" verdict, combine three signals: storedBytes == 0, no log streams (describe-log-streams returns empty), and the owning resource no longer exists. Any group matching all three is a true husk.

# Is the group genuinely empty (no streams at all), or just pruned?
aws logs describe-log-streams \
  --log-group-name /aws/lambda/deleted-image-resizer \
  --order-by LastEventTime --descending --max-items 1 \
  --query 'logStreams[*].[logStreamName,lastEventTimestamp]' \
  --output table

# High-confidence dead check: zero bytes AND no streams AND owning Lambda gone.
LG=/aws/lambda/deleted-image-resizer
FN=${LG#/aws/lambda/}
if ! aws lambda get-function --function-name "$FN" >/dev/null 2>&1; then
  echo "$LG: owning function gone — safe to delete"
fi

What is the impact of empty log groups?

Start with the honest part: the direct cost impact is approximately zero. A group with no stored bytes incurs no storage charge and no ingestion charge. If you delete 39,000 empty groups, your CloudWatch line will not visibly move. Anyone selling this as a savings win is misreading the meter. The impact is real but it is not a dollar figure on this month's invoice.

The first genuine impact is auditability. CloudWatch Logs management — setting retention, attaching metric filters, wiring subscription filters to ship logs to S3 or a SIEM — all operates on the list of log groups. When that list is 95% dead husks, every audit and every bulk operation has to filter the noise first, and humans simply stop doing it. Clearing the empties turns an un-auditable inventory into one a person can actually reason about, which is the precondition for the retention discipline that does affect the bill.

The second impact is the silent-cost trap. Most empty groups carry no retention policy (retentionInDays = null, i.e. "keep forever"). They cost nothing while empty, but the moment anything writes to one — a redeployed function reusing an old name, a misrouted log exporter, a debug flag left on — it begins accumulating data with no automatic pruning. So an empty group with no retention is a loaded gun pointed at a future bill. This is exactly why empty-group cleanup pairs with the retention lesson: clear the husks, and ensure the survivors all have a window.

The third impact is operational and security hygiene. A cluttered group list hides metric filters and subscription filters that are still wired up and still billing for processing, makes it harder to spot a group that's leaking sensitive data, and slows down incident response when an on-call engineer has to find the right group among tens of thousands. None of this is a cost line, but all of it is the kind of neglect that predicts real waste — idle resources, oversized stores, forgotten exports — trending the same way.

How do you clear empty log groups safely?

The fix is a four-step loop: inventory the empties, distinguish genuinely-dead groups from live-but-pruned ones, bulk-delete the confirmed-dead, then set a default retention so new groups don't repeat the pattern.

1. Inventory every zero-byte group

Use aws logs describe-log-groups (it paginates — handle the nextToken) and filter to storedBytes == 0. Capture logGroupName and retentionInDays. Expect the result to be enormous in a mature account — tens of thousands of rows is normal. Note how many also have retentionInDays = null; that subset is both the cleanup target and the retention-trap warning. Do not delete anything off this raw list yet — storedBytes lags ingestion by hours, so a brand-new group can briefly read 0.

2. Separate genuinely-dead from live-but-pruned

An empty group is only safe-and-worthwhile to delete if its owning resource is gone. For /aws/lambda/* groups, strip the prefix and call get-function; a ResourceNotFoundException confirms the function is dead. For /ecs/*, /aws/codebuild/* etc., check the corresponding service. A group that's empty only because a short retention pruned it overnight is still attached to a live resource — deleting it is harmless (the resource recreates the group on next log) but pointless, so leave it. The discriminator is always: does the owner still exist?

3. Bulk-delete the confirmed-dead groups

Loop the confirmed-dead list through aws logs delete-log-group --log-group-name $name. The call is idempotent and returns no output on success; it's rate-limited (~5 TPS per account), so budget time for large batches. Deletion is immediate and irreversible for the metadata, but since these groups hold no data there's nothing to lose, and any still-live resource auto-recreates its group on next write. Run it in dry-run first (echo the names) and eyeball the list before letting it delete for real.

4. Stop new groups being born immortal

The husks keep coming unless new groups get a sane default. Set an account-level default retention so auto-created groups (Lambda especially) aren't born with Never Expire, and enable the AWS Config managed rule cw-loggroup-retention-period-check to flag any future group missing a retention window. For greenfield infra, bake retention_in_days into the Terraform/CDK module that defines the group. This is where empty-group cleanup hands off to the retention lesson — the two disciplines are one habit.

# Find empty groups, confirm the owning Lambda is gone, then delete — with a dry-run guard.
DRY_RUN=${DRY_RUN:-true}
aws logs describe-log-groups \
  --query "logGroups[?storedBytes==\`0\`].logGroupName" \
  --output text | tr '\t' '\n' | while read -r LG; do
    case "$LG" in
      /aws/lambda/*)
        FN=${LG#/aws/lambda/}
        if aws lambda get-function --function-name "$FN" >/dev/null 2>&1; then
          continue   # function still exists — leave it
        fi ;;
    esac
    if [ "$DRY_RUN" = "true" ]; then
      echo "WOULD DELETE: $LG"
    else
      aws logs delete-log-group --log-group-name "$LG" && echo "deleted $LG"
    fi
  done

# Stop the next wave: set an account-wide default retention for new groups.
aws logs put-account-policy \
  --policy-name default-retention \
  --policy-type DATA_PROTECTION_POLICY \
  --policy-document '{"retentionInDays":30}' 2>/dev/null || \
  echo 'Set the Lambda/account default retention via your IaC or console as appropriate.'

Quick quiz

Question 1 of 5

You find a /aws/lambda/active-billing-cron log group reporting storedBytes = 0 and retentionInDays = null. The billing-cron Lambda still exists and runs hourly. What's the right read?

Keep learning

Dig deeper into CloudWatch Logs lifecycle, pricing, and the guardrails that keep the inventory clean and retention enforced.

You've completed Delete empty CloudWatch log groups. You now know the honest truth — an empty group costs essentially nothing directly — and why clearing them still matters: inventory clarity, escaping the no-retention silent-cost trap, and a cleaner surface for the retention discipline that does move the bill. You can tell a genuinely-dead group from a live-but-pruned one, bulk-delete the husks safely, and set a default retention so the next wave doesn't repeat. Next time the report flags thousands of empty groups, you'll know it's a hygiene signal to act on, not a saving to book.

Back to the library