Find CloudWatch Alarms With No Actions

A red fire alarm bell mounted on a white wall with no one around

Photo by Jonathan Cosens Photography on Unsplash

A CloudWatch alarm with no actions changes state but tells nobody: its AlarmActions, OKActions and InsufficientDataActions lists are empty, or ActionsEnabled is false, or every action points at an SNS topic that was deleted. Find them with DescribeAlarms across metric, composite and log alarms, check each topic with GetTopicAttributes, then fix or delete them.

Alarms outlive the people who created them. Someone removes an on-call SNS topic, turns off actions during a noisy migration, or clicks through the console without choosing a notification, and the alarm keeps flipping to ALARM with nobody listening. This example is for platform engineers who want a list of every CloudWatch alarm with no actions, or with actions that can’t reach anyone, before an incident proves the point.

The script reports by default. It deletes only the alarms you name with --names and confirm with --apply, and it refuses anything a composite alarm still depends on.

What makes a CloudWatch alarm silent?

An alarm is only as useful as what happens when it changes state. The script flags three cases:

Problem How it shows up in DescribeAlarms Typical cause
No actions All three action lists are empty Created from the console or a template without a notification; left behind after a test
Actions disabled ActionsEnabled: false with actions configured DisableAlarmActions during maintenance, never followed by EnableAlarmActions
All targets deleted Every action is an SNS topic ARN that returns NotFoundException The team’s alert topic was renamed or removed

This is a different failure from an alarm that never gets data. An alarm stuck in INSUFFICIENT_DATA may have perfect actions and a metric that stopped reporting; the companion script to find CloudWatch alarms stuck in INSUFFICIENT_DATA covers that case. Run both: one finds alarms that can’t fire, the other finds alarms that fire into nothing.

When an alarm without actions is fine

Some alarms are silent on purpose. AWS suggests setting alerts only on a composite alarm and leaving its children quiet, which reduces noise. A composite rule such as ALARM(db-connections-high) OR ALARM(db-replica-lag) needs those child alarms to exist, so the script parses every AlarmRule (names can be quoted, unquoted or full ARNs) and shows the parent in a ChildOf column. Alarms also emit state-change events to Amazon EventBridge, so a rule there can route an alarm that has no actions of its own. Check EventBridge before deleting anything a team might rely on.

What does a CloudWatch alarm with no actions cost?

As of September 2026, the AWS Price List for CloudWatch (published 22 September 2026) gives these us-east-1 rates, and the Amazon CloudWatch pricing page explains how they apply. Alarms are billed by the hour from the first hour they exist, whether or not they have actions.

Alarm type Price per month
Standard resolution metric alarm $0.10 per alarm metric (each metric listed in a metric math expression counts)
High resolution metric alarm (period under 60 seconds) $0.30 per alarm metric
Anomaly detection alarm The metric plus 2 band metrics, so $0.30 at standard resolution
Composite alarm $0.50
Log alarm $0.10 plus query charges for data scanned

The free tier covers 10 standard-resolution alarm metrics. Worked example: an account with 40 silent standard alarms, 5 high-resolution ones and 2 composite alarms pays 40 × $0.10 + 5 × $0.30 + 2 × $0.50 = $4.00 + $1.50 + $1.00 = $6.50 a month, or $78 a year. The money is small. The real cost is false confidence: a dashboard full of green alarms that would never have woken anyone. To see your whole CloudWatch line item, run the script that reports the total CloudWatch cost for the current month.

What does the script do?

  1. Lists every alarm typepaginateDescribeAlarms with AlarmTypes set to metric, composite and log alarms. Without that parameter the API returns metric alarms only.
  2. Maps composite childrenEach AlarmRule is parsed for ALARM(), OK() and INSUFFICIENT_DATA() references.
  3. Checks targetsFor alarms whose actions are all SNS topics, it calls GetTopicAttributes once per topic. NotFoundException means deleted; any other error (for example a topic in another account) counts as unknown, so the alarm isn’t flagged.
  4. Estimates the priceFrom the metric count, the period and whether it’s an anomaly detection or composite alarm.
  5. Deletes only on requestWith --names it previews; with --apply as well it calls DeleteAlarms one name at a time, because one call can hold only one composite alarm and log alarms can’t be batch deleted.

Lambda, EC2, Auto Scaling and Systems Manager targets aren’t validated; an alarm with any non-SNS action is treated as able to act. If your alarms mostly notify through SNS, the guide to publishing SNS messages with AWS SDK v3 is a quick way to send a test message to a topic you’re unsure about.

Prerequisites

  • Node.js 18 or later, tsx, @aws-sdk/client-cloudwatch and @aws-sdk/client-sns.
  • A read-only profile for the report and a separate one for --apply; setting up AWS profiles, SSO and assumed roles walks through both.
  • The Regions where your alarms live. Alarms are Regional, so pass every Region you deploy to.

Which IAM permissions does it need?

cloudwatch-alarm-actions-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadAlarms",
      "Effect": "Allow",
      "Action": "cloudwatch:DescribeAlarms",
      "Resource": "*"
    },
    {
      "Sid": "CheckAlarmTopics",
      "Effect": "Allow",
      "Action": "sns:GetTopicAttributes",
      "Resource": "arn:aws:sns:*:123456789012:*"
    },
    {
      "Sid": "DeleteOnlyWithApply",
      "Effect": "Allow",
      "Action": "cloudwatch:DeleteAlarms",
      "Resource": "arn:aws:cloudwatch:*:123456789012:alarm:*"
    }
  ]
}

Replace the account ID. DescribeAlarms must be allowed on "*": AWS returns composite alarms only when that permission isn’t narrowed. Drop the last statement for a report-only role. To check a policy after you extend the script, paste the code into the IAM policy generator for TypeScript AWS SDK code.

The script to find CloudWatch alarms with no actions

find-cloudwatch-alarms-without-actions.ts

// find-cloudwatch-alarms-without-actions.ts
// Lists metric, composite and log alarms that can never notify anyone: no actions at all, actions
// switched off (ActionsEnabled = false), or every action pointing at an SNS topic that no longer exists.
// Alarms used as children of a composite alarm are reported but never deleted.
// Report only by default. --apply --names a,b deletes only the named alarms, and only if they are flagged.
// Usage: npx tsx find-cloudwatch-alarms-without-actions.ts [--regions us-east-1,eu-west-1]
//        [--names alarm-a,alarm-b [--apply]]
import {
  CloudWatchClient,
  DeleteAlarmsCommand,
  paginateDescribeAlarms,
  type MetricAlarm,
} from "@aws-sdk/client-cloudwatch";
import { SNSClient, GetTopicAttributesCommand, NotFoundException } from "@aws-sdk/client-sns";

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 list = (v: string | undefined): string[] => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const regions = list(flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1");
const toDelete = new Set(list(flag("--names")));
const apply = args.includes("--apply");

// us-east-1 list prices per alarm per month (AWS Price List, September 2026). Other Regions can differ.
const PRICE = { standardMetric: 0.1, highResMetric: 0.3, composite: 0.5, logAlarm: 0.1 };

type Kind = "metric" | "composite" | "log";
type Problem = "no actions" | "actions disabled" | "all targets deleted";
interface Row {
  Region: string;
  Alarm: string;
  Kind: Kind;
  State: string;
  Problem: Problem;
  ChildOf: string;
  PerMonth: string;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

/** Estimated monthly list price of one alarm (before the 10-alarm-metric free tier). */
function metricAlarmPrice(a: MetricAlarm): string {
  const queries = a.Metrics ?? [];
  if (queries.some((q) => /SELECT\s/i.test(q.Expression ?? ""))) return "per metric analyzed";
  const metricCount = queries.length === 0 ? 1 : queries.filter((q) => q.MetricStat).length;
  const bands = a.ThresholdMetricId ? 2 : 0; // anomaly detection adds upper and lower band metrics
  const periods = [a.Period, ...queries.map((q) => q.MetricStat?.Period ?? q.Period)].filter((p): p is number => p !== undefined);
  const highRes = periods.some((p) => p < 60);
  const perMetric = highRes ? PRICE.highResMetric : PRICE.standardMetric;
  return `$${((metricCount + bands) * perMetric).toFixed(2)}`;
}

/** Names referenced in composite alarm rules: ALARM(x), OK("x"), INSUFFICIENT_DATA(arn). */
function childrenOf(rule: string): string[] {
  const names: string[] = [];
  for (const m of rule.matchAll(/\b(?:ALARM|OK|INSUFFICIENT_DATA)\(\s*("?)(.+?)\1\s*\)/g)) {
    const ref = m[2].trim();
    names.push(ref.startsWith("arn:") ? ref.slice(ref.lastIndexOf(":alarm:") + 7) : ref);
  }
  return names;
}

// ---- SNS: does each topic an alarm points at still exist? ----
const topicCache = new Map<string, "exists" | "deleted" | "unknown">();
async function topicState(arn: string): Promise<"exists" | "deleted" | "unknown"> {
  const cached = topicCache.get(arn);
  if (cached) return cached;
  const region = arn.split(":")[3];
  let state: "exists" | "deleted" | "unknown" = "unknown";
  try {
    await new SNSClient({ region }).send(new GetTopicAttributesCommand({ TopicArn: arn }));
    state = "exists";
  } catch (err) {
    state = err instanceof NotFoundException ? "deleted" : "unknown"; // e.g. no access to another account's topic
  }
  topicCache.set(arn, state);
  return state;
}

async function problemWith(enabled: boolean | undefined, actions: string[]): Promise<Problem | undefined> {
  if (actions.length === 0) return "no actions";
  if (enabled === false) return "actions disabled";
  const topics = actions.filter((a) => a.startsWith("arn:aws:sns:"));
  if (topics.length !== actions.length) return undefined; // Lambda, EC2, Auto Scaling, SSM targets: not checked
  const states = await Promise.all(topics.map(topicState));
  return states.every((s) => s === "deleted") ? "all targets deleted" : undefined;
}

async function scanRegion(region: string): Promise<Row[]> {
  const cw = new CloudWatchClient({ region });
  const rows: Row[] = [];
  const parents = new Map<string, string[]>(); // child alarm name -> composite alarms that use it
  const pending: { name: string; kind: Kind; state: string; problem: Problem; price: string }[] = [];

  const pages = paginateDescribeAlarms(
    { client: cw, pageSize: 100 },
    { AlarmTypes: ["MetricAlarm", "CompositeAlarm", "LogAlarm"] },
  );
  for await (const page of pages) {
    for (const c of page.CompositeAlarms ?? []) {
      for (const child of childrenOf(c.AlarmRule ?? "")) parents.set(child, [...(parents.get(child) ?? []), c.AlarmName ?? ""]);
      const actions = [...(c.AlarmActions ?? []), ...(c.OKActions ?? []), ...(c.InsufficientDataActions ?? [])];
      const problem = await problemWith(c.ActionsEnabled, actions);
      if (problem) pending.push({ name: c.AlarmName ?? "", kind: "composite", state: c.StateValue ?? "", problem, price: `$${PRICE.composite.toFixed(2)}` });
    }
    for (const m of page.MetricAlarms ?? []) {
      const actions = [...(m.AlarmActions ?? []), ...(m.OKActions ?? []), ...(m.InsufficientDataActions ?? [])];
      const problem = await problemWith(m.ActionsEnabled, actions);
      if (problem) pending.push({ name: m.AlarmName ?? "", kind: "metric", state: m.StateValue ?? "", problem, price: metricAlarmPrice(m) });
    }
    for (const l of page.LogAlarms ?? []) {
      const actions = [...(l.AlarmActions ?? []), ...(l.OKActions ?? []), ...(l.InsufficientDataActions ?? [])];
      const problem = await problemWith(l.ActionsEnabled, actions);
      if (problem) pending.push({ name: l.AlarmName ?? "", kind: "log", state: l.StateValue ?? "", problem, price: `$${PRICE.logAlarm.toFixed(2)} + queries` });
    }
  }
  for (const p of pending) {
    rows.push({
      Region: region,
      Alarm: p.name,
      Kind: p.kind,
      State: p.state,
      Problem: p.problem,
      ChildOf: (parents.get(p.name) ?? []).join(", "),
      PerMonth: p.price,
    });
  }
  return rows;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: ${errorText(err)}`);
    }
  }
  if (rows.length === 0) {
    console.log(`Every alarm in ${regions.join(", ")} has at least one enabled action with a live target.`);
    return;
  }
  const order: Record<Problem, number> = { "all targets deleted": 0, "actions disabled": 1, "no actions": 2 };
  rows.sort((a, b) => order[a.Problem] - order[b.Problem] || a.Alarm.localeCompare(b.Alarm));
  console.table(rows);
  const standalone = rows.filter((r) => !r.ChildOf);
  console.log(`${rows.length} alarms can't notify anyone; ${standalone.length} are not used by a composite alarm.`);

  if (toDelete.size === 0) return;
  for (const name of toDelete) {
    const row = rows.find((r) => r.Alarm === name);
    if (!row) {
      console.log(`skip ${name}: not flagged in this report`);
      continue;
    }
    if (row.ChildOf) {
      console.log(`skip ${name}: used by composite alarm ${row.ChildOf}`);
      continue;
    }
    if (!apply) {
      console.log(`would delete ${row.Kind} alarm ${name} in ${row.Region} (re-run with --apply)`);
      continue;
    }
    try {
      // One name per call: a DeleteAlarms call may hold only one composite alarm, and log alarms can't be batch deleted.
      await new CloudWatchClient({ region: row.Region }).send(new DeleteAlarmsCommand({ AlarmNames: [name] }));
      console.log(`deleted ${name} in ${row.Region}`);
    } catch (err) {
      console.error(`delete ${name}: ${errorText(err)}`);
    }
  }
}

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

The paginator handles NextToken for you with pages of up to 100 alarms; the guide to AWS SDK v3 paginators explains the pattern if you want to adapt it.

How do you run it?

Terminal

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

# Report for two Regions
AWS_PROFILE=readonly npx tsx find-cloudwatch-alarms-without-actions.ts --regions us-east-1,eu-west-1

# Preview, then delete two alarms
AWS_PROFILE=readonly npx tsx find-cloudwatch-alarms-without-actions.ts --names checkout-latency-p99,old-migration-watch
AWS_PROFILE=ops-admin npx tsx find-cloudwatch-alarms-without-actions.ts --names checkout-latency-p99,old-migration-watch --apply

Sample output

Output

┌─────────┬─────────────┬───────────────────────────┬─────────────┬─────────────────────┬───────────────────────┬───────────────────────┬───────────────────┐
│ (index) │ Region      │ Alarm                     │ Kind        │ State               │ Problem               │ ChildOf               │ PerMonth          │
├─────────┼─────────────┼───────────────────────────┼─────────────┼─────────────────────┼───────────────────────┼───────────────────────┼───────────────────┤
│ 0       │ 'us-east-1' │ 'checkout-latency-p99'    │ 'metric'    │ 'OK'                │ 'all targets deleted' │ ''                    │ '$0.30'           │
│ 1       │ 'us-east-1' │ 'db-cpu-high'             │ 'metric'    │ 'OK'                │ 'actions disabled'    │ ''                    │ '$0.10'           │
│ 2       │ 'us-east-1' │ 'auth-failed-logins'      │ 'log'       │ 'OK'                │ 'no actions'          │ ''                    │ '$0.10 + queries' │
│ 3       │ 'us-east-1' │ 'db-connections-high'     │ 'metric'    │ 'OK'                │ 'no actions'          │ 'db-health'           │ '$0.10'           │
│ 4       │ 'us-east-1' │ 'old-migration-watch'     │ 'composite' │ 'OK'                │ 'no actions'          │ ''                    │ '$0.50'           │
│ 5       │ 'us-east-1' │ 'orders-queue-depth'      │ 'metric'    │ 'ALARM'             │ 'no actions'          │ 'old-migration-watch' │ '$0.10'           │
│ 6       │ 'us-east-1' │ 'payments-errors-anomaly' │ 'metric'    │ 'INSUFFICIENT_DATA' │ 'no actions'          │ ''                    │ '$0.30'           │
└─────────┴─────────────┴───────────────────────────┴─────────────┴─────────────────────┴───────────────────────┴───────────────────────┴───────────────────┘
7 alarms can't notify anyone; 5 are not used by a composite alarm.
would delete metric alarm checkout-latency-p99 in us-east-1 (re-run with --apply)
skip db-connections-high: used by composite alarm db-health

This run used mocked CloudWatch and SNS responses. checkout-latency-p99 is a high-resolution alarm whose only topic was deleted: the most dangerous row, because its configuration looks complete. db-connections-high has no actions, but the db-health composite alarm uses it, so the script refuses to delete it. orders-queue-depth is in ALARM right now and nobody has been told.

Fix or delete: what should you do with each alarm?

  • All targets deleted. Point the alarm at a live topic with PutMetricAlarm (it replaces the whole configuration, so pass every field back). Consider using DescribeAlarms with ActionPrefix set to the old topic ARN before you delete a topic next time.
  • Actions disabled. Call EnableAlarmActions if the maintenance is over. For planned windows, alarm mute rules unmute on their own, which avoids the forgotten-switch problem.
  • No actions, not a child. Decide whether anyone needs it. Alarms watching Lambda errors usually do; see investigating Lambda errors with CloudWatch for which signals are worth an alert. Otherwise delete it.
  • No actions, child of a composite. Leave it. If the composite itself is silent, fix or delete the composite first.

Silent alarms tend to travel with other monitoring leftovers. The scripts to find unused CloudWatch dashboards and find empty CloudWatch log groups clean up the rest. If the alarm guards spend rather than health, an AWS Budgets alert created with SDK v3 is often the better tool.

Troubleshooting

  • Composite alarms are missing from the report. The role’s cloudwatch:DescribeAlarms permission is scoped to specific alarms. Allow it on "*".
  • An alarm with a working topic is flagged as deleted. The ARN’s Region is used for the lookup; check that the topic exists in that Region and account.
  • A cross-account topic never gets flagged. Without access, the script can’t tell deleted from forbidden and treats the topic as unknown. Run it in the topic’s account, or check by hand.
  • ResourceNotFound on delete. The alarm was removed between the report and the delete. DeleteAlarms still deletes the correctly named alarms in a request that also names missing ones, but re-run the report to be sure.
  • Access denied on DeleteAlarms or GetTopicAttributes. Compare the role with the policy above, then work through troubleshooting AWS IAM access denied errors.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “Which CloudWatch alarms in this Region have no alarm actions?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result; how ChatWithCloud runs AWS queries locally explains the loop. It uses one profile and Region per session, can be wrong, and runs changes without asking for confirmation, so use a read-only profile and keep deletions in the script. More reports like this live in the library of practical AWS examples.

Frequently asked questions

Does a CloudWatch alarm with no actions still cost money?

Yes. Alarms are billed per hour of existence by type and metric count, whether or not they have actions. In us-east-1 a standard single-metric alarm is $0.10 a month after the free tier’s 10 alarm metrics.

How do I find alarms that notify a specific SNS topic?

Call DescribeAlarms with ActionPrefix set to the topic ARN. Do it before deleting a topic, so you can move those alarms to a new one.

Why doesn’t DescribeAlarms return my composite alarms?

Without AlarmTypes it returns metric alarms only. Pass CompositeAlarm (and LogAlarm if you use them), and make sure the permission is allowed on all resources.

Can I delete a metric alarm that a composite alarm uses?

The script refuses, because the composite rule would lose its input. Change the composite alarm’s rule or delete the composite first, then remove the child.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud