Find Unused Transit Gateway Attachments

Rows of network cables plugged into a rack-mounted switch with small status lights

Photo by David Farkas on Unsplash

Transit Gateway attachment cost in us-east-1 is $0.05 per attachment-hour, about $36.50 a month, plus $0.02 per GB processed for VPC, VPN and Direct Connect attachments (September 2026). An unused attachment still pays the hourly rate. Find one by listing attachments with DescribeTransitGatewayAttachments and summing BytesIn and BytesOut per attachment in CloudWatch over 30 days.

AWS Transit Gateway connects VPCs, VPNs, Direct Connect gateways and other transit gateways through one hub. Each connection is an attachment, and each attachment bills by the hour from the moment it’s accepted until it’s deleted. When a workload moves or a VPC is emptied, the attachment usually stays, because nothing breaks when it does.

This example is for network and platform engineers who want to know which attachments to remove. The script lists every attachment in the Regions you pass with the traffic it carried, how many network interfaces are left in the attached VPC, who owns it and what it costs a month. It’s report only; deleting an attachment changes routing, so that stays a reviewed change. VPCs joined directly rather than through the hub don’t show up here; the script to audit VPC peering connections and their routes covers those.

What does a Transit Gateway attachment cost?

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

Attachment type Per attachment-hour Data processing per GB Hours only, per 730-hour month
VPC $0.05 $0.02 $36.50
Site-to-Site VPN $0.05 $0.02 $36.50
Direct Connect gateway $0.05 $0.02 $36.50
Connect $0.05 Charged on the underlying attachment $36.50
Peering $0.05 None for data sent from a peering attachment $36.50

The AWS Transit Gateway pricing page adds three rules that matter for cleanup. A partial attachment-hour is billed as a full hour. Data processing is charged to the account that sends the traffic. When a transit gateway is shared across accounts, the hourly fee goes to the owner of the attached VPC, not the transit gateway owner. Standard data transfer charges, such as inter-Region traffic over peering, are billed separately.

Worked example: a shared transit gateway has 14 VPC attachments, and 4 of them point at VPCs whose workloads were moved last year. Those 4 cost 4 × $0.05 × 730 = $146.00 a month, or $1,752 a year, with no traffic at all. The attachment in the busiest VPC processed 412.6 GB in 30 days, which adds 412.6 × $0.02 = $8.25 to its $36.50.

How do you tell that an attachment is unused?

Transit Gateway publishes attachment-level metrics to CloudWatch in the AWS/TransitGateway namespace every 60 seconds. BytesIn is the bytes the transit gateway received from the attachment, and BytesOut the bytes it sent to it; the only meaningful statistic for both is Sum. The Transit Gateway CloudWatch metrics documentation lists them and their dimensions.

Two details shape the script. Attachment metrics are published to the transit gateway owner’s account, and the attachment owner sees only its own attachment, so run the report from the account that owns the transit gateway. There are also per-Availability Zone series for VPC attachments; the script keeps one total series per attachment so nothing is counted twice. It finds those series with ListMetrics, which only returns metrics that reported data in the past two weeks. An attachment with no series at all is flagged too.

Zero bytes for 30 days is a strong signal, but not proof. A VPN kept as a backup path or a VPC used for a quarterly job can be quiet on purpose. That’s why the script also counts the network interfaces left in each attached VPC in your account: a VPC with nothing in it except Transit Gateway interfaces is an easy decision.

What does the script do?

  1. Lists attachmentspaginateDescribeTransitGatewayAttachments returns every type (vpc, vpn, direct-connect-gateway, connect, peering and others) and skips deleted, rejected and failed ones.
  2. Finds the metric seriespaginateListMetrics for BytesIn and BytesOut with the TransitGatewayAttachment dimension.
  3. Sums trafficpaginateGetMetricData with daily Sum over --days (default 30), up to 500 series per request.
  4. Checks the VPCFor VPC attachments owned by your account, DescribeNetworkInterfaces counts interfaces that aren’t of type transit_gateway.
  5. Prices and labelsAttachment-hours plus data processing, scaled to a month, and a verdict: in use, UNUSED, or pending acceptance. It never deletes anything.

Prerequisites

  • Node.js 18 or later with tsx, plus @aws-sdk/client-ec2, @aws-sdk/client-cloudwatch and @aws-sdk/client-sts.
  • A read-only profile in the account that owns the transit gateway. Set one up as shown in the guide to AWS SDK v3 credential providers: fromIni, fromSSO and assume role.
  • The Regions where your transit gateways live. Each transit gateway is regional.

Which IAM permissions does it need?

tgw-attachment-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadTransitGatewayAttachmentsAndTraffic",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeTransitGatewayAttachments",
        "ec2:DescribeNetworkInterfaces",
        "cloudwatch:ListMetrics",
        "cloudwatch:GetMetricData"
      ],
      "Resource": "*"
    }
  ]
}

These describe and read calls don’t support resource-level restrictions, so "*" is the narrowest resource here. The script also calls sts:GetCallerIdentity to learn its own account ID, which needs no permission. The IAM policy generator for TypeScript code produces the same list from the script.

The script to find unused Transit Gateway attachments

find-unused-transit-gateway-attachments.ts

// find-unused-transit-gateway-attachments.ts
// Lists Transit Gateway attachments with the bytes they sent and received over the last N days
// (AWS/TransitGateway BytesIn + BytesOut per attachment), what's left in each attached VPC,
// and the monthly attachment-hour cost. Report only: it never deletes an attachment.
// Usage: npx tsx find-unused-transit-gateway-attachments.ts [--regions us-east-1,eu-west-1] [--days 30] [--idle-mb 1] [--csv tgw.csv]
import { writeFileSync } from "node:fs";
import {
  EC2Client,
  paginateDescribeNetworkInterfaces,
  paginateDescribeTransitGatewayAttachments,
  type TransitGatewayAttachment,
} from "@aws-sdk/client-ec2";
import {
  CloudWatchClient,
  paginateGetMetricData,
  paginateListMetrics,
  type Metric,
  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 = Number(flag("--days") ?? 30);
const idleBytes = Number(flag("--idle-mb") ?? 1) * 1024 * 1024;
const csvPath = flag("--csv");

// us-east-1 on-demand prices, AWS Price List, September 2026.
const PER_ATTACHMENT_HOUR = 0.05; // VPC, VPN, Direct Connect, Connect and peering attachments
const PER_GB_PROCESSED = 0.02; // VPC, VPN and Direct Connect attachments; not charged for peering
const HOURS_PER_MONTH = 730;
const SKIP_STATES = new Set(["deleted", "deleting", "rejected", "rejecting", "failed", "failing"]);

interface Row {
  Region: string;
  Attachment: string;
  Type: string;
  Resource: string;
  Owner: string;
  State: string;
  AgeDays: number;
  GBIn: number | string;
  GBOut: number | string;
  OtherENIs: number | string;
  PerMonth: string;
  Verdict: string;
}

/** The exact BytesIn/BytesOut metrics per attachment (ListMetrics only returns metrics with data in the last two weeks). */
async function attachmentMetrics(cw: CloudWatchClient): Promise<Map<string, Metric[]>> {
  const byAttachment = new Map<string, Metric[]>();
  for (const metricName of ["BytesIn", "BytesOut"]) {
    for await (const page of paginateListMetrics({ client: cw }, {
      Namespace: "AWS/TransitGateway",
      MetricName: metricName,
      Dimensions: [{ Name: "TransitGatewayAttachment" }],
    })) {
      for (const m of page.Metrics ?? []) {
        const dims = m.Dimensions ?? [];
        if (dims.some((d) => d.Name === "AvailabilityZone")) continue; // skip per-AZ series, keep the attachment total
        const id = dims.find((d) => d.Name === "TransitGatewayAttachment")?.Value;
        if (!id) continue;
        // Keep one series per attachment and metric (the one with the fewest dimensions) so nothing is counted twice.
        const list = byAttachment.get(id) ?? [];
        const same = list.findIndex((x) => x.MetricName === metricName);
        if (same === -1) list.push(m);
        else if (dims.length < (list[same]?.Dimensions?.length ?? 99)) list[same] = m;
        byAttachment.set(id, list);
      }
    }
  }
  return byAttachment;
}

async function sumBytes(cw: CloudWatchClient, metrics: Map<string, Metric[]>): Promise<Map<string, { in: number; out: number }>> {
  const end = new Date();
  const start = new Date(end.getTime() - days * 86_400_000);
  const entries = [...metrics].flatMap(([id, ms]) => ms.map((m) => ({ id, m })));
  const totals = new Map<string, { in: number; out: number }>();
  for (let i = 0; i < entries.length; i += 500) {
    const chunk = entries.slice(i, i + 500);
    const queries: MetricDataQuery[] = chunk.map(({ m }, j) => ({
      Id: `q${j}`,
      MetricStat: { Metric: m, Period: 86_400, Stat: "Sum" },
    }));
    for await (const page of paginateGetMetricData({ client: cw }, { StartTime: start, EndTime: end, MetricDataQueries: queries })) {
      for (const r of page.MetricDataResults ?? []) {
        const entry = chunk[Number((r.Id ?? "").slice(1))];
        if (!entry) continue;
        const t = totals.get(entry.id) ?? { in: 0, out: 0 };
        const sum = (r.Values ?? []).reduce((a, b) => a + b, 0);
        if (entry.m.MetricName === "BytesIn") t.in += sum;
        else t.out += sum;
        totals.set(entry.id, t);
      }
    }
  }
  return totals;
}

/** Network interfaces left in the VPC that don't belong to a Transit Gateway attachment. */
async function otherEnis(ec2: EC2Client, vpcId: string): Promise<number> {
  let n = 0;
  for await (const page of paginateDescribeNetworkInterfaces({ client: ec2 }, { Filters: [{ Name: "vpc-id", Values: [vpcId] }] })) {
    n += (page.NetworkInterfaces ?? []).filter((eni) => eni.InterfaceType !== "transit_gateway").length;
  }
  return n;
}

async function scanRegion(region: string, account: string): Promise<Row[]> {
  const ec2 = new EC2Client({ region });
  const cw = new CloudWatchClient({ region });
  const attachments: TransitGatewayAttachment[] = [];
  for await (const page of paginateDescribeTransitGatewayAttachments({ client: ec2 }, {})) {
    attachments.push(...(page.TransitGatewayAttachments ?? []).filter((a) => !SKIP_STATES.has(a.State ?? "")));
  }
  if (attachments.length === 0) return [];
  const totals = await sumBytes(cw, await attachmentMetrics(cw));
  const rows: Row[] = [];
  for (const a of attachments) {
    const id = a.TransitGatewayAttachmentId ?? "";
    const type = a.ResourceType ?? "";
    const t = totals.get(id);
    // Only VPCs in this account can be inspected; a VPC shared in from another account shows "other account".
    const eniCount = type !== "vpc" || !a.ResourceId ? "-"
      : a.ResourceOwnerId !== account ? "other account"
      : await otherEnis(ec2, a.ResourceId).catch(() => "unreadable");
    const processedGb = t && !type.includes("peering") ? t.in / 1024 ** 3 : 0;
    const perMonth = a.State === "available" ? PER_ATTACHMENT_HOUR * HOURS_PER_MONTH + (processedGb * PER_GB_PROCESSED * 30) / days : 0;
    let verdict = "in use";
    if (a.State === "pendingAcceptance") verdict = "pending acceptance: not yet billed";
    else if (!t) verdict = "UNUSED: no metrics in 14 days";
    else if (t.in + t.out < idleBytes) verdict = "UNUSED: no traffic";
    if (verdict.startsWith("UNUSED") && eniCount === 0) verdict += ", VPC is empty";
    rows.push({
      Region: region,
      Attachment: id,
      Type: type,
      Resource: a.ResourceId ?? "",
      Owner: a.ResourceOwnerId ?? "",
      State: a.State ?? "",
      AgeDays: a.CreationTime ? Math.floor((Date.now() - a.CreationTime.getTime()) / 86_400_000) : -1,
      GBIn: t ? Number((t.in / 1024 ** 3).toFixed(3)) : "-",
      GBOut: t ? Number((t.out / 1024 ** 3).toFixed(3)) : "-",
      OtherENIs: eniCount,
      PerMonth: `$${perMonth.toFixed(2)}`,
      Verdict: verdict,
    });
  }
  return rows;
}

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> {
  if (!Number.isInteger(days) || days < 1 || days > 455) throw new Error("--days must be a whole number from 1 to 455");
  const { Account: 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 Transit Gateway attachments in ${regions.join(", ")}.`);
    return;
  }
  console.table(rows);
  const unused = rows.filter((r) => r.Verdict.startsWith("UNUSED"));
  const monthly = unused.reduce((s, r) => s + Number(r.PerMonth.slice(1)), 0);
  console.log(`${unused.length} of ${rows.length} attachments carried no traffic in ${days} days: ` +
    `$${monthly.toFixed(2)} a month in attachment hours (us-east-1 prices).`);
  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-ec2 @aws-sdk/client-cloudwatch @aws-sdk/client-sts
npm install --save-dev tsx typescript @types/node

# 30 days in the transit gateway owner's account, CSV for the network team
AWS_PROFILE=network-readonly npx tsx find-unused-transit-gateway-attachments.ts --regions us-east-1,eu-west-1 --csv tgw.csv

# Treat anything under 50 MB in 90 days as unused
AWS_PROFILE=network-readonly npx tsx find-unused-transit-gateway-attachments.ts --days 90 --idle-mb 50

Sample output

Output

┌─────────┬─────────────┬────────────────────────────────┬───────────┬─────────────────────────┬────────────────┬─────────────────────┬─────────┬───────┬───────┬─────────────────┬──────────┬──────────────────────────────────────┐
│ (index) │ Region      │ Attachment                     │ Type      │ Resource                │ Owner          │ State               │ AgeDays │ GBIn  │ GBOut │ OtherENIs       │ PerMonth │ Verdict                              │
├─────────┼─────────────┼────────────────────────────────┼───────────┼─────────────────────────┼────────────────┼─────────────────────┼─────────┼───────┼───────┼─────────────────┼──────────┼──────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'tgw-attach-0a1b2c3d4e5f60001' │ 'vpc'     │ 'vpc-0aa11bb22cc33dd44' │ '111122223333' │ 'available'         │ 412     │ 412.6 │ 388.1 │ 2               │ '$44.75' │ 'in use'                             │
│ 1       │ 'us-east-1' │ 'tgw-attach-0a1b2c3d4e5f60002' │ 'vpc'     │ 'vpc-0ee55ff66aa77bb88' │ '444455556666' │ 'available'         │ 230     │ 0     │ 0     │ 'other account' │ '$36.50' │ 'UNUSED: no traffic'                 │
│ 2       │ 'us-east-1' │ 'tgw-attach-0a1b2c3d4e5f60003' │ 'vpn'     │ 'vpn-0c3d4e5f6a7b8c9d0' │ '111122223333' │ 'available'         │ 700     │ 3.2   │ 5.9   │ '-'             │ '$36.56' │ 'in use'                             │
│ 3       │ 'us-east-1' │ 'tgw-attach-0a1b2c3d4e5f60004' │ 'peering' │ 'tgw-0fedcba9876543210' │ '111122223333' │ 'available'         │ 95      │ 0     │ 0     │ '-'             │ '$36.50' │ 'UNUSED: no traffic'                 │
│ 4       │ 'us-east-1' │ 'tgw-attach-0a1b2c3d4e5f60005' │ 'vpc'     │ 'vpc-0123abcd4567ef890' │ '111122223333' │ 'available'         │ 60      │ '-'   │ '-'   │ 1               │ '$36.50' │ 'UNUSED: no metrics in 14 days'      │
│ 5       │ 'us-east-1' │ 'tgw-attach-0a1b2c3d4e5f60006' │ 'vpc'     │ 'vpc-0999aaa888bbb777c' │ '777788889999' │ 'pendingAcceptance' │ 3       │ '-'   │ '-'   │ 'other account' │ '$0.00'  │ 'pending acceptance: not yet billed' │
└─────────┴─────────────┴────────────────────────────────┴───────────┴─────────────────────────┴────────────────┴─────────────────────┴─────────┴───────┴───────┴─────────────────┴──────────┴──────────────────────────────────────┘
3 of 6 attachments carried no traffic in 30 days: $109.50 a month in attachment hours (us-east-1 prices).

IDs and numbers are illustrative. Attachment ...60002 carried nothing for 30 days, and its VPC belongs to account 444455556666, which also pays its $36.50 a month: that’s a message to that team. The peering attachment ...60004 is idle too; check both Regions before removing it. ...60005 reported no metrics at all in two weeks but still has one network interface in its VPC, so look at what that interface is before deciding. The attachment waiting for acceptance isn’t billed yet; reject it if nobody expects it.

What should you check before deleting an attachment?

  • Routes. Look for static routes and propagations that point at the attachment in the transit gateway route tables, and for VPC route table entries that target the transit gateway. Remove them first, so traffic doesn’t fall into a missing route.
  • Backup paths. A VPN or Direct Connect attachment that’s quiet may be the failover link. Ask the network owner, and check any runbooks that mention it.
  • The owner. In a shared setup, the VPC owner pays the hourly fee and should agree to the change. Tags help; the script to find untagged AWS resources shows which attachments nobody claimed.
  • The rest of the VPC. An empty VPC usually brings other idle costs. The scripts to find idle NAT gateways costing you money and find unused VPC interface endpoints cover the two most common ones, and find unattached elastic network interfaces explains any leftover interfaces.

To remove a VPC attachment, run aws ec2 delete-transit-gateway-vpc-attachment --transit-gateway-attachment-id tgw-attach-.... Peering attachments have delete-transit-gateway-peering-attachment. A VPN attachment is created with its Site-to-Site VPN connection and is removed by deleting that connection, which also ends the connection’s own hourly charge. If the whole VPC is no longer needed, the steps to find and delete default VPCs you aren’t using show what else has to go first.

Troubleshooting

  • Many attachments show “no metrics in 14 days”. Check which account the profile is in. From an attachment owner’s account you only see metrics for that account’s own attachments; run the report from the transit gateway owner’s account.
  • OtherENIs shows “other account”. The attached VPC belongs to another account, which your profile can’t describe. Run the ENI check there, or ask the owner.
  • PerMonth looks high for a busy attachment. It includes data processing on BytesIn, the traffic the attachment sent into the transit gateway. That part scales with traffic and isn’t waste.
  • UnauthorizedOperation from EC2. A missing ec2:Describe* permission or an SCP. The steps to troubleshoot AWS IAM access denied errors show how to find the statement.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “List my Transit Gateway attachments with their type, state and the VPC or VPN they connect.” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and explains the answer; the page on how ChatWithCloud runs AWS SDK code on your machine shows the loop. It uses one profile and Region per session and runs changes without a confirmation step, so connect ChatWithCloud to a read-only AWS profile for network questions. If a jump in networking spend is what sent you here, ask AI why your AWS bill increased to see which usage type moved.

Frequently asked questions

Do you pay for a Transit Gateway attachment with no traffic?

Yes. Each attachment bills per hour while it exists, $0.05 an hour in us-east-1 as of September 2026, whether or not traffic flows. For an idle attachment, that hourly rate is the whole transit gateway attachment cost; only the $0.02 per GB data processing charge depends on traffic.

Who pays for a Transit Gateway attachment in a shared transit gateway?

The account that owns the attached VPC pays the hourly fee, and the account that sends traffic pays data processing, according to the AWS Transit Gateway pricing page.

How do I see traffic per Transit Gateway attachment?

Query BytesIn and BytesOut in the AWS/TransitGateway namespace with the TransitGatewayAttachment dimension and the Sum statistic, from the transit gateway owner’s account.

Is data over a Transit Gateway peering attachment charged for processing?

No data processing charge applies to data sent from a peering attachment to a transit gateway. The attachment-hour and any inter-Region data transfer still apply.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud