Find Idle Amazon Redshift Clusters

A long, empty warehouse aisle between tall metal shelves under dim ceiling lights

Photo by Brian Wangenheim on Unsplash

To find idle Redshift clusters, list them with DescribeClusters, then read the DatabaseConnections and CPUUtilization metrics in the AWS/Redshift CloudWatch namespace for the last 14 days. A cluster with zero connections in that window is idle. Multiply its node count by the node-hour price to see what it costs, then pause it or delete it with a final snapshot.

A provisioned Redshift cluster bills for every node, every hour it runs, whether anyone queries it or not. Proof-of-concept warehouses, staging copies and the cluster a departed team used for quarterly reports are some of the most expensive idle resources in an AWS account, because a single node can cost more than a month of a small RDS instance.

This example is for platform and FinOps engineers who want a list, not a hunch. The script scans every Region you pass, reports each cluster’s node type, node count, state, peak connections and CPU, and estimates its monthly compute cost. It only reads. It pairs with the script to find idle RDS instances with no connections, which applies the same connection test to operational databases.

What does an idle Redshift cluster cost?

As of September 2026, the Amazon Redshift pricing page and the AWS Price List show these on-demand prices in US East (N. Virginia):

Node type Price per node-hour Per node, 730-hour month
dc2.large $0.25 $182.50
ra3.large $0.543 $396.39
ra3.xlplus $1.086 $792.78
ra3.4xlarge $3.26 $2,379.80
ra3.16xlarge $13.04 $9,519.20
Redshift Managed Storage (RA3) $0.024 per GB-month

Worked example: a two-node ra3.xlplus cluster left over from a proof of concept costs 2 × $1.086 × 730 = $1,585.56 a month in compute, plus 500 GB of managed storage at 500 × $0.024 = $12.00. Pausing it removes the $1,585.56; deleting it removes both, leaving only the final snapshot. One exception: reserved nodes are charged for their whole term even if the cluster is paused or gone, so check reservations before you count the savings. Your own numbers by service are in the script to get last month’s AWS cost broken down by service.

Which metrics show a Redshift cluster is idle?

Metric Dimension What the script reads
DatabaseConnections ClusterIdentifier Daily maximum. Zero for 14 days means no client, BI tool or ETL job connected
CPUUtilization ClusterIdentifier Average and maximum across leader and compute nodes. Low CPU with a couple of connections usually means an idle BI tool holding a session open

Connections are the stronger signal. QueriesCompletedPerSecond looks tempting, but AWS doesn’t publish it for single-node clusters and it’s split by latency and queue dimensions, so it’s easy to miss traffic. Paused clusters publish no hardware metrics at all, which is why the script reports them separately instead of calling them idle.

What does the script do?

  1. Lists clusters per RegionpaginateDescribeClusters returns the identifier, NodeType, NumberOfNodes and ClusterStatus, which is available, paused or a transitional state such as modifying.
  2. Reads 14 days of metrics in one batchOne GetMetricData call covers up to 500 queries, three per cluster, with one datapoint per day.
  3. Classifies each clusterZero connections is IDLE; at most 2 connections with average CPU under 5% is BARELY USED; paused clusters and clusters in transition are labeled, not judged.
  4. Prices itNode-hour price × nodes × 730 hours, from a small price table in the script. Unknown node types say so instead of guessing.
  5. Writes a reportA console table, a total for idle clusters and an optional CSV. Nothing is paused or deleted.

Prerequisites

Which IAM permissions does it need?

idle-redshift-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadRedshiftClusters",
      "Effect": "Allow",
      "Action": "redshift:DescribeClusters",
      "Resource": "*"
    },
    {
      "Sid": "ReadRedshiftMetrics",
      "Effect": "Allow",
      "Action": "cloudwatch:GetMetricData",
      "Resource": "*"
    }
  ]
}

The report needs no write permissions, and AWS’s ReadOnlyAccess managed policy already includes both actions. If you adapt the script and aren’t sure which actions it now calls, paste it into the IAM policy generator for TypeScript code and review the result.

The script to find idle Redshift clusters

find-idle-redshift-clusters.ts

// find-idle-redshift-clusters.ts
// Lists every provisioned Amazon Redshift cluster with its node type and count, state (available or paused),
// the most connections and the CPU it used over the last N days, and an on-demand monthly cost estimate.
// Read-only: it never pauses, snapshots or deletes a cluster.
// Usage: npx tsx find-idle-redshift-clusters.ts [--regions us-east-1,eu-west-1] [--days 14] [--csv idle-redshift.csv]
import { writeFileSync } from "node:fs";
import { RedshiftClient, paginateDescribeClusters, type Cluster } from "@aws-sdk/client-redshift";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";

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") ?? 14);
const csvPath = flag("--csv");
const LOW_CPU = 5; // percent: average CPU below this with almost no connections is "barely used"

// On-demand price per node-hour in us-east-1, from the AWS Price List (checked September 2026).
// Other Regions differ; reserved nodes are billed for their term whatever the cluster does.
const NODE_HOUR: Record<string, number> = {
  "dc2.large": 0.25,
  "dc2.8xlarge": 4.8,
  "ra3.large": 0.543,
  "ra3.xlplus": 1.086,
  "ra3.4xlarge": 3.26,
  "ra3.16xlarge": 13.04,
};
const HOURS_PER_MONTH = 730;

interface Row {
  Region: string;
  Cluster: string;
  NodeType: string;
  Nodes: number;
  Status: string;
  MaxConnections: number | string;
  AvgCpu: number | string;
  MaxCpu: number | string;
  ComputePerMonth: string;
  Verdict: string;
}

async function listClusters(region: string): Promise<Cluster[]> {
  const client = new RedshiftClient({ region });
  const clusters: Cluster[] = [];
  for await (const page of paginateDescribeClusters({ client }, { MaxRecords: 100 })) {
    clusters.push(...(page.Clusters ?? []));
  }
  return clusters;
}

interface Usage {
  maxConnections: number;
  avgCpu: number;
  maxCpu: number;
  datapoints: number;
}

async function measure(region: string, ids: string[]): Promise<Map<string, Usage>> {
  const cw = new CloudWatchClient({ region });
  const end = new Date();
  const start = new Date(end.getTime() - days * 86_400_000);
  const q = (id: string, cluster: string, MetricName: string, Stat: string): MetricDataQuery => ({
    Id: id,
    MetricStat: {
      Metric: { Namespace: "AWS/Redshift", MetricName, Dimensions: [{ Name: "ClusterIdentifier", Value: cluster }] },
      Period: 86_400, // one datapoint per day
      Stat,
    },
    ReturnData: true,
  });
  const queries = ids.flatMap((cluster, i) => [
    q(`conn${i}`, cluster, "DatabaseConnections", "Maximum"),
    q(`cpuavg${i}`, cluster, "CPUUtilization", "Average"),
    q(`cpumax${i}`, cluster, "CPUUtilization", "Maximum"),
  ]);

  const usage = new Map<string, Usage>();
  ids.forEach((id) => usage.set(id, { maxConnections: 0, avgCpu: 0, maxCpu: 0, datapoints: 0 }));
  for (let n = 0; n < queries.length; n += 500) { // GetMetricData accepts up to 500 queries per call
    let NextToken: string | undefined;
    do {
      const res = await cw.send(new GetMetricDataCommand({
        MetricDataQueries: queries.slice(n, n + 500), StartTime: start, EndTime: end, NextToken,
      }));
      for (const r of res.MetricDataResults ?? []) {
        const match = /^(conn|cpuavg|cpumax)(\d+)$/.exec(r.Id ?? "");
        if (!match) continue;
        const u = usage.get(ids[Number(match[2])]);
        const values = r.Values ?? [];
        if (!u || values.length === 0) continue;
        if (match[1] === "conn") {
          u.maxConnections = Math.max(u.maxConnections, ...values);
          u.datapoints += values.length;
        }
        if (match[1] === "cpuavg") u.avgCpu = values.reduce((a, b) => a + b, 0) / values.length;
        if (match[1] === "cpumax") u.maxCpu = Math.max(u.maxCpu, ...values);
      }
      NextToken = res.NextToken;
    } while (NextToken);
  }
  return usage;
}

function verdict(status: string, u: Usage | undefined): string {
  if (status === "paused") return "PAUSED: storage billed only";
  if (status !== "available") return `skipped: ${status}`;
  if (!u || u.datapoints === 0) return "no metrics: check the cluster age";
  if (u.maxConnections === 0) return "IDLE: no connections";
  if (u.maxConnections <= 2 && u.avgCpu < LOW_CPU) return "BARELY USED: check who connects";
  return "in use";
}

async function scanRegion(region: string): Promise<Row[]> {
  const clusters = await listClusters(region);
  if (clusters.length === 0) return [];
  const ids = clusters.map((c) => c.ClusterIdentifier ?? "");
  const usage = await measure(region, ids);
  return clusters.map((c) => {
    const id = c.ClusterIdentifier ?? "";
    const status = c.ClusterStatus ?? "unknown";
    const nodeType = c.NodeType ?? "";
    const nodes = c.NumberOfNodes ?? 1;
    const u = usage.get(id);
    const price = NODE_HOUR[nodeType];
    const running = status === "available";
    return {
      Region: region,
      Cluster: id,
      NodeType: nodeType,
      Nodes: nodes,
      Status: status,
      MaxConnections: running && u ? u.maxConnections : "-",
      AvgCpu: running && u ? Math.round(u.avgCpu * 10) / 10 : "-",
      MaxCpu: running && u ? Math.round(u.maxCpu * 10) / 10 : "-",
      ComputePerMonth: !running ? "$0 (not running)" : price === undefined ? "unknown node type" : `$${(price * nodes * HOURS_PER_MONTH).toFixed(0)}`,
      Verdict: verdict(status, u),
    };
  });
}

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)}`);
    }
  }
  if (rows.length === 0) {
    console.log(`No provisioned Redshift clusters in ${regions.join(", ")}.`);
    return;
  }
  console.table(rows);
  const idle = rows.filter((r) => r.Verdict.startsWith("IDLE") || r.Verdict.startsWith("BARELY"));
  const monthly = idle.reduce((sum, r) => sum + (NODE_HOUR[r.NodeType] ?? 0) * r.Nodes * HOURS_PER_MONTH, 0);
  console.log(`${idle.length} of ${rows.length} clusters idle or barely used over ${days} days: about $${monthly.toFixed(0)} a month in on-demand compute (us-east-1 prices).`);
  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-redshift @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node

# Report on two Regions and keep a CSV for the owners
AWS_PROFILE=readonly npx tsx find-idle-redshift-clusters.ts --regions us-east-1,eu-west-1 --csv idle-redshift.csv

# A longer window for monthly reporting clusters
AWS_PROFILE=readonly npx tsx find-idle-redshift-clusters.ts --regions us-east-1 --days 45

Sample output

Output

┌─────────┬─────────────┬─────────────────┬───────────────┬───────┬─────────────┬────────────────┬────────┬────────┬────────────────────┬───────────────────────────────────┐
│ (index) │ Region      │ Cluster         │ NodeType      │ Nodes │ Status      │ MaxConnections │ AvgCpu │ MaxCpu │ ComputePerMonth    │ Verdict                           │
├─────────┼─────────────┼─────────────────┼───────────────┼───────┼─────────────┼────────────────┼────────┼────────┼────────────────────┼───────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'bi-prod'       │ 'ra3.4xlarge' │ 4     │ 'available' │ 38             │ 41.2   │ 97.5   │ '$9519'            │ 'in use'                          │
│ 1       │ 'us-east-1' │ 'analytics-poc' │ 'ra3.xlplus'  │ 2     │ 'available' │ 0              │ 1.3    │ 4.8    │ '$1586'            │ 'IDLE: no connections'            │
│ 2       │ 'us-east-1' │ 'finance-q-end' │ 'ra3.xlplus'  │ 2     │ 'paused'    │ '-'            │ '-'    │ '-'    │ '$0 (not running)' │ 'PAUSED: storage billed only'     │
│ 3       │ 'eu-west-1' │ 'staging-dw'    │ 'dc2.large'   │ 1     │ 'available' │ 2              │ 2.1    │ 9.4    │ '$183'             │ 'BARELY USED: check who connects' │
└─────────┴─────────────┴─────────────────┴───────────────┴───────┴─────────────┴────────────────┴────────┴────────┴────────────────────┴───────────────────────────────────┘
2 of 4 clusters idle or barely used over 14 days: about $1768 a month in on-demand compute (us-east-1 prices).
Wrote 4 rows to idle-redshift.csv

Names and numbers are illustrative. analytics-poc had no connections at all and costs $1,585.56 a month; its CPU never passed 5%, which fits a cluster with no user workload. staging-dw has two connections that never do much: often a BI tool or SQL client left connected. The eu-west-1 price is an estimate from us-east-1 rates, so check the pricing page for that Region.

Should you pause or delete an idle Redshift cluster?

Option What you keep paying Use it when
Pause Storage only; on-demand compute billing is suspended The cluster is needed on a schedule, such as month-end reports. Add a scheduled pause and resume action
Delete with a final snapshot The snapshot’s backup storage Nobody has claimed it after you ask. You can restore the snapshot to a new cluster later
Move to Redshift Serverless RPU-hours while queries run, plus storage Usage is real but bursty, with long quiet periods

Pausing has limits worth knowing, per the Amazon Redshift Management Guide section on pausing and resuming clusters: you can’t pause a cluster that has automated snapshots turned off, an HSM cluster or one that is modifying; a pause can’t be cancelled once started; and when you resume, node IP addresses may change and query performance can dip while data is re-hydrated. Before deleting, run aws redshift delete-cluster --cluster-identifier analytics-poc --final-cluster-snapshot-identifier analytics-poc-final. If you delete a paused cluster without a final snapshot, you can’t restore it. Manual snapshots are billed until you remove them, so keep an eye on them the way you would with the script to find and delete old RDS manual snapshots.

Redshift Serverless workgroups aren’t clusters, so DescribeClusters doesn’t list them and this script ignores them. As of September 2026, Serverless bills compute in RPU-hours per second with a 60-second minimum, at $0.375 per RPU-hour in us-east-1, and scales down when there’s no activity. An idle workgroup mostly costs storage.

What should you check before you act?

  • Who connects, and when? Connect as a superuser and run SELECT username, query_type, start_time FROM sys_query_history ORDER BY start_time DESC LIMIT 20;. Regular users only see their own rows, so a non-superuser can see an empty history on a busy cluster.
  • Datashares and Spectrum. A producer cluster that shares data with consumer clusters may show few connections of its own while others still read its data.
  • Monthly and quarterly jobs. A 14-day window misses a month-end load. Re-run with --days 45 before deleting anything that sounds like finance.
  • Owners. Tags tell you who to ask. The script to find untagged AWS resources shows clusters nobody has claimed.

Idle warehouses rarely come alone. The Spark and Hive clusters that load them can go quiet too, and the script to find idle Amazon EMR clusters flags them from the IsIdle metric. The caches in front of them often go quiet too; the script to find idle ElastiCache clusters covers those, and an AWS budget alert created with SDK v3 tells you when a new cluster starts adding up. Migrations leave the same trail: the script to find idle AWS DMS replication instances finds replication instances whose tasks have stopped or finished. Streaming pipelines that loaded the warehouse can outlive it as well; the script to find idle Amazon MSK clusters reports brokers nobody writes to or reads from.

Troubleshooting

  • “no metrics: check the cluster age”. The cluster is newer than the window, or was resumed recently. Paused periods produce no datapoints.
  • Every cluster shows a few connections. Look at sys_query_history by user. Monitoring agents and BI tools often keep one or two sessions open; the script’s BARELY USED label exists for this case.
  • AccessDenied on DescribeClusters or GetMetricData. An SCP or permission boundary may block one Region. The steps to troubleshoot AWS IAM access denied errors show how to read the message.
  • “unknown node type”. Add the node-hour price for that type and Region to NODE_HOUR. The Price List also includes newer RG node types.

Ask ChatWithCloud instead

For a one-off way to find idle Redshift clusters without installing anything, ask ChatWithCloud “Which Redshift clusters had no database connections in the last 14 days?” 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 locally shows the loop. It works with one profile and Region per session and runs changes without a confirmation step, so connect ChatWithCloud to a read-only AWS profile and leave pausing and deleting to a person. More scripts like this one are on the AWS practical examples hub.

Frequently asked questions

How do I know if a Redshift cluster is being used?

Check the DatabaseConnections CloudWatch metric for the cluster over at least 14 days. Zero means nothing connected. For who ran what, query sys_query_history as a superuser.

Do you pay for a paused Redshift cluster?

Only for storage. On-demand compute billing stops while the cluster is paused. Reserved nodes are the exception: you pay for them for the whole term, paused or not.

Can I restore a deleted Redshift cluster?

Only from a snapshot. Create a final snapshot when you delete it, then restore that snapshot to a new cluster when you need the data again.

Is Redshift Serverless cheaper than an idle cluster?

For bursty or rarely used workloads, usually yes, because Serverless bills compute only while queries run. For steady, all-day workloads, a provisioned cluster or reserved nodes can cost less. Compare with your own query hours.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud