The difference between a public and a private subnet is not a checkbox. It is routing. A subnet is public because its route table sends internet-bound traffic to an internet gateway, and private because it does not.
This page shows the three-call pattern that wires public routing, how the main route table catches everything you do not explicitly associate, and the routing rules that decide which entry wins.
To make a subnet public you need three calls: create a route table, add a default route to an internet gateway, and associate the subnet. This assumes an internet gateway is already attached to the VPC.
The association is the decisive step. Until AssociateRouteTable runs, the subnet still uses the VPC's main route table and has no internet path no matter how many routes the new table holds.
A realistic setup has two public subnets (in two AZs) sharing one public route table, and two private subnets left on the main table. Here is the public side wired for both subnets.
One route table can serve many subnets, so all your public subnets typically share a single public route table. The private subnets need no action here - by staying unassociated they inherit the main route table, which has no internet route.
Every VPC is born with a main route table. Any subnet you never explicitly associate uses it. That default is convenient but dangerous: if you add a 0.0.0.0/0 -> igw route to the main table, every unassociated subnet - including ones you meant to keep private - becomes public at once.
The safe pattern is to keep the main route table private (no internet route) and associate an explicit public route table only to the subnets that should be public.
Every route table also carries an implicit local route: the VPC's own CIDR pointed at target local. It is what lets subnets talk to each other, it is present from creation, and you can neither add nor remove it.
When traffic leaves a subnet, AWS picks the route with the most specific matching prefix - longest-prefix-match. A packet to 52.10.0.5 matches both 0.0.0.0/0 and a hypothetical 52.10.0.0/16 route, and the /16 wins because it is more specific.
This is how you carve exceptions: a default route to a NAT gateway plus a more-specific route to a peering connection sends most traffic out via NAT but that one CIDR over the peer.
Routes are mutable. ReplaceRoute changes the target of an existing destination (for example, failing a private subnet's default route from one NAT gateway to another). DeleteRoute removes a single entry by its destination CIDR. You never edit the local route.
To move a subnet from one route table to another you call ReplaceRouteTableAssociation with the existing association id (found in DescribeRouteTables), not AssociateRouteTable again. Re-associating a subnet that already has an explicit association is what the replace call is for.
Adding the internet route to the main route table. This silently makes every unassociated subnet public. Keep the main table private and use an explicit public table.
Forgetting the association. Routes in an unassociated table do nothing. The subnet only gets internet access after AssociateRouteTable.
No public IP on the instance. A correct route still needs the instance to have a public IP (via MapPublicIpOnLaunch or an Elastic IP). Routing alone is not enough.
Internet gateway not attached.CreateRoute to an unattached gateway fails or the route black-holes. Attach the gateway to the VPC first.
Trying to touch the local route. You cannot delete or modify the implicit local route; attempts error.
Deleting a route table while associated. Disassociate its subnets first, and note you cannot delete the main route table at all.
A single route table for the whole VPC works for a flat network with no public/private split, but you lose the isolation that separate tables give.
VPC endpoints with gateway routes (for S3 and DynamoDB) add prefix-list routes to a table so that traffic never touches the internet gateway - covered on the peering/PrivateLink page.
Transit Gateway replaces a mesh of route tables and peering routes with a hub-and-spoke model once you have many VPCs.
Infrastructure as code (Terraform, CloudFormation, CDK) declares tables, routes, and associations as resources so the wiring is reproducible instead of imperative.
Use explicit per-tier route tables for anything beyond a toy VPC; reach for Transit Gateway when the number of VPCs makes pairwise routing unmanageable.
Its associated route table has a 0.0.0.0/0 route to an internet gateway, and its instances have public IPs. There is no public flag on the subnet itself.
What is the main route table?
The default route table a VPC is created with. Any subnet you do not explicitly associate with another table uses it.
Can one route table serve multiple subnets?
Yes. A route table can be associated with many subnets, which is why all public subnets usually share one public route table.
What is the local route?
An implicit route for the VPC's own CIDR pointed at target local. It lets subnets communicate and cannot be added, changed, or removed.
Which route wins when two match?
The most specific one - longest prefix match. A /16 route beats 0.0.0.0/0, and a /32 beats the /16.
How do I move a subnet to a different route table?
Call ReplaceRouteTableAssociation with the subnet's current association id, not AssociateRouteTable, which is for a subnet that has no explicit association yet.
Why does my instance still have no internet after adding the route?
Likely it has no public IP, or the internet gateway is not attached to the VPC, or you added the route to a table the subnet is not associated with.
Can I delete the main route table?
No. You can delete custom route tables (after disassociating their subnets), but the main route table lives for the life of the VPC.
How do I change a route's target without recreating it?
Use ReplaceRoute with the same destination CIDR and the new target id. It is how you fail a default route over from one NAT gateway to another.