Photo by Ronald Crow on Unsplash
To find idle NAT gateways, list them with EC2 DescribeNatGateways, then check 14 days of CloudWatch metrics such as BytesOutToDestination and ActiveConnectionCount for each one, and whether any route table still points at it. A NAT gateway bills every hour it exists, even with zero traffic. The script below reports idle and low-traffic gateways with their monthly cost. It never deletes anything.
NAT gateways are easy to create and easy to forget. A VPC built from a template gets one per Availability Zone, a test environment is torn down except for its networking, or a workload moves to VPC endpoints and the NAT gateway stays behind. This example is for engineers who want to find idle NAT gateways in a region and back the finding with data before they talk to the VPC owner. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that joins gateway, route table and traffic data into one report.
It’s part of our AWS cost audit examples with full scripts. NAT gateway charges appear as the NatGateway-Hours and NatGateway-Bytes usage types, and the script to break last month’s AWS bill down by service tells you whether they’re a line worth chasing.
What does an idle NAT gateway cost?
Prices in US East (N. Virginia), as of September 2026, from the official Amazon VPC pricing page:
| Charge | Price | Idle gateway for a month (730 hours) |
|---|---|---|
| NAT gateway-hour | $0.045 per hour (partial hours billed as full hours) | 730 × $0.045 = $32.85 |
| Data processed | $0.045 per GB, whatever the source or destination | $0.00 with no traffic |
| Public IPv4 address (public NAT) | $0.005 per hour per address | 730 × $0.005 = $3.65 |
So an idle public NAT gateway with one Elastic IP costs about $36.50 a month, or $438 a year, doing nothing. A VPC with one gateway in each of three Availability Zones costs three times that. Regional NAT gateways are billed per Availability Zone they span: one spanning three zones is charged 3 × $0.045 = $0.135 per hour. Standard data transfer charges also apply to traffic that does flow. Transit Gateway attachments bill the same way, a similar hourly charge whether or not traffic flows; the script to find unused Transit Gateway attachments finds the ones carrying no data.
Which metrics show that a NAT gateway is idle?
NAT gateways publish metrics to the AWS/NATGateway namespace every minute, kept for 15 months. The script reads three over a 14-day window:
BytesOutToDestination(Sum): bytes sent from your VPC through the gateway. Greater than zero means something behind it is using it.BytesInFromDestination(Sum): bytes coming back, useful for estimating the data processing charge.ActiveConnectionCount(Maximum): concurrent TCP connections. Zero across the whole window is a strong idle signal.
Zonal NAT gateways publish these with the NatGatewayId dimension only; regional NAT gateways add AvailabilityZone. The script uses a CloudWatch SEARCH expression wrapped in SUM(), so both kinds are totaled correctly. It also lists route tables whose routes target each gateway: a gateway no route points at can’t carry traffic at all.
What does the script do?
- Lists NAT gateways
paginateDescribeNatGatewayswithstate = available, including public and private gateways. - Maps route tables
paginateDescribeRouteTablesonce, collecting every route with aNatGatewayId. - Reads 14 days of trafficOne
GetMetricDatacall per gateway with three daily queries. - Labels and prices each gateway
IDLE: no route points here,IDLE: no traffic,LOW traffic(under--max-gb, default 1 GB) orin use, with the monthly hourly charge and the data charge for the window.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-ec2and@aws-sdk/client-cloudwatchpackages. - A profile with a default region, or
AWS_REGIONset. NAT gateways are regional; run once per region.
Which IAM permissions does it need?
Everything is read-only. These describe and metric actions don’t support resource-level restrictions, so they use *.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadNatGatewaysRoutesAndMetrics",
"Effect": "Allow",
"Action": [
"ec2:DescribeNatGateways",
"ec2:DescribeRouteTables",
"cloudwatch:GetMetricData"
],
"Resource": "*"
}
]
}
AWS’s ReadOnlyAccess managed policy already covers these. To build a tighter policy for a scheduled job, the AI IAM policy generator for TypeScript AWS code reads the script and lists the actions, and the guide to finding the IAM actions your AWS SDK for JavaScript code needs explains how to check its output by hand.
The full script to find idle NAT gateways
// find-idle-nat-gateways.ts
// Reports NAT gateways in one region that moved little or no traffic in the last --days days,
// with the route tables that point at them and their fixed monthly cost. Read-only.
// Usage: npx tsx find-idle-nat-gateways.ts [--days 14] [--max-gb 1]
import {
EC2Client,
paginateDescribeNatGateways,
paginateDescribeRouteTables,
type NatGateway,
} from "@aws-sdk/client-ec2";
import { CloudWatchClient, GetMetricDataCommand } from "@aws-sdk/client-cloudwatch";
// USD, us-east-1, as of September 2026. Regional NAT gateways bill this per Availability Zone.
const NAT_PER_HOUR = 0.045;
const NAT_PER_GB = 0.045;
const HOURS_PER_MONTH = 730;
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i === -1 ? undefined : process.argv[i + 1];
}
const ec2 = new EC2Client({}); // region from AWS_REGION or your profile
const cw = new CloudWatchClient({});
// Zonal gateways publish metrics with NatGatewayId only; regional ones add AvailabilityZone.
// A SEARCH expression summed across zones works for both.
function search(nat: NatGateway, metric: string, stat: string, period: number): string {
const schema = nat.AvailabilityMode === "regional" ? "{AWS/NATGateway,AvailabilityZone,NatGatewayId}" : "{AWS/NATGateway,NatGatewayId}";
return `SUM(SEARCH('${schema} MetricName="${metric}" NatGatewayId="${nat.NatGatewayId}"', '${stat}', ${period}))`;
}
async function traffic(nat: NatGateway, days: number) {
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
const period = 86_400; // one data point per day
const res = await cw.send(
new GetMetricDataCommand({
StartTime: start,
EndTime: end,
MetricDataQueries: [
{ Id: "bytesout", Expression: search(nat, "BytesOutToDestination", "Sum", period), Period: period },
{ Id: "bytesin", Expression: search(nat, "BytesInFromDestination", "Sum", period), Period: period },
{ Id: "conns", Expression: search(nat, "ActiveConnectionCount", "Maximum", period), Period: period },
],
}),
);
const values = (id: string) => res.MetricDataResults?.find((r) => r.Id === id)?.Values ?? [];
const sum = (xs: number[]) => xs.reduce((s, x) => s + x, 0);
return {
gbOut: sum(values("bytesout")) / 1e9,
gbIn: sum(values("bytesin")) / 1e9,
maxConnections: Math.max(0, ...values("conns")),
};
}
async function main(): Promise<void> {
const days = Number(arg("--days") ?? 14);
const maxGb = Number(arg("--max-gb") ?? 1);
const nats: NatGateway[] = [];
for await (const page of paginateDescribeNatGateways({ client: ec2 }, { Filter: [{ Name: "state", Values: ["available"] }] })) {
nats.push(...(page.NatGateways ?? []));
}
if (nats.length === 0) {
console.log("No available NAT gateways in this region.");
return;
}
// Which route tables send traffic to each NAT gateway?
const routes = new Map<string, string[]>();
for await (const page of paginateDescribeRouteTables({ client: ec2 }, {})) {
for (const rt of page.RouteTables ?? []) {
for (const r of rt.Routes ?? []) {
if (r.NatGatewayId) routes.set(r.NatGatewayId, [...(routes.get(r.NatGatewayId) ?? []), rt.RouteTableId ?? ""]);
}
}
}
const rows = [];
let idleCost = 0;
for (const nat of nats) {
const id = nat.NatGatewayId ?? "";
const t = await traffic(nat, days);
const regional = nat.AvailabilityMode === "regional";
const hourlyPerMonth = NAT_PER_HOUR * HOURS_PER_MONTH; // per AZ for a regional gateway
const routeTables = routes.get(id) ?? [];
const verdict =
routeTables.length === 0 ? "IDLE: no route points here"
: t.gbOut + t.gbIn === 0 && t.maxConnections === 0 ? "IDLE: no traffic"
: t.gbOut + t.gbIn < maxGb ? "LOW traffic"
: "in use";
if (verdict !== "in use") idleCost += hourlyPerMonth;
rows.push({
"NAT gateway": id,
Name: nat.Tags?.find((tag) => tag.Key === "Name")?.Value ?? "",
Mode: regional ? "regional" : "zonal",
Type: nat.ConnectivityType ?? "",
VPC: nat.VpcId ?? "",
"Route tables": routeTables.length,
[`GB out (${days}d)`]: t.gbOut.toFixed(2),
[`GB in (${days}d)`]: t.gbIn.toFixed(2),
"Max conns": t.maxConnections,
"Hourly $/mo": hourlyPerMonth.toFixed(2) + (regional ? " per AZ" : ""),
[`Data $ (${days}d)`]: ((t.gbOut + t.gbIn) * NAT_PER_GB).toFixed(2),
Verdict: verdict,
});
}
console.table(rows);
console.log(`Idle or low-traffic NAT gateways: at least $${idleCost.toFixed(2)}/month in hourly charges.`);
console.log("Report only: nothing was changed. Confirm with the VPC owner before deleting a NAT gateway.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
GetMetricData is billed per metric requested, so a run over a handful of gateways costs a fraction of a cent. The data charge column multiplies bytes in and out by the per-GB rate, which is a close estimate rather than a billing figure.
How do you run it?
npm install @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript
# Default: last 14 days, "low" means under 1 GB
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-idle-nat-gateways.ts
# Look back 30 days and treat anything under 5 GB as low
AWS_PROFILE=readonly AWS_REGION=eu-west-1 npx tsx find-idle-nat-gateways.ts --days 30 --max-gb 5
Sample output
┌─────────┬─────────────────────────┬───────────────┬─────────┬───────────┬─────────────────────────┬──────────────┬──────────────┬─────────────┬───────────┬─────────────┬──────────────┬──────────────────────────────┐
│ (index) │ NAT gateway │ Name │ Mode │ Type │ VPC │ Route tables │ GB out (14d) │ GB in (14d) │ Max conns │ Hourly $/mo │ Data $ (14d) │ Verdict │
├─────────┼─────────────────────────┼───────────────┼─────────┼───────────┼─────────────────────────┼──────────────┼──────────────┼─────────────┼───────────┼─────────────┼──────────────┼──────────────────────────────┤
│ 0 │ 'nat-0a1b2c3d4e5f60718' │ 'prod-nat-1a' │ 'zonal' │ 'public' │ 'vpc-0123456789abcdef0' │ 2 │ '412.37' │ '1893.04' │ 1840 │ '32.85' │ '103.74' │ 'in use' │
│ 1 │ 'nat-0b2c3d4e5f6071829' │ 'staging-nat' │ 'zonal' │ 'public' │ 'vpc-0fedcba9876543210' │ 1 │ '0.21' │ '0.35' │ 3 │ '32.85' │ '0.03' │ 'LOW traffic' │
│ 2 │ 'nat-0c3d4e5f607182930' │ 'old-poc-nat' │ 'zonal' │ 'public' │ 'vpc-0a9b8c7d6e5f40312' │ 0 │ '0.00' │ '0.00' │ 0 │ '32.85' │ '0.00' │ 'IDLE: no route points here' │
└─────────┴─────────────────────────┴───────────────┴─────────┴───────────┴─────────────────────────┴──────────────┴──────────────┴─────────────┴───────────┴─────────────┴──────────────┴──────────────────────────────┘
Idle or low-traffic NAT gateways: at least $65.70/month in hourly charges.
Report only: nothing was changed. Confirm with the VPC owner before deleting a NAT gateway.
IDs and figures are illustrative. The busy gateway shows how data processing can outweigh the hourly charge: (412.37 + 1,893.04) GB × $0.045 ≈ $103.74 in two weeks.
What should you do with an idle NAT gateway?
The script is report-only on purpose. Deleting a NAT gateway that a private subnet still depends on breaks outbound access for everything in it, from package installs to calls to third-party APIs. Before you delete one:
- Confirm with the owner, and check tags or the VPC name for an environment that’s only used occasionally, such as a monthly batch.
- For
LOW trafficgateways, find what’s talking: VPC Flow Logs show the sources, and the script to find VPCs without flow logs turns them on where they’re missing. If it’s mostly S3 or DynamoDB, a gateway VPC endpoint removes that traffic from the NAT gateway. Interface endpoints are billed by the hour instead, so check they carry traffic with the script to find unused VPC interface endpoints. - After deleting, release the Elastic IP, or it keeps costing $0.005 an hour. The example to find and release unassociated Elastic IP addresses catches any you miss.
Heavy NAT traffic to S3 also shows up as data processing charges; the guide to estimate AWS S3 data transfer out cost covers the transfer side of that bill. Load balancers left behind in the same VPCs cost money in the same quiet way; the sibling script can find unused load balancers with no healthy targets, and so do databases nobody connects to, which the script to find idle RDS instances with no connections reports. Caches left behind by retired services bill the same way; the script to find idle ElastiCache clusters with no reads or writes lists them. So do ML and search endpoints nobody calls anymore; the scripts to find idle SageMaker endpoints and find idle OpenSearch Service domains report those.
Troubleshooting
- Every gateway shows zero traffic. Check that the profile belongs to the account that owns the gateways. Metrics are per account and region, and the script reads them from the same region it lists gateways in.
AccessDeniedonGetMetricData. Many EC2-only policies leave out CloudWatch. Addcloudwatch:GetMetricData, or follow the steps to troubleshoot AWS IAM access denied errors.- A gateway created last week shows little traffic. The window starts before it existed. Compare with
CreateTimein theDescribeNatGatewaysoutput before acting. - Longer windows return nothing for old, silent gateways.
SEARCHonly finds metrics that reported data in the past two weeks. A gateway that has been silent longer than that returns no data, which the script treats as zero traffic, the correct answer.
Ask ChatWithCloud instead
You can ask ChatWithCloud “Which NAT gateways had no traffic in the last two weeks?” It writes AWS SDK for JavaScript v2 code for EC2 and CloudWatch, runs it on your machine with your AWS profile and explains the answer, like the questions in the guide to ask AI why your AWS bill increased. It runs generated code without a confirmation step, so never phrase a question as “delete the idle ones” on a profile that could. Connect ChatWithCloud to your AWS account with a read-only profile and check the ChatWithCloud security model first.
Frequently asked questions
Do NAT gateways cost money when not in use?
Yes. You pay the hourly charge for every hour a NAT gateway exists, plus the public IPv4 address charge, whether or not any traffic flows.
How do I find unused NAT gateways in the AWS console?
Open each NAT gateway’s Monitoring tab in the VPC console and check BytesOutToDestination over two weeks. The script does the same for every gateway at once and adds route table checks.
Can I stop a NAT gateway instead of deleting it?
No. NAT gateways can’t be stopped. To stop the charges, delete the gateway and recreate it when you need it again.
Should every Availability Zone have its own NAT gateway?
For production, one per zone avoids cross-zone dependencies. For development VPCs, a single gateway is often enough, which is where idle ones tend to pile up.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud