Find Idle Amazon OpenSearch Service Domains

Tall wooden bookshelves in a quiet, dimly lit library with no one in the aisles

Photo by Anastasia Meraki on Unsplash

To find idle OpenSearch domains, list them with ListDomainNames and DescribeDomains, then sum the SearchRate and IndexingRate metrics in the AWS/ES CloudWatch namespace over 14 days, using the DomainName and ClientId dimensions. A domain with no searches and no indexing is idle. Take a manual snapshot before you delete it.

An Amazon OpenSearch Service domain bills for every data node, dedicated master node, UltraWarm node and gigabyte of EBS storage, every hour, with no way to pause it. Search experiments, log clusters for retired apps and the Elasticsearch domain nobody upgraded keep running long after anyone looks at them.

This example is for platform and FinOps engineers. The script reports every domain in the Regions you pass with its node layout, storage, 14 days of search and indexing activity and a rough monthly cost, and it only reads. If you’re already cleaning up data stores, the script to find idle ElastiCache clusters uses the same approach for caches.

What makes an OpenSearch domain idle?

Metric What it counts Reading
SearchRate Search requests per minute for all shards on a data node, so one request can count once per shard Zero for 14 days: nobody ran a search
IndexingRate Indexing operations per minute; deletions don’t count Zero for 14 days: nothing ships data into it

Both zero means idle. Indexing with no searches is a different finding: a write-only domain, usually a log pipeline nobody reads. You may still need it for audit reasons, but it’s a candidate for shorter retention, fewer replicas or UltraWarm. Cluster-level metrics use two dimensions: DomainName and ClientId, which is your account ID. Miss ClientId and CloudWatch returns nothing, which looks like idle. The OpenSearch Service developer guide says the service keeps two weeks of these metrics, so the script caps the window at 14 days.

How much does an idle domain cost?

As of September 2026, the AWS Price List shows these on-demand prices in US East (N. Virginia):

Item Price 730-hour month
t3.small.search $0.036 per hour $26.28
m6g.large.search $0.128 per hour $93.44
r6g.large.search $0.167 per hour $121.91
r7g.large.search $0.178 per hour $129.94
ultrawarm1.medium.search $0.238 per hour $173.74
gp3 EBS storage $0.122 per GB-month per GB
UltraWarm managed storage $0.024 per GB-month per GB

Worked example: a production-style domain with 3 × r6g.large.search data nodes, 3 × m6g.large.search dedicated masters and 200 GB of gp3 per data node costs (3 × $0.167 + 3 × $0.128) × 730 + 600 × $0.122 = $365.73 + $280.32 + $73.20 = $719.25 a month. Idle, every cent of that is waste. Dedicated masters are a large share of the bill on small domains, which is why they’re listed separately in the report.

What does the script do?

  1. Finds your account IDGetCallerIdentity returns the account ID the ClientId dimension needs.
  2. Lists and describes domainsListDomainNames returns OpenSearch and legacy Elasticsearch domains; DescribeDomains, five names at a time, returns ClusterConfig (instance type and count, dedicated masters, UltraWarm) and EBSOptions. Domains marked Deleted are skipped.
  3. Sums 14 days of activityOne GetMetricData batch reads hourly sums of SearchRate and IndexingRate per domain.
  4. Estimates costNodes × hourly price × 730, plus gp3 storage. Other volume types and unknown instance types are flagged, not guessed.
  5. Reports onlyA console table and optional CSV. It never snapshots, resizes or deletes.

Prerequisites

Which IAM permissions does it need?

idle-opensearch-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListDomains",
      "Effect": "Allow",
      "Action": "es:ListDomainNames",
      "Resource": "*"
    },
    {
      "Sid": "DescribeDomains",
      "Effect": "Allow",
      "Action": "es:DescribeDomains",
      "Resource": "arn:aws:es:*:123456789012:domain/*"
    },
    {
      "Sid": "ReadMetrics",
      "Effect": "Allow",
      "Action": "cloudwatch:GetMetricData",
      "Resource": "*"
    }
  ]
}

OpenSearch Service still uses the es: prefix for its IAM actions and ARNs. Replace the account ID with yours; sts:GetCallerIdentity needs no permission. If a Region returns AccessDenied, the guide to troubleshoot AWS IAM access denied errors step by step helps you find the blocking policy, and the free IAM policy generator for TypeScript lists the actions of any changed version of the script.

The script to find idle OpenSearch domains

find-idle-opensearch-domains.ts

