An IPv4 prefix length says how many leading address bits identify a network. For 192.0.2.5/24, the network is 192.0.2.0/24 and the ordinary broadcast address is 192.0.2.255. Configuring that interface commonly installs a connected route, but the prefix is not the entire routing algorithm. The kernel consults routing policy and route tables to select a path for each destination.
When it breaks
A prefix that is too short can make the host treat remote destinations as directly connected. Keeping address 192.0.2.5 but changing /24 to /16 includes 192.0.3.9 in the connected range. On a real Ethernet segment, that mistaken choice may lead to neighbor resolution for a destination that is not present on the link. An address that looks correct can therefore coexist with a broken next-hop decision.
“Outside my subnet means use the default gateway” is also incomplete. A more specific route can select a different gateway or interface. Within a selected routing table, a longer matching prefix takes precedence over a shorter one. An explicit host route can even override the ordinary connected route for a destination inside the interface’s subnet. Diagnose the actual lookup rather than reconstructing it from the address alone.
Address-count shortcuts need context. An ordinary /24 has network and directed-broadcast addresses that are not usual host assignments, but subtracting two is not universal. RFC 3021 permits both addresses of a /31 point-to-point link as endpoints. A /32 identifies one address. Those cases describe address semantics, not proof that two configured endpoints can exchange packets through the current physical topology.
Check it quickly
Save this as check-subnet.py and run python3 check-subnet.py with Docker. It builds the pinned Python image with iproute2 6.1.0, then grants NET_ADMIN only to a disposable container with no external network attachment. The program creates a dummy interface and changes that container’s routes. It does not alter host interfaces, routes, or a production gateway:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='''FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84
RUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 && rm -rf /var/lib/apt/lists/*
'''
program=r'''
from ipaddress import IPv4Address,IPv4Interface,IPv4Network
import json,subprocess
def ip(*args):
result=subprocess.run(['ip',*args],capture_output=True,text=True,timeout=3)
assert result.returncode==0,result.stderr
return result.stdout
def route(address):return json.loads(ip('-j','route','get',address))[0]
interface=IPv4Interface('192.0.2.5/24');network=interface.network
assert str(network.network_address)=='192.0.2.0' and str(network.broadcast_address)=='192.0.2.255'
assert IPv4Address('192.0.2.77') in network and IPv4Address('192.0.3.9') not in network
try:IPv4Network('192.0.2.5/24');raise AssertionError('host bits accepted in strict network')
except ValueError:strict_rejected=True
point_to_point=[str(a) for a in IPv4Network('192.0.2.0/31').hosts()]
assert point_to_point==['192.0.2.0','192.0.2.1']
ip('link','add','lab0','type','dummy');ip('link','set','lab0','up')
ip('address','add','192.0.2.5/24','dev','lab0');ip('route','add','default','via','192.0.2.1','dev','lab0')
ip('route','add','203.0.113.0/24','via','192.0.2.2','dev','lab0')
direct=route('192.0.2.77');specific=route('203.0.113.9');default=route('198.51.100.9');before=route('192.0.3.9')
assert direct['dev']=='lab0' and 'gateway' not in direct
assert specific['gateway']=='192.0.2.2' and default['gateway']==before['gateway']=='192.0.2.1'
ip('route','add','192.0.2.77/32','via','192.0.2.1','dev','lab0');overridden=route('192.0.2.77')
assert overridden['gateway']=='192.0.2.1'
ip('address','del','192.0.2.5/24','dev','lab0');ip('address','add','192.0.2.5/16','dev','lab0')
ip('route','replace','default','via','192.0.2.1','dev','lab0');wrong=route('192.0.3.9')
assert wrong['dev']=='lab0' and 'gateway' not in wrong
print(json.dumps({'interface':str(interface),'network':str(network),'broadcast':str(network.broadcast_address),
'strict_host_bits_rejected':strict_rejected,'point_to_point_31_hosts':point_to_point,
'connected':direct,'more_specific_route':specific,'default':default,'host_route_overrides_subnet':overridden,
'destination_before_widening':before,'destination_after_wrong_16':wrong,'iproute_version':ip('-V').strip()}))
'''
name='xbcatm-subnet-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN',
'--memory','128m','--pids-limit','64',name,'python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
Python distinguishes the interface 192.0.2.5/24 from its network 192.0.2.0/24. Strict network parsing rejects the former because host bits are set. The /31 example enumerates both 192.0.2.0 and 192.0.2.1 as usable endpoints. This calculation establishes the address set; the fixture does not configure a working point-to-point link or test forwarding between those two addresses.
The dummy interface receives 192.0.2.5/24 and a default route through 192.0.2.1. A specific route for 203.0.113.0/24 uses 192.0.2.2. Actual ip route get results choose no gateway for 192.0.2.77, gateway 192.0.2.2 for 203.0.113.9, and the default gateway for 198.51.100.9. The output also records the selected interface and preferred source address.
Adding a /32 route for 192.0.2.77 through 192.0.2.1 changes that destination’s lookup to the gateway, despite its membership in the connected /24. This deliberate override demonstrates why subnet membership and final route selection must be kept separate. The selected table has a more specific instruction for that one destination; there is no requirement to rewrite the interface address to express it.
Before widening the interface prefix, 192.0.3.9 uses the default gateway. After replacing /24 with /16 while keeping 192.0.2.5, the lookup selects the dummy interface directly and no longer includes a gateway. The test asserts this change in the real kernel’s decision. It does not claim successful delivery, an observed ARP failure, or a functioning router beyond the dummy interface.
Check the route before changing the mask
Inspect the effective address and prefix, relevant policy rules, and a route lookup for the exact failing destination. On multihomed systems, include the intended source and other policy inputs when reproducing the lookup. A simple destination-only check can differ from the application’s path when source routing, marks, or separate routing domains are in use. Keep the diagnostic context with the result.
On Ethernet, distinguish resolving the destination’s link address from resolving the selected gateway’s link address. A gateway must be reachable according to the configured link and routing rules; a plausible number in a route file is insufficient. Compare actual neighbor state and packet delivery after confirming the route, rather than treating a displayed next hop as proof that forwarding works.
Plan prefix changes with the connected routes and affected destinations they create or remove. Validate representative local, specifically routed, and default-routed traffic after the change. A mask is an input to routing and address interpretation, not an independent reachability promise. This distinction explains both accidental on-link expansion and legitimate exceptions created by more specific routes.
Sources
- RFC 4632 CIDR, classless prefixes and longest-match forwarding, checked 2026-09-12.
- RFC 3021 point-to-point /31 links, the two usable endpoint addresses of a /31 link, checked 2026-09-12.
- Linux ip-route(8), route lookup, next hops and explicit host routes, checked 2026-09-12.
- Python 3.11 ipaddress, interface, network and host-address distinctions, checked 2026-09-12.