Cost AWS cost

an idle NAT Gateway still costs about $32 a month

Someone built a private subnet, needed outbound internet for one task, and created a NAT Gateway. The task moved to a different account, or the workload was replaced by something using VPC endpoints, and the gateway stayed. It has no traffic. It has had no traffic for months. At $0.045 per gateway-hour it is still billing about $32 a month, and it will keep doing that until somebody looks.

EC2 and CloudWatch APIs Python and Node.js Dry run by default
The short answer

A NAT Gateway is charged per hour it exists, not per byte it moves. At $0.045/hour in us-east-1 that is roughly $32.40 a month before any data-processing charge, and it applies to a gateway sitting completely idle.

CloudWatch publishes BytesOutToDestination per gateway. If that is flat zero across a fortnight, nothing is using it. The script below finds those, and — because deleting a gateway can cut off a workload you have not thought of — it reports by default and needs an explicit flag to delete.

The problem in plain words

Nothing breaks and nothing alerts. The gateway shows as available, which reads like health rather than cost. Cost Explorer files it under EC2-Other, which is one of the least legible lines on an AWS bill, so even somebody looking at spend will not obviously see it.

It compounds. NAT Gateways are per availability zone, so a three-AZ VPC built for redundancy has three of them at roughly $97 a month combined. Multiply by a staging VPC nobody deleted and a proof of concept from last year, and this single line is often the largest piece of an unexplained bill.

Why it happens

Hourly billing hides idleness. Most AWS cost intuition is usage-based: no traffic, no charge. NAT Gateway breaks that intuition, and the break is exactly what makes it easy to leave running.

They outlive their reason. The usual sequence is a private subnet that needed to reach S3, later replaced by a gateway VPC endpoint — which is free — without anyone removing the NAT Gateway that endpoint made redundant.

Deleting one feels risky, so nobody does. If a Lambda or an ECS task in a private subnet still routes through it, deleting it breaks outbound traffic in a way that surfaces as timeouts rather than a clear error. That risk is real, which is why the check below measures actual traffic before recommending anything.

How to fix it

List every gateway and its age

Start with what exists. A gateway in any state other than deleted is billing.

aws ec2 describe-nat-gateways \
  --filter Name=state,Values=available \
  --query 'NatGateways[].{Id:NatGatewayId,VPC:VpcId,Subnet:SubnetId,Since:CreateTime}'

Measure traffic, do not guess

CloudWatch has BytesOutToDestination for the AWS/NATGateway namespace. Sum it over at least fourteen days — a week can miss anything that runs fortnightly, and a monthly batch job is exactly the workload you do not want to cut off.

Check what still routes through it

Before deleting, find the route tables pointing at the gateway. A route table with associated subnets means something may still depend on it, even if it has been quiet.

aws ec2 describe-route-tables \
  --filters Name=route.nat-gateway-id,Values=nat-0abc123 \
  --query 'RouteTables[].{Id:RouteTableId,Assoc:Associations[].SubnetId}'

Consider whether you needed it at all

If the only outbound traffic was to S3 or DynamoDB, a gateway VPC endpoint costs nothing — no hourly charge and no per-GB charge. Interface endpoints are $0.01/hour plus $0.01/GB, still far below NAT. Replacing NAT with endpoints is usually the real fix rather than simply deleting.

How to check it worked

Confirm the gateway is gone and nothing started failing:

aws ec2 describe-nat-gateways --nat-gateway-ids nat-0abc123 \
  --query 'NatGateways[].State'
# "deleted"

Then watch the workloads that shared its VPC for a full cycle of whatever they do — a nightly job, a weekly report. Timeouts to external hosts are the symptom of having cut off something that mattered.

The full code

The script lists every available NAT Gateway, pulls fourteen days of egress from CloudWatch, and reports the ones that moved no bytes along with what each is costing. Deletion requires both --apply and the specific gateway id: it will not bulk-delete, because a quiet gateway and an unused one are not the same thing.

nat_gateway_idle_audit.py
"""Find NAT Gateways with no traffic and report what they cost.

A NAT Gateway bills per hour it exists, so an idle one is pure waste. But quiet is
not the same as unused -- a monthly batch job looks idle for 29 days -- so this
reports by default and deletes only a gateway you name explicitly.
"""
import argparse
import datetime as dt
import logging
import sys

import boto3

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("nat_gateway_idle_audit")

# us-east-1 list price, August 2026. Other regions differ; see the sources.
HOURLY_USD = 0.045
MONTHLY_USD = HOURLY_USD * 24 * 30


def egress_bytes(cw, nat_id, days):
    """Total bytes out to the internet over the window."""
    end = dt.datetime.now(dt.timezone.utc)
    points = cw.get_metric_statistics(
        Namespace="AWS/NATGateway",
        MetricName="BytesOutToDestination",
        Dimensions=[{"Name": "NatGatewayId", "Value": nat_id}],
        StartTime=end - dt.timedelta(days=days),
        EndTime=end,
        Period=86400,
        Statistics=["Sum"],
    )["Datapoints"]
    return sum(p["Sum"] for p in points)


def verdict(total_bytes, days, threshold_bytes=1_000_000):
    """Pure decision function.

    A threshold rather than zero, because health checks and DNS produce a trickle
    on a gateway nothing actually uses. A megabyte over two weeks is noise.
    """
    if total_bytes <= threshold_bytes:
        return "IDLE", f"{total_bytes:,.0f} bytes in {days} days"
    return "IN USE", f"{total_bytes / 1e9:.2f} GB in {days} days"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--region", default="us-east-1")
    ap.add_argument("--days", type=int, default=14)
    ap.add_argument("--delete", help="NAT Gateway id to delete")
    ap.add_argument("--apply", action="store_true")
    args = ap.parse_args()

    ec2 = boto3.client("ec2", region_name=args.region)
    cw = boto3.client("cloudwatch", region_name=args.region)

    gws = ec2.describe_nat_gateways(
        Filter=[{"Name": "state", "Values": ["available"]}])["NatGateways"]
    if not gws:
        log.info("no available NAT Gateways in %s", args.region)
        return 0

    idle_cost = 0.0
    for gw in gws:
        nat_id = gw["NatGatewayId"]
        state, detail = verdict(egress_bytes(cw, nat_id, args.days), args.days)
        line = f"{nat_id} in {gw['VpcId']} -- {detail}, ~${MONTHLY_USD:.2f}/month"
        if state == "IDLE":
            idle_cost += MONTHLY_USD
            log.warning("IDLE   %s", line)
        else:
            log.info("IN USE %s", line)

    if idle_cost:
        log.warning("idle NAT Gateways are costing about $%.2f/month", idle_cost)
        log.warning("check route tables before deleting: "
                    "aws ec2 describe-route-tables --filters "
                    "Name=route.nat-gateway-id,Values=<id>")

    if args.delete:
        if args.apply:
            ec2.delete_nat_gateway(NatGatewayId=args.delete)
            log.info("deleting %s", args.delete)
        else:
            log.info("WOULD delete %s -- pass --apply", args.delete)
    return 0


if __name__ == "__main__":
    sys.exit(main())
nat-gateway-idle-audit.mjs
/**
 * Find NAT Gateways with no traffic and report what they cost.
 *
 * A NAT Gateway bills per hour it exists, so an idle one is pure waste. But quiet
 * is not the same as unused -- a monthly batch job looks idle for 29 days -- so
 * this reports by default and deletes only a gateway you name explicitly.
 */
import { EC2Client, DescribeNatGatewaysCommand, DeleteNatGatewayCommand } from '@aws-sdk/client-ec2';
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch';

// us-east-1 list price, August 2026. Other regions differ; see the sources.
const HOURLY_USD = 0.045;
const MONTHLY_USD = HOURLY_USD * 24 * 30;

async function egressBytes(cw, natId, days) {
  const EndTime = new Date();
  const StartTime = new Date(EndTime.getTime() - days * 86400_000);
  const out = await cw.send(new GetMetricStatisticsCommand({
    Namespace: 'AWS/NATGateway',
    MetricName: 'BytesOutToDestination',
    Dimensions: [{ Name: 'NatGatewayId', Value: natId }],
    StartTime, EndTime, Period: 86400, Statistics: ['Sum'],
  }));
  return (out.Datapoints ?? []).reduce((t, p) => t + (p.Sum ?? 0), 0);
}

/**
 * Pure decision function.
 *
 * A threshold rather than zero, because health checks and DNS produce a trickle on
 * a gateway nothing actually uses. A megabyte over two weeks is noise.
 */
export function verdict(totalBytes, days, thresholdBytes = 1_000_000) {
  if (totalBytes <= thresholdBytes) {
    return { state: 'IDLE', detail: `${totalBytes.toLocaleString()} bytes in ${days} days` };
  }
  return { state: 'IN USE', detail: `${(totalBytes / 1e9).toFixed(2)} GB in ${days} days` };
}

async function main() {
  const region = process.env.AWS_REGION ?? 'us-east-1';
  const days = Number(process.env.DAYS ?? 14);
  const apply = process.argv.includes('--apply');
  const toDelete = process.argv[process.argv.indexOf('--delete') + 1];

  const ec2 = new EC2Client({ region });
  const cw = new CloudWatchClient({ region });

  const { NatGateways = [] } = await ec2.send(new DescribeNatGatewaysCommand({
    Filter: [{ Name: 'state', Values: ['available'] }],
  }));
  if (!NatGateways.length) return console.log(`no available NAT Gateways in ${region}`);

  let idleCost = 0;
  for (const gw of NatGateways) {
    const { state, detail } = verdict(await egressBytes(cw, gw.NatGatewayId, days), days);
    const line = `${gw.NatGatewayId} in ${gw.VpcId} -- ${detail}, ~$${MONTHLY_USD.toFixed(2)}/month`;
    if (state === 'IDLE') { idleCost += MONTHLY_USD; console.warn(`IDLE   ${line}`); }
    else console.log(`IN USE ${line}`);
  }
  if (idleCost) {
    console.warn(`idle NAT Gateways are costing about $${idleCost.toFixed(2)}/month`);
  }

  if (process.argv.includes('--delete')) {
    if (apply) {
      await ec2.send(new DeleteNatGatewayCommand({ NatGatewayId: toDelete }));
      console.log(`deleting ${toDelete}`);
    } else {
      console.log(`WOULD delete ${toDelete} -- pass --apply`);
    }
  }
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The threshold is the interesting part: exactly zero bytes almost never happens, because health checks and DNS leak a trickle through a gateway nothing really uses. The test pins down where the line sits.

test_nat_gateway_idle_audit.py
from nat_gateway_idle_audit import verdict


def test_zero_traffic_is_idle():
    state, _ = verdict(0, 14)
    assert state == "IDLE"


def test_a_trickle_is_still_idle():
    """Health checks and DNS leak bytes through a gateway nothing really uses."""
    state, _ = verdict(500_000, 14)
    assert state == "IDLE"


def test_real_traffic_is_in_use():
    state, detail = verdict(5_000_000_000, 14)
    assert state == "IN USE"
    assert "GB" in detail


def test_the_threshold_boundary_is_inclusive():
    assert verdict(1_000_000, 14)[0] == "IDLE"
    assert verdict(1_000_001, 14)[0] == "IN USE"
nat-gateway-idle-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './nat-gateway-idle-audit.mjs';

test('zero traffic is idle', () => {
  assert.equal(verdict(0, 14).state, 'IDLE');
});

test('a trickle is still idle', () => {
  assert.equal(verdict(500_000, 14).state, 'IDLE');
});

test('real traffic is in use', () => {
  const { state, detail } = verdict(5_000_000_000, 14);
  assert.equal(state, 'IN USE');
  assert.match(detail, /GB/);
});

test('the threshold boundary is inclusive', () => {
  assert.equal(verdict(1_000_000, 14).state, 'IDLE');
  assert.equal(verdict(1_000_001, 14).state, 'IN USE');
});

FAQ

How much does an idle NAT Gateway actually cost?

About $32.40 a month in us-east-1, from the $0.045 per gateway-hour charge alone. Data processing is billed separately at $0.045/GB, so an idle gateway pays the hourly charge and nothing else — which is exactly why it goes unnoticed.

Why does it cost anything if no traffic passes through it?

Because the charge is for the gateway existing, not for what it carries. Most AWS cost intuition is usage-based, and this breaks that intuition, which is what makes it easy to leave running for months.

Is it safe to delete a gateway with no traffic?

Not automatically. A workload that runs monthly looks idle for 29 days out of 30. Check the route tables that point at it and whether their subnets have anything in them before deleting, which is why the script needs an explicit id rather than deleting everything it flags.

What should I use instead?

If the traffic was to S3 or DynamoDB, a gateway VPC endpoint costs nothing at all — no hourly charge, no per-GB charge. Interface endpoints are $0.01/hour plus $0.01/GB, still far cheaper than NAT. Replacing rather than deleting is usually the real fix.

Why do I have three of them?

NAT Gateways are per availability zone. A three-AZ VPC built for redundancy has three, at roughly $97 a month combined. That is correct for production; it is waste in a staging VPC nobody uses.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.