AWS VPC Route Tables
How route tables decide where traffic goes: the local route, longest prefix match, the main table trap, and debugging a subnet with no connectivity.
A route table is a list of destinations and where to send traffic for each. Every subnet uses exactly one, and that association is what makes a subnet public, private or isolated.
Most VPC connectivity problems are route table problems.
The local route
Every route table is created with one route you cannot remove:
Destination Target
10.0.0.0/16 local
This is what lets every subnet in the VPC reach every other subnet, with no configuration. It cannot be deleted or overridden, which is why you never need to add routes between your own subnets. A frequent misunderstanding when someone adds a route for another subnet and wonders why it appears to do nothing.
Adding routes
# Public: send internet-bound traffic to the Internet Gateway
aws ec2 create-route \
--route-table-id rtb-public \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id igw-0abc123
# Private: send internet-bound traffic to a NAT gateway
aws ec2 create-route \
--route-table-id rtb-private-a \
--destination-cidr-block 0.0.0.0/0 \
--nat-gateway-id nat-0abc123
A target can be an internet gateway, NAT gateway, virtual private gateway, transit gateway, VPC peering connection, network interface, or a gateway VPC endpoint.
The presence of a 0.0.0.0/0 route to an internet gateway is the entire definition of a public subnet. Nothing else marks it.
Longest prefix match wins
When several routes could match, the most specific one is used. Regardless of order in the table.
0.0.0.0/0 nat-0abc123 ← general
10.1.0.0/16 pcx-0peering ← more specific, wins for 10.1.x.x
10.1.5.0/24 tgw-0transit ← most specific, wins for 10.1.5.x
Traffic to 10.1.5.20 takes the transit gateway; traffic to 10.1.9.20 takes the peering connection; everything else goes to NAT.
This is how you route selected destinations differently without disturbing the default path, and it is worth knowing because the behaviour is not order-dependent the way firewall rules are.
The local route always wins within the VPC CIDR, whatever else is listed.
The main route table trap
Every VPC has a main route table. A subnet with no explicit association uses it, which is the single most common cause of a subnet behaving unexpectedly.
The typical failure: someone creates a "private" subnet, does not associate it with anything, and it silently inherits the main table. If the main table happens to have an internet gateway route, that private subnet is quietly public.
# Which subnets are relying on the main table?
aws ec2 describe-route-tables \
--filters Name=vpc-id,Values=vpc-0abc123 \
--query 'RouteTables[?Associations[?Main==`true`]].RouteTableId'
# What is a specific subnet actually using?
aws ec2 describe-route-tables \
--filters Name=association.subnet-id,Values=subnet-0abc123 \
--query 'RouteTables[].{ID:RouteTableId,Routes:Routes[].[DestinationCidrBlock,GatewayId,NatGatewayId]}'
Two habits prevent it entirely: associate every subnet explicitly, and leave the main route table with only the local route so an unassociated subnet is isolated rather than accidentally connected.
aws ec2 associate-route-table --subnet-id subnet-0abc123 --route-table-id rtb-private-a
One table per AZ for private subnets
Public subnets can share a single route table, since they all point at the same internet gateway.
Private subnets cannot, if you run a NAT gateway per Availability Zone. Each private subnet needs its own table pointing at the NAT gateway in its own zone:
rtb-private-a 0.0.0.0/0 → nat-zone-a
rtb-private-b 0.0.0.0/0 → nat-zone-b
rtb-private-c 0.0.0.0/0 → nat-zone-c
Pointing all three at one NAT gateway works, and sends traffic across zones. Incurring cross-AZ charges and creating a dependency on that zone staying healthy. It is a reasonable saving in development and a poor choice in production.
Gateway endpoints add routes
A gateway VPC endpoint for S3 or DynamoDB works by adding a prefix list route to the route tables you specify:
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0abc123 \
--service-name com.amazonaws.eu-west-1.s3 \
--route-table-ids rtb-private-a rtb-private-b
Destination Target
pl-6da54004 (S3) vpce-0abc123
Traffic to S3 then bypasses the NAT gateway entirely, which is free and removes a per-gigabyte charge. The endpoint must be associated with every route table that needs it. Associating only one table means the other subnets keep paying for NAT.
Route propagation
A virtual private gateway can propagate routes learned over BGP from a VPN or Direct Connect connection, so you do not maintain them by hand:
aws ec2 enable-vgw-route-propagation \
--route-table-id rtb-private-a \
--gateway-id vgw-0abc123
Propagated routes appear alongside static ones. Where both exist for the same destination, the static route wins.
Debugging
Work through in this order:
- Which route table is the subnet actually associated with? Not the one you think. Check.
- Is there a route matching the destination? Remember longest prefix match.
- Is the target healthy? A NAT gateway in a failed state, or a detached internet gateway, leaves a route that looks correct.
- Security groups and NACLs, which are a separate layer entirely.
A route with target blackhole means the thing it pointed at no longer exists. A deleted NAT gateway or a removed peering connection. The route remains and silently discards traffic:
aws ec2 describe-route-tables \
--query 'RouteTables[].Routes[?State==`blackhole`]'
VPC Reachability Analyzer tests the whole path and names the blocking component, which is faster than checking each layer:
aws ec2 create-network-insights-path \
--source i-0source --destination i-0dest \
--protocol tcp --destination-port 443
Quick reference
| Task | Command |
|---|---|
| List route tables | aws ec2 describe-route-tables --filters Name=vpc-id,Values=<vpc> |
| Which table a subnet uses | aws ec2 describe-route-tables --filters Name=association.subnet-id,Values=<subnet> |
| Create a table | aws ec2 create-route-table --vpc-id <vpc> |
| Associate a subnet | aws ec2 associate-route-table --subnet-id <s> --route-table-id <rtb> |
| Add an internet route | aws ec2 create-route --route-table-id <rtb> --destination-cidr-block 0.0.0.0/0 --gateway-id <igw> |
| Add a NAT route | aws ec2 create-route --route-table-id <rtb> --destination-cidr-block 0.0.0.0/0 --nat-gateway-id <nat> |
| Find blackhole routes | aws ec2 describe-route-tables --query 'RouteTables[].Routes[?State==\blackhole`]'` |
| Diagnose a path | aws ec2 create-network-insights-path --source <a> --destination <b> --protocol tcp |
Key takeaways
- The
localroute is automatic and cannot be removed. You never route between your own subnets. - A
0.0.0.0/0route to an internet gateway is what makes a subnet public. - Longest prefix match wins, regardless of the order routes appear in.
- An unassociated subnet uses the main route table: associate every subnet explicitly and keep the main table minimal.
- One private route table per AZ when running a NAT gateway per zone.
- Gateway endpoints must be attached to every route table that should use them.
- A
blackholeroute means its target is gone and traffic is being discarded silently.
Check your understanding
A private subnet with internet access
A subnet created for private workloads turns out to have internet access, though no route table was configured for it. How?
A route that goes nowhere
A route table shows a 0.0.0.0/0 route with state "blackhole". What does that mean?
Frequently Asked Questions
What is the main route table and why does it matter?
Every VPC has one, and any subnet not explicitly associated with another route table uses it. That default is the most common cause of a subnet behaving unexpectedly. A "private" subnet that inherits an internet gateway route from the main table is quietly public. The two habits that prevent it are associating every subnet explicitly and leaving the main table containing only the local route, so an unassociated subnet is isolated rather than accidentally connected.
Do I need routes between subnets in the same VPC?
No. Every route table is created with a local route covering the VPC CIDR, and it cannot be removed or overridden. All subnets in a VPC can therefore reach each other at the routing layer by default. If traffic between two subnets is being blocked, the cause is a security group or network ACL rather than routing. Adding a route for the other subnet's CIDR achieves nothing.
How does AWS choose between multiple matching routes?
By longest prefix match. The most specific route wins, regardless of the order entries appear in the table. A /24 beats a /16, which beats 0.0.0.0/0. This is how you send particular destinations through a peering connection or transit gateway while everything else goes to a NAT gateway. The local route always takes precedence within the VPC CIDR. Where a static and a propagated route match the same destination, the static one wins.
Why do I need a separate route table per private subnet?
Because each should point at the NAT gateway in its own Availability Zone. If all private subnets share one table pointing at a single NAT gateway, traffic from other zones crosses zones to reach it. Incurring cross-AZ data charges and making every private subnet dependent on that one zone staying healthy. Public subnets can share a table, since they all point at the same internet gateway.
What does a blackhole route mean?
The route exists but its target has been deleted, so matching traffic is silently discarded. It usually follows a NAT gateway being replaced, since the new one has a different ID and the old route is not updated automatically, or a VPC peering connection being removed. Find them with a query filtering routes on State == 'blackhole'. They are worth checking for whenever connectivity breaks after infrastructure changes, because nothing reports an error.
How do gateway VPC endpoints interact with route tables?
A gateway endpoint for S3 or DynamoDB works by adding a prefix list route to the route tables you associate it with, sending that traffic directly rather than through a NAT gateway. It is free and removes a per-gigabyte NAT charge, so there is no reason not to have one. The important detail is that it only affects the route tables you explicitly associate. Attach it to every private route table, or the subnets you missed keep routing S3 traffic through NAT and paying for it.