// find-idle-opensearch-domains.ts
// Lists every Amazon OpenSearch Service domain (OpenSearch and legacy Elasticsearch) with its data nodes,
// dedicated master nodes, UltraWarm nodes and EBS storage, the search and indexing activity CloudWatch
// recorded over the last N days, and a rough on-demand monthly cost. Read-only: it never changes or deletes a domain.
// Usage: npx tsx find-idle-opensearch-domains.ts [--regions us-east-1,eu-west-1] [--days 14] [--csv idle-opensearch.csv]
import { writeFileSync } from "node:fs";
import { DescribeDomainsCommand, ListDomainNamesCommand, OpenSearchClient, type DomainStatus } from "@aws-sdk/client-opensearch";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
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 regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Math.min(Number(flag("--days") ?? 14), 14); // OpenSearch Service documents two weeks of metric history
const csvPath = flag("--csv");

// On-demand prices in us-east-1 from the AWS Price List (checked September 2026). Other Regions differ.
const INSTANCE_HOUR: Record<string, number> = {
  "t3.small.search": 0.036,
  "t3.medium.search": 0.073,
  "m5.large.search": 0.142,
  "m6g.large.search": 0.128,
  "m7g.large.search": 0.135,
  "c6g.large.search": 0.113,
  "r5.large.search": 0.186,
  "r6g.large.search": 0.167,
  "r6g.xlarge.search": 0.335,
  "r7g.large.search": 0.178,
  "or1.large.search": 0.209,
  "ultrawarm1.medium.search": 0.238,
};
const GP3_GB_MONTH = 0.122;
const HOURS_PER_MONTH = 730;

interface Row {
  Region: string;
  Domain: string;
  Engine: string;
  DataNodes: string;
  Masters: string;
  Warm: string;
  EbsGiB: number;
  Searches: number;
  IndexOps: number;
  PerMonth: string;
  Verdict: string;
}

async function describeAll(region: string): Promise<DomainStatus[]> {
  const client = new OpenSearchClient({ region });
  const { DomainNames = [] } = await client.send(new ListDomainNamesCommand({}));
  const names = DomainNames.map((d) => d.DomainName ?? "").filter(Boolean);
  const domains: DomainStatus[] = [];
  for (let i = 0; i < names.length; i += 5) { // ask for a few domains per call
    const res = await client.send(new DescribeDomainsCommand({ DomainNames: names.slice(i, i + 5) }));
    domains.push(...(res.DomainStatusList ?? []));
  }
  return domains.filter((d) => !d.Deleted);
}

async function activity(region: string, accountId: string, names: string[]): Promise<Map<string, { searches: number; indexOps: number; points: number }>> {
  const cw = new CloudWatchClient({ region });
  const end = new Date();
  const start = new Date(end.getTime() - days * 86_400_000);
  // Cluster-level metrics use the DomainName and ClientId (account ID) dimensions in the AWS/ES namespace.
  const q = (id: string, domain: string, MetricName: string): MetricDataQuery => ({
    Id: id,
    MetricStat: {
      Metric: {
        Namespace: "AWS/ES",
        MetricName,
        Dimensions: [{ Name: "DomainName", Value: domain }, { Name: "ClientId", Value: accountId }],
      },
      Period: 3_600,
      Stat: "Sum", // per-minute rates summed per hour; only zero versus non-zero matters here
    },
    ReturnData: true,
  });
  const queries = names.flatMap((n, i) => [q(`s${i}`, n, "SearchRate"), q(`x${i}`, n, "IndexingRate")]);
  const totals = new Map<string, { searches: number; indexOps: number; points: number }>();
  names.forEach((n) => totals.set(n, { searches: 0, indexOps: 0, points: 0 }));
  for (let n = 0; n < queries.length; n += 500) {
    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 t = totals.get(names[Number(r.Id?.slice(1))]);
        if (!t) continue;
        const sum = (r.Values ?? []).reduce((a, b) => a + b, 0);
        if (r.Id?.startsWith("s")) {
          t.searches += sum;
          t.points += (r.Values ?? []).length;
        } else {
          t.indexOps += sum;
        }
      }
      NextToken = res.NextToken;
    } while (NextToken);
  }
  return totals;
}

function monthlyCost(d: DomainStatus): string {
  const c = d.ClusterConfig ?? {};
  const parts: [string | undefined, number][] = [
    [c.InstanceType, c.InstanceCount ?? 0],
    [c.DedicatedMasterEnabled ? c.DedicatedMasterType : undefined, c.DedicatedMasterEnabled ? c.DedicatedMasterCount ?? 0 : 0],
    [c.WarmEnabled ? c.WarmType : undefined, c.WarmEnabled ? c.WarmCount ?? 0 : 0],
  ];
  let total = 0;
  for (const [type, count] of parts) {
    if (!type || count === 0) continue;
    const price = INSTANCE_HOUR[type];
    if (price === undefined) return `unknown price for ${type}`;
    total += price * count * HOURS_PER_MONTH;
  }
  const ebs = d.EBSOptions;
  if (ebs?.EBSEnabled && ebs.VolumeType === "gp3") total += (ebs.VolumeSize ?? 0) * (c.InstanceCount ?? 0) * GP3_GB_MONTH;
  return `$${total.toFixed(0)}${ebs?.EBSEnabled && ebs.VolumeType !== "gp3" ? " + EBS" : ""}`;
}

async function scanRegion(region: string, accountId: string): Promise<Row[]> {
  const domains = await describeAll(region);
  if (domains.length === 0) return [];
  const names = domains.map((d) => d.DomainName ?? "");
  const totals = await activity(region, accountId, names);
  return domains.map((d) => {
    const c = d.ClusterConfig ?? {};
    const t = totals.get(d.DomainName ?? "") ?? { searches: 0, indexOps: 0, points: 0 };
    const verdict = t.points === 0 ? "no metrics: new or processing domain"
      : t.searches === 0 && t.indexOps === 0 ? "IDLE: no searches, no writes"
      : t.searches === 0 ? "WRITE-ONLY: nobody searches it"
      : "in use";
    return {
      Region: region,
      Domain: d.DomainName ?? "",
      Engine: d.EngineVersion ?? "",
      DataNodes: `${c.InstanceCount ?? 0} x ${c.InstanceType ?? "?"}`,
      Masters: c.DedicatedMasterEnabled ? `${c.DedicatedMasterCount ?? 0} x ${c.DedicatedMasterType ?? "?"}` : "-",
      Warm: c.WarmEnabled ? `${c.WarmCount ?? 0} x ${c.WarmType ?? "?"}` : "-",
      EbsGiB: d.EBSOptions?.EBSEnabled ? (d.EBSOptions.VolumeSize ?? 0) * (c.InstanceCount ?? 0) : 0,
      Searches: Math.round(t.searches),
      IndexOps: Math.round(t.indexOps),
      PerMonth: monthlyCost(d),
      Verdict: verdict,
    };
  });
}

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 { Account = "" } = await new STSClient({ region: regions[0] }).send(new GetCallerIdentityCommand({}));
  const rows: Row[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region, Account)));
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
  if (rows.length === 0) {
    console.log(`No OpenSearch Service domains in ${regions.join(", ")}.`);
    return;
  }
  console.table(rows);
  const idle = rows.filter((r) => r.Verdict.startsWith("IDLE"));
  console.log(`${idle.length} of ${rows.length} domains had no searches and no indexing in ${days} days.`);
  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-opensearch @aws-sdk/client-cloudwatch @aws-sdk/client-sts
npm install --save-dev tsx typescript @types/node

AWS_PROFILE=readonly npx tsx find-idle-opensearch-domains.ts --regions us-east-1,eu-west-1 --csv idle-opensearch.csv

Sample output

Output

┌─────────┬─────────────┬─────────────────┬──────────────────────┬────────────────────────┬────────────────────────┬────────────────────────────────┬────────┬──────────┬──────────┬──────────────┬──────────────────────────────────┐
│ (index) │ Region      │ Domain          │ Engine               │ DataNodes              │ Masters                │ Warm                           │ EbsGiB │ Searches │ IndexOps │ PerMonth     │ Verdict                          │
├─────────┼─────────────┼─────────────────┼──────────────────────┼────────────────────────┼────────────────────────┼────────────────────────────────┼────────┼──────────┼──────────┼──────────────┼──────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'logs-prod'     │ 'OpenSearch_2.19'    │ '3 x r6g.large.search' │ '3 x m6g.large.search' │ '-'                            │ 600    │ 184220   │ 96310455 │ '$719'       │ 'in use'                         │
│ 1       │ 'us-east-1' │ 'search-poc'    │ 'OpenSearch_2.11'    │ '2 x t3.small.search'  │ '-'                    │ '-'                            │ 40     │ 0        │ 0        │ '$57'        │ 'IDLE: no searches, no writes'   │
│ 2       │ 'us-east-1' │ 'audit-archive' │ 'OpenSearch_2.17'    │ '2 x r6g.large.search' │ '-'                    │ '2 x ultrawarm1.medium.search' │ 200    │ 0        │ 1204877  │ '$616'       │ 'WRITE-ONLY: nobody searches it' │
│ 3       │ 'eu-west-1' │ 'es-legacy'     │ 'Elasticsearch_7.10' │ '2 x m5.large.search'  │ '-'                    │ '-'                            │ 100    │ 0        │ 0        │ '$207 + EBS' │ 'IDLE: no searches, no writes'   │
└─────────┴─────────────┴─────────────────┴──────────────────────┴────────────────────────┴────────────────────────┴────────────────────────────────┴────────┴──────────┴──────────┴──────────────┴──────────────────────────────────┘
2 of 4 domains had no searches and no indexing in 14 days.
Wrote 4 rows to idle-opensearch.csv

Names and numbers are illustrative. search-poc and es-legacy are idle. audit-archive still receives about 1.2 million indexing operations every two weeks but nobody searches it, so the question for its owner is retention, not deletion. The UltraWarm managed storage behind it is billed per GB on top of the figure shown, and es-legacy uses a non-gp3 volume, so its EBS cost is left out.

How do you delete an idle domain safely?

There’s no stop or pause for a managed domain; you shrink it or delete it. Automated snapshots exist only to recover that domain, so take a manual snapshot to your own S3 bucket before you delete anything:

  1. Prepare the bucket and roleCreate an S3 bucket and an IAM role that es.amazonaws.com can assume, with s3:ListBucket on the bucket and s3:GetObject, s3:PutObject and s3:DeleteObject on its objects. Don’t add a Glacier lifecycle rule: manual snapshots don’t support that storage class.
  2. Register the repositorySend PUT _snapshot/archive with "type": "s3" and the bucket, Region and role_arn as a SigV4-signed request. Your identity needs iam:PassRole on the role and es:ESHttpPut on the domain.
  3. Take the snapshotPUT _snapshot/archive/search-poc-final, then check it with GET _snapshot/archive/_all until its state is SUCCESS. The OpenSearch snapshot and restore documentation covers the request bodies and how to restore into a new domain.
  4. Deleteaws opensearch delete-domain --domain-name search-poc. Remove it from Terraform or CloudFormation too, or the next deploy recreates it.

Snapshots in S3 cost standard S3 rates, which are far below node-hours; the guide to S3 storage class cost for backups helps you pick a class, remembering that Glacier classes won’t work for this bucket. OpenSearch Serverless collections are a separate API: ListDomainNames doesn’t return them, so this script doesn’t cover them.

What should you check before you delete a domain?

  • Seasonal or on-call use. A log domain searched only during incidents can show zero searches for 14 quiet days. Ask the on-call team before you delete a log cluster.
  • Who writes to it. A write-only domain is fed by something: Firehose, Logstash, Fluent Bit, a Lambda function. Turn the producer off first, or it starts failing and retrying.
  • Log groups that feed it. CloudWatch Logs subscriptions into OpenSearch often come with log groups that keep data forever; the script to set CloudWatch log retention for all log groups caps them.
  • Owner tags. The script to find untagged AWS resources shows domains nobody has claimed.

Retired search stacks leave other hourly charges behind. After you find idle OpenSearch domains, run the script to find idle NAT gateways costing you money and the one to find unused VPC interface endpoints in the same VPCs. If Spark or Hive jobs fed the domain, also check for idle Amazon EMR clusters still billing in those Regions. If logs reached the domain through a Kafka pipeline, the script to find idle Amazon MSK clusters checks whether the brokers feeding it still carry traffic.

Troubleshooting

  • Every domain shows zero searches. The ClientId dimension must be the account that owns the domain. With a cross-account role, check which account GetCallerIdentity returned.
  • “no metrics: new or processing domain”. The domain is newer than the window or was just created. Run the script again in a few days.
  • “unknown price for …”. Add the instance type’s hourly price for your Region to INSTANCE_HOUR.
  • Searches look high on a quiet domain. SearchRate counts shard-level searches, so one request that touches five shards on a node counts as five. Any non-zero value still means someone searched.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “Which OpenSearch domains had no search requests in the last 14 days, and what instance types do they use?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and explains the answer; how ChatWithCloud turns questions into AWS SDK calls walks through the loop. It uses one profile and Region per session and doesn’t ask before making changes, so connect ChatWithCloud to a read-only AWS profile. More cost scripts are on the AWS practical examples hub.

Frequently asked questions

Can I stop an Amazon OpenSearch Service domain to save money?

No. A managed domain has no stop or pause. You can scale it down to fewer or smaller nodes, or take a manual snapshot and delete it.

How do I check if an OpenSearch domain is being used?

Sum SearchRate and IndexingRate in the AWS/ES namespace for the domain, with the DomainName and ClientId dimensions, over 14 days. Zero for both means no searches and no writes.

Are OpenSearch automated snapshots kept after I delete the domain?

Don’t rely on them. AWS describes automated snapshots as a way to recover the domain itself. Take a manual snapshot to your own S3 bucket before deleting.

Does this script cover OpenSearch Serverless?

No. Serverless collections aren’t returned by ListDomainNames. They’re billed differently and need the OpenSearch Serverless API.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud