What it actually is
AWS Transit Gateway (TGW) is a Regional virtual router. VPCs, VPN connections, Direct Connect gateways, Connect (SD-WAN) attachments and other transit gateways plug into it, and it forwards packets between them using route tables that you control. Every plug is called an attachment, and an attachment is both a source and a destination of packets.
The mental model the docs bury: a VPC peering connection is a cable between two networks, and cables do not compose. If A peers with B and B peers with C, A cannot reach C. To fully connect N VPCs you need N(N-1)/2 peerings, which is 45 for ten VPCs, each with route table entries on both sides. A transit gateway replaces the mesh with a star. Ten VPCs need ten attachments, and the routing policy lives in one place instead of in ninety route table entries scattered across accounts.
The second half of the model is the part people miss, and it is where all the design work lives. A transit gateway can hold several route tables, and each attachment is associated with exactly one of them. That association decides where packets coming from that attachment get looked up. So a TGW is not just "everything talks to everything". It is a place where you decide, per attachment, which other attachments are reachable. Segmentation is a routing decision, not a security group decision.
The model
There are five primitives, and you need all five to read any TGW design.
An attachment connects a resource to the gateway. For a VPC attachment, Transit Gateway puts an elastic network interface in one subnet per Availability Zone you enable. A route table maps destination CIDRs to attachments. An association binds an attachment to exactly one route table: packets arriving from that attachment are looked up in that table. A propagation lets an attachment advertise its routes into one or more route tables; a VPC attachment propagates its VPC CIDR blocks, and you cannot filter what it advertises. A static route is one you create by hand, and it can be a blackhole route that drops matching traffic.
Route selection is longest prefix match. For the same CIDR from different attachment types, static routes beat propagated routes. Remember this, because it is how the build below works.
Two more facts. A new transit gateway gets a default route table that serves as both the default association table and the default propagation table. That default is why a fresh TGW behaves like a flat network: everything associates with and propagates into the same table, so everything reaches everything. And the TGW is only half of the path. Each VPC subnet route table needs its own route pointing at the transit gateway, or traffic never reaches it.
On Availability Zones: you enable an AZ by giving the attachment one subnet in it. A resource in an AZ without an attachment cannot reach the transit gateway. If the destination attachment is not in the source's AZ, the traffic is routed internally to an AZ where the attachment exists, with no extra TGW charge for that hop. AWS recommends enabling more than one AZ.
Who operates what: AWS runs the router, its scaling and its availability. You operate the route tables, the subnet routes, the CIDR plan, and the security groups and network ACLs at each end. TGW does not inspect traffic, and it does not fix overlapping CIDRs. For overlapping ranges, AWS announced managed 1:1 NAT on Transit Gateway and Cloud WAN on October 23, 2026, which translates each source CIDR to a different private range using Regional Private NAT Gateway. The announcement gives no rates and points at the VPC pricing page.
When to use it, when not to
VPC peering | Transit Gateway | PrivateLink | |
|---|---|---|---|
Shape | Point to point | Hub and spoke | One service exposed to consumers |
Transitive routing | No | Yes | Not applicable |
Scales to | A handful of VPCs | Up to 5,000 attachments per gateway by default | Many consumers, one service |
Segmentation | Per pair | Per attachment, through route tables | Per endpoint |
Overlapping CIDRs | Not allowed | Not allowed (1:1 NAT now available) | Allowed |
Pricing shape | No attachment-hour charge | Attachment-hours plus per-GB processing | Endpoint-hours plus per-GB processing |
Use peering when you have two or three VPCs that need to talk and you want the simplest thing. Use Transit Gateway when you have more than a handful of VPCs, when you need on-premises (VPN or Direct Connect) to reach many VPCs through one attachment, or when you need to segment: prod can reach shared services, but prod and dev cannot reach each other. Use PrivateLink when the question is "expose one service", not "join two networks". PrivateLink is article 37 of this series.
Do not use a TGW because it feels like the grown-up option. For two VPCs it adds an hourly meter and a per-GB meter to a problem a free peering connection solves.
What it costs
Two meters. The pricing page gives its worked example in US East (Ohio) at $0.05 per hour per VPC attachment and $0.02 per GB sent from a VPC attached to the gateway. It lists no full regional rate table in the text I fetched, so check your Region before you trust my arithmetic. Billing details worth knowing: each partial attachment hour is billed as a full hour; the VPC account owner pays the attachment-hour for a VPC attachment, while the TGW owner pays for VPN and Connect attachments; and standard data transfer charges apply on top.
Here is the model for ten VPCs, each sending 500 GB a month through the gateway at those example rates. Attachments: 10 x $0.05 x 730 hours = $365.00. Data processing: 10 x 500 GB x $0.02 = $100.00. Total about $465 a month. The surprise is that the fixed meter dominates at low traffic and the per-GB meter dominates at high traffic, and nothing about the architecture tells you which side you are on until you measure. The same page notes that the receiving side of an inter-Region peering attachment gets no data processing charge, so a 1 GB transfer across a peering costs $0.02 of processing plus the outbound inter-Region transfer.
For comparison, the VPC pricing page's NAT Gateway example in US East (Ohio) is $0.045 per hour and $0.045 per GB processed. If a transit gateway is replacing a pile of NAT-based or appliance-based hops, the per-GB price is lower than the NAT it removes.
Since November 21, 2025, Flexible Cost Allocation lets a central metering policy charge data processing to the source attachment account, the destination account, or the central TGW account, at the attachment or flow level, at no additional charge. If you run a shared network account, turn this on before finance asks who owns the bill.
There is no free tier mentioned on the pricing page. The cost of following along is below, in the build section.
The limits that bite
From the quotas page: 5 transit gateways per account (adjustable), 20 route tables per gateway (adjustable), 5,000 attachments per gateway (adjustable), and 10,000 total routes across all route tables of a gateway, which is raised through your Solutions Architect or Technical Account Manager rather than self-service. Peering attachments are capped at 50 per gateway, with one peering allowed between any two gateways.
Bandwidth is per attachment per AZ: up to 100 Gbps each direction and up to 7,500,000 packets per second for a VPC attachment per Availability Zone. A Connect peer (one GRE tunnel) tops out at 5 Gbps, which is why SD-WAN designs use several peers, with 4 allowed per Connect attachment. MTU is 8500 bytes on VPC, Direct Connect, Connect and peering attachments, but 1500 on VPN connections, so a path that crosses both will black-hole large packets unless path MTU discovery works end to end.
Three limits are not numbers. First, a transit gateway cannot have more than one VPC attachment to the same VPC. Second, the CLI tutorial requires that the VPCs you connect do not have identical or overlapping CIDRs; the new managed NAT feature is the alternative to renumbering. Third, a static route for a given prefix can point to only one attachment.
Build it
We build the classic segmentation: a shared-services VPC that everyone can reach, and two workload VPCs that cannot reach each other. Three VPCs (A at 10.1.0.0/16, B at 10.2.0.0/16, S at 10.3.0.0/16), one transit gateway, and two TGW route tables: spokes (associated with A and B) and shared (associated with S).
The routing design: spokes receives S's CIDR by propagation, so A and B can reach S. shared receives A's and B's CIDRs by propagation, so S can answer and initiate. Nothing propagates A into spokes, so A has no route to B. We add a blackhole static route for 10.0.0.0/8 in spokes to make the denial explicit, and because longest prefix match makes the propagated 10.3.0.0/16 win over the /8, S stays reachable.
For verification we avoid launching EC2 instances. VPC Reachability Analyzer can analyze a path between two network interfaces, so we create an empty ENI in each VPC and ask the analyzer whether TCP 443 can flow. The analyzer costs $0.10 per analysis per the VPC pricing page.
Prerequisites. Python 3.10+, boto3, AWS credentials for a sandbox account, and a Region (the script reads AWS_REGION, default us-east-1). The principal needs EC2 permissions for VPCs, subnets, route tables, security groups and network interfaces, plus the transit gateway actions used below: ec2:CreateTransitGateway, ec2:DescribeTransitGateways, ec2:DeleteTransitGateway, ec2:CreateTransitGatewayRouteTable, ec2:DeleteTransitGatewayRouteTable, ec2:CreateTransitGatewayVpcAttachment, ec2:DescribeTransitGatewayVpcAttachments, ec2:DeleteTransitGatewayVpcAttachment, ec2:AssociateTransitGatewayRouteTable, ec2:EnableTransitGatewayRouteTablePropagation, ec2:CreateTransitGatewayRoute, ec2:SearchTransitGatewayRoutes, and the CreateNetworkInsightsPath, StartNetworkInsightsAnalysis, DescribeNetworkInsightsAnalyses, DeleteNetworkInsightsAnalysis and DeleteNetworkInsightsPath family. Reachability Analyzer also requires permissions under the tiros service prefix per its documentation; check that page for the current list. The Transit Gateway guide's identity and access management chapter is the authority on resource-level scoping.
Honest disclosure. I wrote this script against the current API documentation and the official CLI tutorial. I did not run it against a live account for this issue, so treat the first run as a sandbox run and read the output.
Step 1: the network skeleton. Three VPCs, one subnet each, a security group allowing TCP 443 from 10.0.0.0/8, and an ENI per VPC.
import os, time, boto3
region = os.environ.get("AWS_REGION", "us-east-1")
ec2 = boto3.client("ec2", region_name=region)
az = ec2.describe_availability_zones()["AvailabilityZones"][0]["ZoneName"]
state = {}
def make_vpc(name, cidr):
vpc = ec2.create_vpc(CidrBlock=cidr, TagSpecifications=[{
"ResourceType": "vpc", "Tags": [{"Key": "Name", "Value": f"tgw-demo-{name}"}]}])["Vpc"]["VpcId"]
ec2.get_waiter("vpc_available").wait(VpcIds=[vpc])
subnet = ec2.create_subnet(VpcId=vpc, CidrBlock=cidr.replace("0.0/16", "1.0/24"),
AvailabilityZone=az)["Subnet"]["SubnetId"]
sg = ec2.create_security_group(GroupName=f"tgw-demo-{name}", Description="tgw demo",
VpcId=vpc)["GroupId"]
ec2.authorize_security_group_ingress(GroupId=sg, IpPermissions=[{
"IpProtocol": "tcp", "FromPort": 443, "ToPort": 443,
"IpRanges": [{"CidrIp": "10.0.0.0/8"}]}])
eni = ec2.create_network_interface(SubnetId=subnet, Groups=[sg])["NetworkInterface"]["NetworkInterfaceId"]
rtb = ec2.describe_route_tables(Filters=[
{"Name": "vpc-id", "Values": [vpc]}, {"Name": "association.main", "Values": ["true"]}
])["RouteTables"][0]["RouteTableId"]
state[name] = dict(vpc=vpc, subnet=subnet, sg=sg, eni=eni, rtb=rtb)
for n, c in [("a", "10.1.0.0/16"), ("b", "10.2.0.0/16"), ("s", "10.3.0.0/16")]:
make_vpc(n, c)
Step 2: the transit gateway with defaults turned off. We disable the default association and propagation so nothing is flat by accident. With both disabled, AWS does not create a default route table, so we create our own two.
def wait(fn, ok, what, tries=60, delay=10):
for _ in range(tries):
v = fn()
if v in ok:
return v
time.sleep(delay)
raise TimeoutError(what)
tgw = ec2.create_transit_gateway(
Description="tgw-demo",
Options={"DefaultRouteTableAssociation": "disable",
"DefaultRouteTablePropagation": "disable"},
)["TransitGateway"]["TransitGatewayId"]
wait(lambda: ec2.describe_transit_gateways(TransitGatewayIds=[tgw])
["TransitGateways"][0]["State"], {"available"}, "tgw available")
def make_rt(name):
return ec2.create_transit_gateway_route_table(TransitGatewayId=tgw, TagSpecifications=[{
"ResourceType": "transit-gateway-route-table",
"Tags": [{"Key": "Name", "Value": name}]}]
)["TransitGatewayRouteTable"]["TransitGatewayRouteTableId"]
rt_spokes, rt_shared = make_rt("spokes"), make_rt("shared")
The official CLI tutorial notes that a new gateway stays pending for several minutes, and you cannot attach VPCs until it is available. The loop above is that wait.
Step 3: attach, associate, propagate, route. Attach each VPC, wait for available, associate each attachment with its table, and enable propagation as designed. Then add the blackhole and the subnet routes.
att = {}
for n in ("a", "b", "s"):
att[n] = ec2.create_transit_gateway_vpc_attachment(
TransitGatewayId=tgw, VpcId=state[n]["vpc"], SubnetIds=[state[n]["subnet"]]
)["TransitGatewayVpcAttachment"]["TransitGatewayAttachmentId"]
def att_state(a):
return ec2.describe_transit_gateway_vpc_attachments(
TransitGatewayAttachmentIds=[a])["TransitGatewayVpcAttachments"][0]["State"]
for a in att.values():
wait(lambda a=a: att_state(a), {"available"}, f"attachment {a}")
assoc = {"a": rt_spokes, "b": rt_spokes, "s": rt_shared}
for n, rt in assoc.items():
ec2.associate_transit_gateway_route_table(TransitGatewayRouteTableId=rt,
TransitGatewayAttachmentId=att[n])
# spokes learn S; shared learns A and B
ec2.enable_transit_gateway_route_table_propagation(
TransitGatewayRouteTableId=rt_spokes, TransitGatewayAttachmentId=att["s"])
for n in ("a", "b"):
ec2.enable_transit_gateway_route_table_propagation(
TransitGatewayRouteTableId=rt_shared, TransitGatewayAttachmentId=att[n])
# explicit denial; the propagated /16 for S wins by longest prefix match
ec2.create_transit_gateway_route(TransitGatewayRouteTableId=rt_spokes,
DestinationCidrBlock="10.0.0.0/8", Blackhole=True)
# each VPC's subnet route table needs a route to the TGW
for n in ("a", "b", "s"):
ec2.create_route(RouteTableId=state[n]["rtb"], DestinationCidrBlock="10.0.0.0/8",
TransitGatewayId=tgw)
Step 4: verify the TGW route tables. Association and propagation settle asynchronously, so give them a moment, then list the routes. Expect spokes to show the 10.3.0.0/16 route as propagated plus the 10.0.0.0/8 blackhole, and shared to show 10.1.0.0/16 and 10.2.0.0/16.
time.sleep(30)
for name, rt in (("spokes", rt_spokes), ("shared", rt_shared)):
r = ec2.search_transit_gateway_routes(TransitGatewayRouteTableId=rt, Filters=[
{"Name": "state", "Values": ["active", "blackhole"]}])["Routes"]
print(name, [(x["DestinationCidrBlock"], x["Type"], x["State"]) for x in r])
Step 5: verify reachability. Run three analyses: A to S should be found, S to A should be found, and A to B must not be found. The returned NetworkPathFound is the verdict, and a blocked path comes back with Explanations naming the component that stopped it.
def check(src, dst):
path = ec2.create_network_insights_path(
Source=state[src]["eni"], Destination=state[dst]["eni"], Protocol="tcp",
FilterAtSource={"DestinationPortRange": {"FromPort": 443, "ToPort": 443}},
)["NetworkInsightsPath"]["NetworkInsightsPathId"]
nia = ec2.start_network_insights_analysis(NetworkInsightsPathId=path)[
"NetworkInsightsAnalysis"]["NetworkInsightsAnalysisId"]
while True:
a = ec2.describe_network_insights_analyses(
NetworkInsightsAnalysisIds=[nia])["NetworkInsightsAnalyses"][0]
if a["Status"] != "running":
break
time.sleep(10)
print(f"{src} -> {dst}: path found = {a.get('NetworkPathFound')}",
[e.get("ExplanationCode") for e in a.get("Explanations", [])])
return path, nia
analyses = [check("a", "s"), check("s", "a"), check("a", "b")]
Expected: a -> s true, s -> a true, a -> b false. The CLI documentation describes NetworkPathFound as true when a reachable path exists, and Explanations as present when the path is blocked. If a -> s comes back false, read the explanation code: the usual suspects are a missing subnet route, a security group, or an attachment that is not yet available.
What this costs. Three attachments at $0.05 per hour for roughly one hour is $0.15 (partial hours bill as full hours). Three Reachability Analyzer runs at $0.10 is $0.30. VPCs, subnets, route tables, security groups and empty ENIs are free. Total: about $0.45, and it keeps ticking at $0.15 an hour if you forget the cleanup.
When it breaks
The ping goes nowhere, the TGW looks perfect. Almost always the subnet route table. The TGW route is half the path, and a subnet whose route table lacks a route to the gateway never reaches it. Related: a subnet in an AZ with no attachment cannot reach the gateway at all, even if the route exists.
Attachment stuck in pending or pendingAcceptance. Pending right after create is normal and resolves to available. Cross-account attachments sit in pendingAcceptance until the gateway owner accepts them, and billing starts on acceptance.
Routes missing after you wired propagation. Association and propagation are asynchronous, so check again after a short wait before you debug. If still missing, verify the propagation is enabled for the right attachment into the right table. A VPC attachment propagates its CIDRs and you cannot filter them, so a surprising route usually means a surprising attachment is propagating into that table.
Asymmetric traffic through a firewall VPC. Stateful appliances drop return traffic that lands in a different AZ. The fix from the docs is appliance mode on the appliance VPC attachment (ApplianceModeSupport=enable), with exactly one transit gateway per appliance VPC, because multiple gateways do not share flow state. Enabling it on an existing attachment can change that attachment's current routing, so do it in a window.
Large packets die on the VPN path. MTU is 8500 bytes on VPC and Direct Connect paths and 1500 on VPN, so a flow that crosses both depends on path MTU discovery working. If ICMP is filtered somewhere, the connection hangs on the first big packet while small pings succeed.
Delete fails or hangs. The official tutorial deletes all VPC attachments first, waits until they are gone, and only then deletes the gateway. The cleanup order below follows that.
Cleanup
Delete in this order: analyses and paths, attachments (and wait until they are gone, since charges stop once all attachments are deleted), the TGW route tables, the gateway, then ENIs, security groups, subnets and VPCs.
for path, nia in analyses:
ec2.delete_network_insights_analysis(NetworkInsightsAnalysisId=nia)
ec2.delete_network_insights_path(NetworkInsightsPathId=path)
for a in att.values():
ec2.delete_transit_gateway_vpc_attachment(TransitGatewayAttachmentId=a)
def gone(a):
r = ec2.describe_transit_gateway_vpc_attachments(TransitGatewayAttachmentIds=[a])
return r["TransitGatewayVpcAttachments"][0]["State"] if r["TransitGatewayVpcAttachments"] else "deleted"
for a in att.values():
wait(lambda a=a: gone(a), {"deleted"}, f"delete {a}")
for rt in (rt_spokes, rt_shared):
ec2.delete_transit_gateway_route_table(TransitGatewayRouteTableId=rt)
ec2.delete_transit_gateway(TransitGatewayId=tgw)
for n in ("a", "b", "s"):
s = state[n]
ec2.delete_network_interface(NetworkInterfaceId=s["eni"])
ec2.delete_security_group(GroupId=s["sg"])
ec2.delete_subnet(SubnetId=s["subnet"])
ec2.delete_vpc(VpcId=s["vpc"])
Confirm the bill stops: aws ec2 describe-transit-gateways --filters Name=state,Values=available,pending should return nothing for the demo gateway, and describe-transit-gateway-vpc-attachments should show no live attachments. The deleted gateway may linger in deleting for a few minutes. Check Cost Explorer the next day for any transit gateway attachment-hours after your cleanup time.
What to take away
Transit Gateway is a routing product, and the interesting part is not the hub, it is the route tables. Design the association and propagation matrix on paper first: rows are attachments, columns are tables. The matrix is your segmentation policy, and the cost model is attachments times hours plus gigabytes times the processing rate. Everything else is plumbing, and the plumbing is where the 3 a.m. pages come from.

