Find Unused Route 53 Hosted Zones

Rows of network switch ports with blinking amber and green indicator lights

Photo by User_Pascal on Unsplash

To find unused Route 53 hosted zones, list them with ListHostedZones and flag any whose ResourceRecordSetCount is 2 (only the default SOA and NS records). For public zones, also check that the domain is actually delegated to the zone’s name servers and that CloudWatch DNSQueries isn’t zero. Each zone costs $0.50 a month whether it’s used or not.

Hosted zones are cheap enough that nobody notices them, and that’s how an account ends up with dozens: one per experiment, a duplicate created when someone re-registered a domain, zones for domains that lapsed years ago. This example is for engineers who want to find unused Route 53 hosted zones with evidence rather than guesswork. You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3 that checks each zone three ways and prints what the unused ones cost.

It belongs with the other cleanup scripts in our AWS cost and cleanup examples. If a zone you rely on isn’t answering at all, start with the checklist to troubleshoot a Route 53 domain that isn’t serving CloudFront instead.

What do hosted zones cost?

As of September 2026, the Amazon Route 53 pricing page and the AWS Price List list these rates. Route 53 is a global service, so there’s no per-Region price.

Item Price Notes
First 25 hosted zones $0.50 per zone per month Public and private zones alike
Each zone after 25 $0.10 per zone per month Counted per account
Zone deleted within 12 hours No zone charge Queries to public zones are still billed

The monthly charge isn’t prorated. A zone is billed when it’s created and again on the first day of every month, so deleting one on the 2nd saves nothing for that month. Worked example: an account with 18 zones, 7 of them unused, pays 7 × $0.50 = $3.50 a month, or $42 a year, for zones that answer nothing. Small, but it’s free money, and each unused public zone is also a place where records can be added and forgotten.

How can you tell a hosted zone is unused?

No single field says “unused”, so the script combines three signals:

  • Only SOA and NS records. Route 53 creates those two automatically in every new zone. A ResourceRecordSetCount of 2 means nobody ever added anything.
  • Not delegated. A public zone only answers the internet if the registrar (or the parent zone, for a subdomain) lists the zone’s four name servers. DNS delegation works through those NS records in the parent, as defined in RFC 1034. The script compares the NS records that public resolvers return with the zone’s own DelegationSet. No overlap means the zone is invisible.
  • No queries. CloudWatch publishes DNSQueries in the AWS/Route53 namespace for public zones only, and only in us-east-1. Zero over 14 days is strong evidence.

The delegation check is what catches duplicate zones. If example.com exists twice, the registrar points at one set of name servers, and the other zone answers nothing even though it holds a full copy of the records.

What does the script do?

  1. Lists every hosted zonepaginateListHostedZones, public and private, with record counts.
  2. Counts DNS queriesOne GetMetricData call in us-east-1 sums DNSQueries per public zone over 14 days.
  3. Checks delegationGetHostedZone returns the zone’s name servers; Node’s DNS resolver asks public resolvers (1.1.1.1 and 8.8.8.8) for the domain’s NS records.
  4. Skips service-owned zonesZones with a LinkedService were created by another AWS service and can’t be edited or deleted in Route 53.
  5. Prints a verdict and the costIt never deletes anything.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • The @aws-sdk/client-route-53 and @aws-sdk/client-cloudwatch packages.
  • Outbound DNS (UDP and TCP port 53) to public resolvers from the machine you run it on.

Which IAM permissions does it need?

Three read actions. GetHostedZone can be scoped to hosted zone ARNs, which have no Region or account in them.

unused-hosted-zones-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListZonesAndQueryMetrics",
      "Effect": "Allow",
      "Action": ["route53:ListHostedZones", "cloudwatch:GetMetricData"],
      "Resource": "*"
    },
    {
      "Sid": "ReadZoneNameServers",
      "Effect": "Allow",
      "Action": "route53:GetHostedZone",
      "Resource": "arn:aws:route53:::hostedzone/*"
    }
  ]
}

The free IAM policy generator for TypeScript code produces a draft like this from any SDK v3 script.

The full script to find unused Route 53 hosted zones

find-unused-route-53-hosted-zones.ts

// find-unused-route-53-hosted-zones.ts
// Flags Route 53 hosted zones that hold only the default SOA and NS records, public zones whose
// domain isn't delegated to the zone's name servers, and public zones that answered no DNS queries
// in the last 14 days. Read-only: it never deletes a record or a zone.
// Usage: npx tsx find-unused-route-53-hosted-zones.ts
import { Resolver } from "node:dns/promises";
import { Route53Client, GetHostedZoneCommand, paginateListHostedZones, type HostedZone } from "@aws-sdk/client-route-53";
import { CloudWatchClient, GetMetricDataCommand } from "@aws-sdk/client-cloudwatch";

const route53 = new Route53Client({ region: "us-east-1" });
const cloudwatch = new CloudWatchClient({ region: "us-east-1" }); // Route 53 metrics live in us-east-1 only
const resolver = new Resolver();
resolver.setServers(["1.1.1.1", "8.8.8.8"]); // ask public resolvers, not a split-horizon office DNS
const LOOKBACK_DAYS = 14;

interface Row { Zone: string; Id: string; Type: string; Records: number; Delegated: string; Queries14d: string; Verdict: string }

const bare = (n: string): string => n.replace(/\.$/, "").toLowerCase();

async function delegated(zone: string, zoneId: string): Promise<boolean> {
  const own = (await route53.send(new GetHostedZoneCommand({ Id: zoneId }))).DelegationSet?.NameServers ?? [];
  try {
    const live = (await resolver.resolveNs(bare(zone))).map(bare);
    return own.some((ns) => live.includes(bare(ns)));
  } catch {
    return false; // NXDOMAIN or no NS records: the registrar or parent zone points nowhere
  }
}

async function queryCounts(ids: string[]): Promise<Map<string, number>> {
  const out = new Map<string, number>();
  const end = new Date();
  const start = new Date(end.getTime() - LOOKBACK_DAYS * 86_400_000);
  for (let i = 0; i < ids.length; i += 500) {
    const batch = ids.slice(i, i + 500);
    let NextToken: string | undefined;
    do {
      const res = await cloudwatch.send(new GetMetricDataCommand({
        StartTime: start,
        EndTime: end,
        NextToken,
        MetricDataQueries: batch.map((id, n) => ({
          Id: `z${n}`,
          MetricStat: {
            Metric: { Namespace: "AWS/Route53", MetricName: "DNSQueries", Dimensions: [{ Name: "HostedZoneId", Value: id }] },
            Period: 86_400,
            Stat: "Sum",
          },
        })),
      }));
      for (const r of res.MetricDataResults ?? []) {
        const id = batch[Number((r.Id ?? "z0").slice(1))];
        out.set(id, (out.get(id) ?? 0) + (r.Values ?? []).reduce((a, b) => a + b, 0));
      }
      NextToken = res.NextToken;
    } while (NextToken);
  }
  return out;
}

async function main(): Promise<void> {
  const zones: HostedZone[] = [];
  for await (const page of paginateListHostedZones({ client: route53 }, {})) zones.push(...(page.HostedZones ?? []));

  const shortId = (z: HostedZone): string => (z.Id ?? "").replace("/hostedzone/", "");
  const publicZones = zones.filter((z) => !z.Config?.PrivateZone);
  const queries = await queryCounts(publicZones.map(shortId));

  const rows: Row[] = [];
  for (const z of zones) {
    const id = shortId(z);
    const records = z.ResourceRecordSetCount ?? 0;
    const isPrivate = z.Config?.PrivateZone === true;
    const isDelegated = isPrivate ? undefined : await delegated(z.Name ?? "", id);
    const q = isPrivate ? undefined : queries.get(id) ?? 0;
    let verdict = "in use";
    if (z.LinkedService) verdict = `managed by ${z.LinkedService.ServicePrincipal ?? "another service"}: leave it`;
    else if (records <= 2) verdict = "UNUSED: only SOA and NS";
    else if (isDelegated === false && q === 0) verdict = "UNUSED: not delegated, no queries";
    else if (isDelegated === false) verdict = "not delegated: check registrar";
    else if (q === 0) verdict = "delegated but no queries in 14 days";
    rows.push({
      Zone: z.Name ?? "",
      Id: id,
      Type: isPrivate ? "private" : "public",
      Records: records,
      Delegated: isDelegated === undefined ? "n/a" : isDelegated ? "yes" : "NO",
      Queries14d: q === undefined ? "n/a" : String(q),
      Verdict: verdict,
    });
  }
  console.table(rows);
  const unused = rows.filter((r) => r.Verdict.startsWith("UNUSED"));
  // $0.50 per zone per month for the first 25 zones, $0.10 after that (us-east-1 price list, September 2026).
  const rate = zones.length > 25 ? 0.1 : 0.5;
  console.log(`${zones.length} hosted zones; ${unused.length} look unused (at least $${(unused.length * rate).toFixed(2)}/month).`);
  console.log("Read-only: nothing was deleted.");
}

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

How do you run it?

Terminal

npm install @aws-sdk/client-route-53 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript

AWS_PROFILE=readonly npx tsx find-unused-route-53-hosted-zones.ts

Sample output

Output

┌─────────┬─────────────────────┬─────────────────────────┬───────────┬─────────┬───────────┬────────────┬───────────────────────────────────────────────────────┐
│ (index) │ Zone                │ Id                      │ Type      │ Records │ Delegated │ Queries14d │ Verdict                                               │
├─────────┼─────────────────────┼─────────────────────────┼───────────┼─────────┼───────────┼────────────┼───────────────────────────────────────────────────────┤
│ 0       │ 'example.com.'      │ 'Z0123456789ABCDEFGHIJ' │ 'public'  │ 24      │ 'yes'     │ '1843221'  │ 'in use'                                              │
│ 1       │ 'example.com.'      │ 'Z0987654321ZYXWVUTSRQ' │ 'public'  │ 22      │ 'NO'      │ '0'        │ 'UNUSED: not delegated, no queries'                   │
│ 2       │ 'test-idea.dev.'    │ 'Z05566778899AABBCCDDE' │ 'public'  │ 2       │ 'NO'      │ '0'        │ 'UNUSED: only SOA and NS'                             │
│ 3       │ 'old-campaign.com.' │ 'Z0A1B2C3D4E5F6G7H8I9J' │ 'public'  │ 6       │ 'yes'     │ '0'        │ 'delegated but no queries in 14 days'                 │
│ 4       │ 'internal.corp.'    │ 'Z0FEDCBA9876543210ABC' │ 'private' │ 41      │ 'n/a'     │ 'n/a'      │ 'in use'                                              │
│ 5       │ 'sd.local.'         │ 'Z0112233445566778899A' │ 'private' │ 5       │ 'n/a'     │ 'n/a'      │ 'managed by servicediscovery.amazonaws.com: leave it' │
└─────────┴─────────────────────┴─────────────────────────┴───────────┴─────────┴───────────┴────────────┴───────────────────────────────────────────────────────┘
6 hosted zones; 2 look unused (at least $1.00/month).
Read-only: nothing was deleted.

Zone names and IDs are illustrative. The two example.com zones are the duplicate case: the registrar points at the first, so the second is dead weight. old-campaign.com is delegated and holds records, but nobody has queried it in two weeks; check whether the domain is still needed before touching the zone.

What should you check before you delete a hosted zone?

  • Export the records first. Save the output of ListResourceRecordSets. Route 53 won’t delete a zone that still has records other than the default SOA and NS, so you’ll be deleting them anyway.
  • Look for validation records. ACM DNS-validation CNAMEs in a zone are what let certificates renew. If the zone is delegated, removing them breaks renewal; the script to find expiring ACM certificates before they break HTTPS shows which certificates depend on them.
  • Private zones need a different check. Queries to private zones are free and have no DNSQueries metric, so the script reports them as n/a. Check the associated VPCs with GetHostedZone instead.
  • Domains registered with Route 53. If the registrar is Route 53 itself and the domain is delegated to the zone, deleting the zone takes the domain offline.

Hosted zones are one line in a bill full of small leftovers. The scripts to find and release unassociated Elastic IP addresses, find unused load balancers with no healthy targets and the one for idle NAT gateways cover bigger ones, and the script to get last month’s AWS cost broken down by service shows how much Route 53 costs you in total.

Troubleshooting

  • Every public zone shows Delegated: NO. Your network blocks outbound DNS to 1.1.1.1 and 8.8.8.8. Change the servers in resolver.setServers to resolvers you can reach.
  • Queries14d is 0 for a busy zone. Check the zone ID. The metric only exists in us-east-1, which the script sets explicitly; resolvers also cache answers, so a zone with long TTLs and little traffic can legitimately show very low numbers.
  • AccessDenied on GetHostedZone. Add route53:GetHostedZone on arn:aws:route53:::hostedzone/*.

Ask ChatWithCloud instead

You can also ask ChatWithCloud “Which Route 53 hosted zones have only SOA and NS records?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result, like the cost questions in the guide to ask AI why your AWS bill increased. It runs generated code without a confirmation step, so ask for a report rather than a deletion and connect ChatWithCloud with a read-only AWS profile. The ChatWithCloud security model explains what runs locally.

Frequently asked questions

Do empty Route 53 hosted zones cost money?

Yes. Every hosted zone is billed monthly from creation, $0.50 each for the first 25 as of September 2026, whether it has records or not. Only zones deleted within 12 hours of creation avoid the charge.

Why can’t I delete a Route 53 hosted zone?

The zone still has records other than the default SOA and NS. Delete those first. Zones created by another service, shown with a LinkedService, can’t be deleted through Route 53 at all.

How do I know which hosted zone my domain uses?

Look up the domain’s NS records with a public resolver and compare them with the name servers each zone lists in GetHostedZone. The zone whose name servers match is the live one.

Are Route 53 hosted zone charges prorated?

No. The full monthly charge applies when a zone is created and on the first day of each following month.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud