Find and Delete Unused CloudWatch Dashboards

A dark control room with rows of switched-off monitors on long desks

Photo by Frantisek Duris on Unsplash

To delete unused CloudWatch dashboards, first prove they’re unused: call ListDashboards and GetDashboard, check each metric a widget references with ListMetrics (it only returns metrics with data in the past two weeks), and look up recent GetDashboard events in CloudTrail. Dashboards with dead metrics that nobody has opened can go with DeleteDashboards.

Dashboards pile up. Every incident, migration and proof of concept leaves one behind, and each custom dashboard beyond the free tier is billed every hour it exists. Worse, a dashboard full of flat lines for instances that were terminated a year ago wastes the time of the next on-call engineer who opens it. Before you delete unused CloudWatch dashboards, you want evidence, not a guess based on the name.

This example is for platform and FinOps engineers cleaning up an account. The script scores every dashboard on three signals, prices the result and deletes only the ones that fail all of them, and only when you pass --apply. It pairs with the script to find CloudWatch alarms stuck in INSUFFICIENT_DATA, which cleans up the alarms that watch the same dead resources.

How do you tell a CloudWatch dashboard is unused?

CloudWatch has no “last viewed” field, so the script combines three signals:

Signal Where it comes from What it tells you
Dead metrics ListMetrics for each metric in each widget ListMetrics doesn’t return a metric that hasn’t reported data in the past two weeks, so a miss usually means the instance, function or queue behind it is gone
Last change LastModified from ListDashboards Nobody has edited it for N days
Last view GetDashboard events in CloudTrail Event history CloudTrail records GetDashboard as a management event, including calls from the console, and Event history keeps 90 days of them

A dashboard whose metrics all stopped reporting, that hasn’t changed in 90 days and that nobody else has opened in 90 days is safe to remove. One with live data that nobody opens is a conversation with its owner, not a deletion; the script reports it but won’t delete it. Empty dashboards, with no widgets at all, count as dead.

What do CloudWatch dashboards cost?

As of September 2026, the AWS Price List and the Amazon CloudWatch pricing page show:

Item Price
Custom dashboard $3.00 per dashboard per month, prorated by the hour
Free tier 3 custom dashboards with up to 50 metrics each
Automatic dashboards Free

Dashboards are global: one account has one set of dashboards, whatever Region you call the API in, so the price applies per account. Worked example: an account with 14 custom dashboards pays for 14 − 3 = 11 of them, 11 × $3.00 = $33.00 a month. Deleting 5 dead ones saves 5 × $3.00 = $15.00 a month, $180 a year. It’s a small line item, which is why nobody cleans it up, and why the time saved for on-call engineers matters as much as the money. To see what CloudWatch costs you in total, and which usage types drive it, run the script to get this month’s AWS CloudWatch cost with Cost Explorer.

What does the script do?

  1. Reads CloudTrail firstLookupEvents with EventName=GetDashboard for the last 90 days, keeping the newest event per dashboard. It skips events made by its own identity, and reads them before it calls GetDashboard itself.
  2. Lists every dashboardpaginateListDashboards returns the name, LastModified and size in bytes.
  3. Parses each dashboard bodyGetDashboard returns the body as a JSON string. The script walks the metrics array of every metric widget, resolving the "." shorthand that repeats a value from the previous row, and reads each widget’s or metric’s region.
  4. Checks each metricOne ListMetrics call per distinct metric, in the metric’s Region, with results cached. Math and search expressions and cross-account metrics are skipped because ListMetrics can’t answer for them.
  5. Deletes only with --applyDashboards that are empty or fully dead, older than --days and not opened within --days go to DeleteDashboards, which accepts up to 100 names per call.

Prerequisites

  • Node.js 18 or later, npm and tsx, plus @aws-sdk/client-cloudwatch, @aws-sdk/client-cloudtrail and @aws-sdk/client-sts.
  • A read-only profile for the report, and a separate profile for --apply.
  • Know which Regions your team works in. Console views land in CloudTrail in the Region the console used, so pass them all with --trail-regions. The check to confirm CloudTrail is enabled and logging in every Region is useful if you also want a trail beyond the 90 days of Event history.

Which IAM permissions does it need?

cloudwatch-dashboard-cleanup-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportDashboards",
      "Effect": "Allow",
      "Action": [
        "cloudwatch:ListDashboards",
        "cloudwatch:ListMetrics",
        "cloudtrail:LookupEvents"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ReadDashboardBodies",
      "Effect": "Allow",
      "Action": "cloudwatch:GetDashboard",
      "Resource": "arn:aws:cloudwatch::123456789012:dashboard/*"
    },
    {
      "Sid": "DeleteOnlyWithApply",
      "Effect": "Allow",
      "Action": "cloudwatch:DeleteDashboards",
      "Resource": "arn:aws:cloudwatch::123456789012:dashboard/*"
    }
  ]
}

Dashboard ARNs have no Region. Leave the last statement out of the report profile. sts:GetCallerIdentity needs no permission. If a call is denied anyway, the steps to troubleshoot an AWS IAM access denied error show how to read the message, and the IAM policy generator for TypeScript code derives the action list from the script itself.

The script to delete unused CloudWatch dashboards

find-unused-cloudwatch-dashboards.ts

// find-unused-cloudwatch-dashboards.ts
// Lists every custom CloudWatch dashboard with its last change, size, widget count, the metrics it
// references that have reported no data for two weeks, and the last time anyone else opened it
// (GetDashboard events in CloudTrail Event history, 90 days). Read-only by default.
// With --apply it deletes dashboards that are both stale and unviewed.
// Usage:
//   npx tsx find-unused-cloudwatch-dashboards.ts [--days 90] [--trail-regions us-east-1,eu-west-1] [--csv dashboards.csv]
//   npx tsx find-unused-cloudwatch-dashboards.ts --days 180 --apply
import { writeFileSync } from "node:fs";
import {
  CloudWatchClient,
  DeleteDashboardsCommand,
  GetDashboardCommand,
  ListMetricsCommand,
  paginateListDashboards,
  type DimensionFilter,
} from "@aws-sdk/client-cloudwatch";
import { CloudTrailClient, paginateLookupEvents } from "@aws-sdk/client-cloudtrail";
import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";

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

// Checked September 2026 in the AWS Price List: $3.00 per custom dashboard per month,
// first 3 dashboards (up to 50 metrics each) free. Automatic dashboards are free.
const PRICE_PER_DASHBOARD = 3.0;
const FREE_DASHBOARDS = 3;
const DAY = 86_400_000;

interface MetricRef {
  region: string;
  namespace: string;
  metricName: string;
  dimensions: DimensionFilter[];
}

interface Row {
  Dashboard: string;
  LastModified: string;
  SizeKB: number;
  Widgets: number;
  Metrics: number;
  StaleMetrics: number;
  LastViewed: string;
  Verdict: string;
  Action: string;
}

type Json = Record<string, unknown>;
const isObj = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v);

/** Expand one widget's metrics array, resolving the "." shorthand against the previous row. */
function metricRefs(widget: Json, defaultRegion: string): MetricRef[] {
  const props = isObj(widget.properties) ? widget.properties : {};
  if (widget.type !== "metric" || !Array.isArray(props.metrics)) return [];
  const widgetRegion = typeof props.region === "string" ? props.region : defaultRegion;
  const refs: MetricRef[] = [];
  let previous: string[] = [];
  for (const row of props.metrics) {
    if (!Array.isArray(row)) continue;
    const options = isObj(row[row.length - 1]) ? (row[row.length - 1] as Json) : {};
    const parts = row.filter((v): v is string => typeof v === "string");
    if (parts.length < 2) continue; // an expression row such as [{ "expression": "SUM(METRICS())" }]
    if (props.accountId || options.accountId) continue; // cross-account metric: ListMetrics here can't see it
    const resolved = parts.map((v, i) => (v === "." ? previous[i] ?? "" : v));
    previous = resolved;
    const [namespace, metricName, ...dims] = resolved;
    const dimensions: DimensionFilter[] = [];
    for (let i = 0; i + 1 < dims.length; i += 2) dimensions.push({ Name: dims[i], Value: dims[i + 1] });
    refs.push({
      region: typeof options.region === "string" ? options.region : widgetRegion,
      namespace: namespace ?? "",
      metricName: metricName ?? "",
      dimensions,
    });
  }
  return refs;
}

const cwClients = new Map<string, CloudWatchClient>();
const cw = (region: string) => {
  if (!cwClients.has(region)) cwClients.set(region, new CloudWatchClient({ region }));
  return cwClients.get(region)!;
};

/** ListMetrics only returns metrics that reported data in the past two weeks. */
const activeCache = new Map<string, boolean>();
async function hasRecentData(m: MetricRef): Promise<boolean> {
  const key = JSON.stringify(m);
  if (!activeCache.has(key)) {
    const res = await cw(m.region).send(
      new ListMetricsCommand({ Namespace: m.namespace, MetricName: m.metricName, Dimensions: m.dimensions }),
    );
    activeCache.set(key, (res.Metrics ?? []).length > 0);
  }
  return activeCache.get(key)!;
}

/** Last GetDashboard call per dashboard by anyone except this script's own identity. */
async function lastViews(ownArn: string): Promise<Map<string, Date>> {
  const views = new Map<string, Date>();
  for (const region of trailRegions) {
    const trail = new CloudTrailClient({ region });
    const pages = paginateLookupEvents(
      { client: trail },
      { LookupAttributes: [{ AttributeKey: "EventName", AttributeValue: "GetDashboard" }], StartTime: new Date(Date.now() - 90 * DAY) },
    );
    for await (const page of pages) {
      for (const ev of page.Events ?? []) {
        const detail = JSON.parse(ev.CloudTrailEvent ?? "{}");
        const name: string | undefined = detail.requestParameters?.dashboardName;
        if (!name || detail.userIdentity?.arn === ownArn || !ev.EventTime) continue;
        const seen = views.get(name);
        if (!seen || ev.EventTime > seen) views.set(name, ev.EventTime);
      }
    }
  }
  return views;
}

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 { Arn: ownArn = "" } = await new STSClient({ region: homeRegion }).send(new GetCallerIdentityCommand({}));
  const views = await lastViews(ownArn); // read CloudTrail before this script adds its own GetDashboard calls
  const client = cw(homeRegion); // dashboards are global: any Region returns the same list
  const rows: Row[] = [];
  const toDelete: string[] = [];

  for await (const page of paginateListDashboards({ client }, {})) {
    for (const entry of page.DashboardEntries ?? []) {
      const name = entry.DashboardName ?? "";
      const { DashboardBody = "{}" } = await client.send(new GetDashboardCommand({ DashboardName: name }));
      const body = JSON.parse(DashboardBody) as Json;
      const widgets = Array.isArray(body.widgets) ? body.widgets.filter(isObj) : [];
      const refs = widgets.flatMap((w) => metricRefs(w, homeRegion));
      let stale = 0;
      for (const ref of refs) if (!(await hasRecentData(ref))) stale++;

      const modified = entry.LastModified ?? new Date(0);
      const viewed = views.get(name);
      const old = Date.now() - modified.getTime() > days * DAY;
      const empty = widgets.length === 0;
      const allStale = refs.length > 0 && stale === refs.length;
      const unviewed = !viewed || Date.now() - viewed.getTime() > days * DAY;

      let verdict = "keep";
      if (empty) verdict = "empty";
      else if (allStale && old && unviewed) verdict = "unused: no data, not opened";
      else if (allStale) verdict = "no data in 14 days";
      else if (stale > 0) verdict = "some widgets dead";
      else if (old && unviewed) verdict = "not opened, data still flowing";

      const deletable = (empty || allStale) && old && unviewed;
      const row: Row = {
        Dashboard: name,
        LastModified: modified.toISOString().slice(0, 10),
        SizeKB: Math.round((entry.Size ?? 0) / 102.4) / 10,
        Widgets: widgets.length,
        Metrics: refs.length,
        StaleMetrics: stale,
        LastViewed: viewed ? viewed.toISOString().slice(0, 10) : "not in 90 days",
        Verdict: verdict,
        Action: deletable ? (apply ? "delete" : "would delete") : "",
      };
      rows.push(row);
      if (deletable && apply) toDelete.push(name);
    }
  }

  // DeleteDashboards takes up to 100 names; on an error it still deletes as many as it can.
  for (let i = 0; i < toDelete.length; i += 100) {
    const batch = toDelete.slice(i, i + 100);
    let result = "deleted";
    try {
      await client.send(new DeleteDashboardsCommand({ DashboardNames: batch }));
    } catch (err) {
      result = `error (${err instanceof Error ? err.name : "unknown"}): re-run to check`;
    }
    for (const r of rows) if (batch.includes(r.Dashboard)) r.Action = result;
  }

  console.table(rows);
  const candidates = rows.filter((r) => r.Action !== "");
  const billable = Math.max(0, rows.length - FREE_DASHBOARDS);
  console.log(`${rows.length} custom dashboards (${billable} billable at most), ${candidates.length} stale and unviewed for ${days}+ days`);
  console.log(`Removing them saves up to $${(Math.min(candidates.length, billable) * PRICE_PER_DASHBOARD).toFixed(2)} a month`);
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  if (!apply) console.log("Dry run: no dashboard was deleted. Add --apply to delete the ones marked 'would delete'.");
}

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

How do you run it?

Terminal

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

# Report only, with CloudTrail views from the two Regions your team uses
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-unused-cloudwatch-dashboards.ts --trail-regions us-east-1,eu-west-1 --csv dashboards.csv

# Delete dashboards dead and unopened for 180 days
AWS_PROFILE=monitoring-admin npx tsx find-unused-cloudwatch-dashboards.ts --days 180 --trail-regions us-east-1,eu-west-1 --apply

Export the dashboards before deleting anything, so you can restore one with PutDashboard: aws cloudwatch get-dashboard --dashboard-name NAME --query DashboardBody --output text > NAME.json for each name in the CSV.

Sample output

Output (report)

┌─────────┬──────────────────────────┬──────────────┬────────┬─────────┬─────────┬──────────────┬──────────────────┬───────────────────────────────────┬────────────────┐
│ (index) │ Dashboard                │ LastModified │ SizeKB │ Widgets │ Metrics │ StaleMetrics │ LastViewed       │ Verdict                           │ Action         │
├─────────┼──────────────────────────┼──────────────┼────────┼─────────┼─────────┼──────────────┼──────────────────┼───────────────────────────────────┼────────────────┤
│ 0       │ 'api-prod'               │ '2026-09-02' │ 6.1    │ 12      │ 31      │ 0            │ '2026-09-27'     │ 'keep'                            │ ''             │
│ 1       │ 'black-friday-2024'      │ '2024-11-20' │ 9.8    │ 18      │ 44      │ 44           │ 'not in 90 days' │ 'unused: no data, not opened'     │ 'would delete' │
│ 2       │ 'poc-kinesis-ingest'     │ '2025-03-11' │ 2.4    │ 4       │ 9       │ 9            │ 'not in 90 days' │ 'unused: no data, not opened'     │ 'would delete' │
│ 3       │ 'payments-eu'            │ '2026-01-14' │ 4.7    │ 8       │ 20      │ 6            │ '2026-09-21'     │ 'some widgets dead'               │ ''             │
│ 4       │ 'legacy-batch-workers'   │ '2025-06-30' │ 3.2    │ 6       │ 14      │ 0            │ 'not in 90 days' │ 'not opened, data still flowing'  │ ''             │
│ 5       │ 'Untitled'               │ '2025-08-08' │ 0.1    │ 0       │ 0       │ 0            │ 'not in 90 days' │ 'empty'                           │ 'would delete' │
└─────────┴──────────────────────────┴──────────────┴────────┴─────────┴─────────┴──────────────┴──────────────────┴───────────────────────────────────┴────────────────┘
14 custom dashboards (11 billable at most), 3 stale and unviewed for 90+ days
Removing them saves up to $9.00 a month
Dry run: no dashboard was deleted. Add --apply to delete the ones marked 'would delete'.

Rows are trimmed to 6 of 14 and names are illustrative. payments-eu has 6 dead metrics, probably instances replaced by an Auto Scaling group; fix the widgets rather than the dashboard. legacy-batch-workers still gets data but nobody looks at it, so ask its owner before deleting. The owner of a dashboard is usually the owner of what it charts, and the script to find untagged AWS resources shows which of those resources have no owner tag.

What should you check before you delete a dashboard?

  • Is it deployed as code? Dashboards created by CloudFormation, CDK or Terraform come back on the next deploy. Delete them from the template instead, or the next deploy recreates them.
  • Is it shared? Dashboards shared publicly or with other accounts have viewers who may not appear in your CloudTrail.
  • Is it used by automation? Scripts that call GetMetricWidgetImage for reports don’t call GetDashboard, so they leave no view event.
  • Are the dead metrics seasonal? A batch job that runs quarterly has no data for most two-week windows. Raise --days or skip those dashboards.

Dead dashboards usually point at other leftovers. The same terminated resources often leave log groups with no expiry, which the script to set CloudWatch log retention for all log groups caps, and custom metrics that keep arriving from forgotten jobs, covered in the guide to publish custom CloudWatch metrics with PutMetricData.

Troubleshooting

  • Every dashboard shows “not in 90 days”. The console used another Region than the ones in --trail-regions, or your team views dashboards through a role whose events you filtered out. Add Regions and compare with the CloudTrail console.
  • ThrottlingException from CloudTrail. LookupEvents has a low request rate per Region. The SDK retries it; for large accounts, run the report outside business hours.
  • A metric shows as stale but the graph has data. The metric’s dimensions in the widget differ from what’s published, such as a missing dimension. ListMetrics matches dimension names exactly.
  • An error on delete. Someone may have deleted or renamed a dashboard between the report and the delete. DeleteDashboards still deletes as many of the named dashboards as it can when one fails, so re-run the report to see what’s left.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “Which CloudWatch dashboards haven’t been modified in 90 days?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result; how ChatWithCloud runs AWS SDK code locally 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 keep deletions in the script above. More scripts like this are on the AWS practical examples hub.

Frequently asked questions

How do I delete a CloudWatch dashboard with the AWS CLI?

Run aws cloudwatch delete-dashboards --dashboard-names my-dashboard other-dashboard. One call accepts up to 100 names, and if one fails, CloudWatch still deletes as many of the others as it can.

Can I see who last viewed a CloudWatch dashboard?

Not directly. CloudTrail records GetDashboard as a management event, including console views, and Event history keeps 90 days per Region. Filter on that event name to see who opened which dashboard.

Are CloudWatch dashboards free?

Automatic dashboards are free, and so are the first 3 custom dashboards with up to 50 metrics each. After that, as of September 2026, each custom dashboard costs $3.00 a month, prorated by the hour.

Can I restore a deleted CloudWatch dashboard?

No. Save the body with get-dashboard first, then recreate it with aws cloudwatch put-dashboard --dashboard-name NAME --dashboard-body file://NAME.json.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud