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?
- Finds your account ID
GetCallerIdentityreturns the account ID theClientIddimension needs. - Lists and describes domains
ListDomainNamesreturns OpenSearch and legacy Elasticsearch domains;DescribeDomains, five names at a time, returnsClusterConfig(instance type and count, dedicated masters, UltraWarm) andEBSOptions. Domains markedDeletedare skipped. - Sums 14 days of activityOne
GetMetricDatabatch reads hourly sums ofSearchRateandIndexingRateper domain. - Estimates costNodes × hourly price × 730, plus gp3 storage. Other volume types and unknown instance types are flagged, not guessed.
- Reports onlyA console table and optional CSV. It never snapshots, resizes or deletes.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-opensearch,@aws-sdk/client-cloudwatchand@aws-sdk/client-sts. - A read-only AWS profile, set up as in AWS SDK v3 credential providers with fromIni and fromSSO.
- The Regions you use. To see which Regions carry OpenSearch spend, run the script to get last month’s AWS cost by service.
Which IAM permissions does it need?
{
"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
// 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?
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
┌─────────┬─────────────┬─────────────────┬──────────────────────┬────────────────────────┬────────────────────────┬────────────────────────────────┬────────┬──────────┬──────────┬──────────────┬──────────────────────────────────┐
│ (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:
- Prepare the bucket and roleCreate an S3 bucket and an IAM role that
es.amazonaws.comcan assume, withs3:ListBucketon the bucket ands3:GetObject,s3:PutObjectands3:DeleteObjecton its objects. Don’t add a Glacier lifecycle rule: manual snapshots don’t support that storage class. - Register the repositorySend
PUT _snapshot/archivewith"type": "s3"and the bucket, Region androle_arnas a SigV4-signed request. Your identity needsiam:PassRoleon the role andes:ESHttpPuton the domain. - Take the snapshot
PUT _snapshot/archive/search-poc-final, then check it withGET _snapshot/archive/_alluntil its state isSUCCESS. The OpenSearch snapshot and restore documentation covers the request bodies and how to restore into a new domain. - Delete
aws 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
ClientIddimension must be the account that owns the domain. With a cross-account role, check which accountGetCallerIdentityreturned. - “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.
SearchRatecounts 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