Build the mental model
When a host is about to send a packet, the first question it answers is whether the destination sits on its own subnet. The test is mechanical: AND both its own address and the destination with the subnet mask, then compare the network parts. If they match, the destination is on the same link and no router is involved — the host ARPs for the destination's MAC and puts the frame straight onto the wire. If they do not match, the host cannot reach it directly and must hand the packet to somebody who can. That somebody is the default gateway.
Here is the detail most people get wrong. When you send through a gateway, the packet's destination IP is not changed to the gateway's address. The IP header keeps the original destination; what changes is the destination MAC address in the Ethernet frame. Sending to the gateway means 'please carry this frame onward', not 'this is addressed to you'. Each router strips the frame, reads the IP destination, consults its routing table, builds a fresh frame addressed to the next hop's MAC, and forwards it. A hop is one iteration of that cycle, and it is where the TTL gets decremented.
What happens when several routes match at once? The rule is not first match but most specific match: the longest prefix wins. 10.20.30.5 matches 10.20.30.0/24, 10.0.0.0/8 and 0.0.0.0/0 at once, and the /24 wins. Seen this way the default gateway is nothing special — it is simply the route 0.0.0.0/0, which matches everything but has the shortest possible prefix, so it never outranks anything. That is why adding one specific route silently overrides the default without removing anything.
HOW A HOST DECIDES WHERE TO SEND A PACKET
-----------------------------------------
packet for 10.20.30.5 leaves the application
|
v
+----------------------------------+
| collect EVERY route in the table |
| whose network contains the dest |
+----------------------------------+
|
v
+----------------------------------+
| keep the LONGEST prefix |
| /24 beats /8 beats /0 (default) |
+----------------------------------+
|
+-------------+-------------+
| |
v v
route is on-link route has a
(my own subnet) next-hop gateway
| |
v v
ARP for the DESTINATION ARP for the GATEWAY
frame carries its MAC frame carries its MAC
| |
+-------------+-------------+
|
v
IP addresses never change; the MAC addresses are
rewritten at every single hop. that IS a hop.Connect it to a real scenario
Consider the classic complaint that the internet stops working the moment the office VPN connects. Most VPN clients, on connecting, install a new 0.0.0.0/0 route with a better metric, which pushes all traffic into the tunnel — so-called full tunnelling. If the office firewall does not let that traffic reach the public internet, browsing dies. This is not a bug; it is longest-prefix match doing exactly what it says.
The proper fix is split tunnelling: instead of a default route, the client installs a route only for the office network, say 10.0.0.0/8, pointing at the VPN gateway, and leaves everything else on the original default gateway. Because /8 is longer than /0, office traffic enters the tunnel while everything else goes straight out through the home router. The next problem you will meet when doing this is address overlap. If home Wi-Fi uses 192.168.1.0/24 and the office also uses 192.168.1.0/24, the host's AND-with-mask test concludes the destination is local, ARPs for it on the home LAN, and never touches the VPN at all — so the office server becomes permanently unreachable. That is exactly why well-run offices avoid the popular ranges like 192.168.1.0/24 for their own networks.
Try the working example
import ipaddress
# A host's routing table. Order in the list is irrelevant: the rule is
# "longest prefix wins", not "first match wins".
ROUTES = [
("0.0.0.0/0", "192.168.1.1", "eth0"), # default gateway
("192.168.1.0/24", None, "eth0"), # directly connected
("10.0.0.0/8", "192.168.1.254", "eth0"), # office VPN
("10.20.30.0/24", "192.168.1.253", "eth0"), # a more specific carve-out
("127.0.0.0/8", None, "lo"), # loopback
]
TABLE = [(ipaddress.ip_network(n), gw, dev) for n, gw, dev in ROUTES]
MY_IP = ipaddress.ip_interface("192.168.1.42/24")
def lookup(dest):
"""Longest-prefix match: every route whose network contains the
destination is a candidate; the one with the biggest prefix wins."""
ip = ipaddress.ip_address(dest)
matches = [r for r in TABLE if ip in r[0]]
return max(matches, key=lambda r: r[0].prefixlen), len(matches)
print("my address :", MY_IP)
print("my subnet :", MY_IP.network)
print()
print("destination chosen route next hop action")
print("--------------- ---------------- --------------- ----------------")
for dest in ["192.168.1.7", "10.20.30.5", "10.99.0.1",
"203.0.113.9", "127.0.0.1"]:
(net, gw, dev), n = lookup(dest)
if dev == "lo":
action = "never leaves host"
elif gw is None:
action = "ARP for the host"
else:
action = "ARP for the gateway"
print("%-15s %-16s %-15s %s"
% (dest, str(net), gw or "direct (" + dev + ")", action))
print()
# The AND-with-mask test the host really performs for the local case.
mask = int(MY_IP.network.netmask)
for dest in ["192.168.1.7", "203.0.113.9"]:
d = int(ipaddress.ip_address(dest))
same = (d & mask) == (int(MY_IP.ip) & mask)
print("%-13s AND mask -> %-15s same subnet as me? %s"
% (dest, ipaddress.ip_address(d & mask), same))
(net, gw, dev), n = lookup("10.20.30.5")
print()
print("10.20.30.5 matched %d routes; /%d won over the less specific ones"
% (n, net.prefixlen))my address : 192.168.1.42/24
my subnet : 192.168.1.0/24
destination chosen route next hop action
--------------- ---------------- --------------- ----------------
192.168.1.7 192.168.1.0/24 direct (eth0) ARP for the host
10.20.30.5 10.20.30.0/24 192.168.1.253 ARP for the gateway
10.99.0.1 10.0.0.0/8 192.168.1.254 ARP for the gateway
203.0.113.9 0.0.0.0/0 192.168.1.1 ARP for the gateway
127.0.0.1 127.0.0.0/8 direct (lo) never leaves host
192.168.1.7 AND mask -> 192.168.1.0 same subnet as me? True
203.0.113.9 AND mask -> 203.0.113.0 same subnet as me? False
10.20.30.5 matched 3 routes; /24 won over the less specific ones5-minute try-it
Add ('10.20.30.0/28', '192.168.1.252', 'eth0') to ROUTES and check which route 10.20.30.5 now picks and why. Then delete the 0.0.0.0/0 entry and see what happens to 203.0.113.9 — what error would a real host report in that situation?
One important caution
Thinking the destination IP is rewritten to the gateway's address. Only the frame's MAC address changes; the IP header is left untouched.
Reading a routing table top to bottom as if first match wins. The order of entries is irrelevant — only prefix length decides.
RFC 4632 - Classless Inter-domain Routing (CIDR) — Computer Networking