IP Addressing, Subnetting and CIDR
A /N prefix leaves 32-N host bits, so a block holds 2^(32-N) addresses (62 usable in a /26). Covers masks, subnetting, VLSM, CIDR aggregation and longest-prefix match, checked with Python.
Subnetting divides an IPv4 address's 32 bits with a prefix length: a /N keeps N bits for the network and 32 - N for hosts, so a block holds 2^(32-N) addresses. Interviewers expect the minus-two rule: a /26 has 64 addresses but 62 usable hosts, because the all-zeros value is the network address and all-ones is the broadcast.
An IPv4 address is 32 bits split by a prefix length: a /26 keeps 26 bits for the network and 6 for hosts, giving 64 addresses and 62 usable. Every subnetting question, from block sizes to CIDR aggregation to longest-prefix routing, is arithmetic on where that line sits. Each result here was produced with Python's ipaddress module.
- An IPv4 /N prefix leaves 32 - N host bits, so a block holds 2^(32-N) addresses: a /26 has 64 addresses and 62 usable hosts.
- The network address of a host is its address ANDed with the mask, so 192.168.1.130/26 belongs to 192.168.1.128/26, whose broadcast address is 192.168.1.191.
- A /31 keeps both addresses on a point-to-point link under RFC 3021, and Amazon VPC reserves five addresses per subnet, leaving 11 usable in a /28.
- VLSM allocates subnets largest first, aligned to their own size: 100, 50, 20 and 2 hosts in 198.51.100.0/24 become a /25, /26, /27 and /30 using 228 of 256 addresses.
- Under CIDR aggregation, four /24s from 10.0.0.0 collapse to 10.0.0.0/22, but 203.0.113.0/24 and 203.0.114.0/24 do not merge because a /23 must start on an even third octet.
- Routers forward on the longest matching prefix, so 10.1.9.9 uses a 10.1.0.0/16 route over 10.0.0.0/8, and metric only breaks ties between equal prefix lengths.
01One address, one movable line
Dotted decimal is a display habit. Underneath, an IPv4 address is a single 32-bit number, and 192.168.1.130 is only four of its bytes written in base ten with dots between them. Everything in subnetting follows from one extra piece of information: how many of those 32 bits, counted from the left, name the network. The rest name a host inside it.
That count is the prefix length, written after a slash. In 192.168.1.130/26 the first 26 bits identify the network and the last 6 identify the machine. Move the line one bit to the right and each block holds half as many addresses, but there are twice as many blocks of that size. Move it one bit left and a block doubles while the number of such blocks halves. Block size, alignment, route aggregation and longest-prefix matching all fall out of that single idea, so the sections below keep returning to it instead of asking you to memorise a table.
Examples here use the documentation ranges reserved by RFC 5737 (192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24) wherever the address does not need to be private. The code is Python's standard ipaddress module, and every output shown comes from running it. The same prefix idea applies to 128-bit addresses, which IPv6 Addressing Basics covers; this topic assumes you already know where IP sits in the stack from OSI vs TCP/IP Model.
02Prefix length, the mask, and one AND
A /26 prefix and the mask 255.255.255.192 are the same statement in two notations. The mask has 26 one-bits then 6 zero-bits; 255.255.255.192 is that pattern split into bytes (192 is 11000000). Valid masks are always ones followed by zeros, which is why 255.255.255.100 is not a mask at all.
To find the network a host belongs to, AND its address with the mask. Ones keep a bit, zeros wipe it. To find the broadcast address, set every host bit to 1 instead. The script below does both for 192.168.1.130/26 by treating the address as an integer.
import ipaddress as ip
addr = ip.ip_interface("192.168.1.130/26")
a = int(addr.ip)
m = int(addr.netmask)
print("addr ", format(a, "032b"))
print("mask ", format(m, "032b"))
print("AND ", format(a & m, "032b"))
print("network", ip.ip_address(a & m), "broadcast", ip.ip_address(a | (~m & 0xFFFFFFFF)))
print(addr.network, addr.netmask, addr.network.num_addresses)A run of this code prints:
addr 11000000101010000000000110000010
mask 11111111111111111111111111000000
AND 11000000101010000000000110000000
network 192.168.1.128 broadcast 192.168.1.191
192.168.1.128/26 255.255.255.192 64Only the last octet changes. 130 is 10000010; the first two bits belong to the network, so the six host bits clear to give 10000000, which is 128. Setting those six bits to 1 gives 10111111, which is 191. The host occupies the range .128 to .191, a block of 64 addresses that starts on a multiple of 64.
The diagram shows the same arithmetic bit by bit, with the red line marking where the prefix ends.
The shortcut interviewers like is the block-size trick: for a /26 the interesting octet is the fourth, the block size is 256 minus 192, which is 64, and the network is the largest multiple of 64 not exceeding 130. The binary AND is the proof; the block size is the speed.
03Counting addresses, and why the usual answer is minus two
A prefix of N bits leaves 32 minus N host bits, so the block holds 2 to the power (32 - N) addresses. A /26 has 6 host bits and therefore 64 addresses. Two of them are spoken for: the one with every host bit at 0 names the network itself, and the one with every host bit at 1 is the directed broadcast. That leaves 62 for machines.
The script checks several prefix lengths and then the awkward cases.
import ipaddress as ip
for p in (24, 26, 28, 30, 31, 32):
n = ip.ip_network(f"192.0.2.0/{p}")
print(f"/{p}: addresses={n.num_addresses}, hosts()={len(list(n.hosts()))}")
n = ip.ip_network("192.0.2.8/31")
print([str(h) for h in n.hosts()])
print("AWS-style usable in a /28:", ip.ip_network("192.0.2.0/28").num_addresses - 5)
try:
ip.ip_network("192.0.2.77/26")
except ValueError as e:
print("ValueError:", e)
print(ip.ip_network("192.0.2.77/26", strict=False))A run of this code prints:
/24: addresses=256, hosts()=254
/26: addresses=64, hosts()=62
/28: addresses=16, hosts()=14
/30: addresses=4, hosts()=2
/31: addresses=2, hosts()=2
/32: addresses=1, hosts()=1
['192.0.2.8', '192.0.2.9']
AWS-style usable in a /28: 11
ValueError: 192.0.2.77/26 has host bits set
192.0.2.64/26The /24 and /26 lines are the minus-two rule at work: hosts() gives 254 and 62. The /31 line breaks it, because a /31 keeps both addresses. RFC 3021 allows this on point-to-point links, where there is no one to broadcast to, so a link uses two addresses instead of the four a /30 burns. A /32 is a single host, the form used for a loopback or a host route.
The last two lines show a different mistake. 192.0.2.77/26 is not a network, it is an interface address that happens to sit inside 192.0.2.64/26, and Python refuses to call it a network unless you pass strict=False. For a host address, ip_interface is the right tool.
Cloud providers subtract more than two. Amazon's VPC documentation reserves five addresses in every subnet: the network address, the next three above it (for the VPC router, DNS and future use) and the last address. The line "usable in a /28" prints 11, which is 16 minus 5, against 14 in a plain network. When an exercise says "AWS", reach for five, not two.
04Carving a block: equal splits and VLSM
Splitting a block means moving the line right. Adding two bits to 198.51.100.0/24 yields four /26 blocks of 64. When the needs are uneven you size each subnet separately, which is VLSM, variable-length subnet masking.
The sizing rule: a subnet for H hosts needs the smallest power of two that is at least H + 2, and the prefix is 32 minus the exponent. Then place subnets from largest to smallest. Alignment is the reason. A block of 128 must start at a multiple of 128; if you put a /27 first and a /25 after it, the /25 would not fit at offset 32 and you would have to skip ahead and waste addresses.
The script below splits the /24 evenly, then allocates a plan of 100, 50, 20 and 2 hosts.
import ipaddress as ip
from math import ceil, log2
block = ip.ip_network("198.51.100.0/24")
print([str(s) for s in block.subnets(new_prefix=26)])
needs = {"servers": 100, "staff": 50, "lab": 20, "uplink": 2}
cursor = int(block.network_address)
for name, hosts in sorted(needs.items(), key=lambda kv: -kv[1]):
prefix = 32 - ceil(log2(hosts + 2))
size = 2 ** (32 - prefix)
assert cursor % size == 0, "block would be misaligned"
net = ip.ip_network((cursor, prefix))
print(f"{name:8} need {hosts:3} -> {str(net):18} usable {size - 2}")
cursor += size
print("next free:", ip.ip_address(cursor))A run of this code prints:
['198.51.100.0/26', '198.51.100.64/26', '198.51.100.128/26', '198.51.100.192/26']
servers need 100 -> 198.51.100.0/25 usable 126
staff need 50 -> 198.51.100.128/26 usable 62
lab need 20 -> 198.51.100.192/27 usable 30
uplink need 2 -> 198.51.100.224/30 usable 2
next free: 198.51.100.228The /25 takes 128 addresses for 100 hosts, the /26 takes 64 for 50, the /27 takes 32 for 20 and the /30 takes 4 for 2. Together they use 228 of the 256 addresses, so the cursor stops at 198.51.100.228 and the last 28 addresses are free for growth. The assert guards the alignment rule; processing the needs in ascending order would trip it. Using a /31 for the uplink would work too, per RFC 3021, and save two more addresses.
Real plans should be sized for the hosts you expect later, not the ones you have today. A half-empty /26 costs nothing. Renumbering a subnet that has outgrown its block means touching every static address, DHCP scope and firewall rule that names it.
05CIDR and aggregation
Before CIDR, the network part was implied by the address itself and fell on 8, 16 or 24 bits; a site that needed 2,000 hosts had to take a block far larger than it could use. Classless Inter-Domain Routing, specified in RFC 4632, made the prefix explicit and any length from 0 to 32. Section 3 of that RFC says the community chose to deprecate the class A, B and C assignment system in favour of classless prefixes. Today the mask comes from configuration: 10.0.0.0/24 is a perfectly valid network, even though 10.x was once a class A address.
The second gain is aggregation. If a provider owns four consecutive /24s, it can tell the rest of the internet about one /22 instead of four routes. RFC 4632 (section 4.1) treats aggregation as the only widely understood way to reduce routing state.
Two blocks merge only when they are adjacent and the lower one is aligned to the size of the combined block. The script tries three cases.
import ipaddress as ip
four = [ip.ip_network(f"10.0.{i}.0/24") for i in range(4)]
print([str(n) for n in ip.collapse_addresses(four)])
odd = [ip.ip_network(f"203.0.{i}.0/24") for i in (113, 114)]
print([str(n) for n in ip.collapse_addresses(odd)])
trio = [ip.ip_network(f"203.0.{i}.0/24") for i in (112, 113, 114)]
print([str(n) for n in ip.collapse_addresses(trio)])
print(ip.ip_network("203.0.113.0/24").supernet())A run of this code prints:
['10.0.0.0/22']
['203.0.113.0/24', '203.0.114.0/24']
['203.0.112.0/23', '203.0.114.0/24']
203.0.112.0/23Four /24s from 10.0.0.0 to 10.0.3.0 collapse to 10.0.0.0/22. The next pair, 203.0.113.0/24 and 203.0.114.0/24, are neighbours but do not merge: a /23 must start on an even third octet, so 113 pairs with 112, not 114. Add 112 and the first two merge into 203.0.112.0/23 while 114 stays alone. The supernet() call confirms the pairing for 113.
Aggregation has a safety rule. Under rule 2 of RFC 4632 section 5.1, a router that generates an aggregate must discard packets that match the aggregate route but none of the more-specific routes behind it, which in practice means pointing the aggregate at a null route. The RFC gives the reason: it prevents forwarding loops when some addresses inside the aggregate are not reachable.
06How hosts and routers use the prefix
Hosts make a binary choice per packet. AND the destination with your own mask; if the result equals your network address, the target is on the same link. On IPv4 the host then uses ARP (the Address Resolution Protocol) to learn the target's MAC address and sends the frame straight to it. If not, the packet goes to the default gateway, and ARP is used to find the gateway's MAC address instead. A mistaken mask therefore causes odd failures: some destinations work, some are sent to the router that should have been reached directly.
The question "are 10.1.2.3 and 10.1.3.4 in the same subnet?" depends entirely on the prefix.
import ipaddress as ip
def same_subnet(a, b, prefix):
mask = int(ip.ip_network(f"0.0.0.0/{prefix}").netmask)
return int(ip.ip_address(a)) & mask == int(ip.ip_address(b)) & mask
for prefix in (24, 23):
print(prefix, same_subnet("10.1.2.3", "10.1.3.4", prefix))
# the block-size trick: a /23 spans 2 values of the third octet
n = ip.ip_network("10.1.3.4/23", strict=False)
print(n, n[0], n[-1])A run of this code prints:
24 False
23 True
10.1.2.0/23 10.1.2.0 10.1.3.255With /24 the third octets 2 and 3 differ, so the answer is no. With /23 the block size in the third octet is 2, the network is 10.1.2.0/23 and it spans 10.1.2.0 to 10.1.3.255, so both are inside.
Routers apply the same idea to many prefixes at once. RFC 4632 (section 5.1) states that forwarding in the Internet is done on a longest-match basis: among all routes that contain the destination, the most specific wins. That is why a default route 0.0.0.0/0, which matches everything with a prefix length of zero, only catches traffic nobody else claims.
import ipaddress as ip
routes = {
"0.0.0.0/0": "default via ISP",
"10.0.0.0/8": "corporate core",
"10.1.0.0/16": "region A",
"10.1.2.0/24": "branch office",
}
table = [(ip.ip_network(p), hop) for p, hop in routes.items()]
def lookup(dst):
dst = ip.ip_address(dst)
matches = [(n, hop) for n, hop in table if dst in n]
return max(matches, key=lambda m: m[0].prefixlen)
for dst in ("10.1.2.9", "10.1.9.9", "10.9.9.9", "8.8.8.8"):
n, hop = lookup(dst)
print(f"{dst:9} -> {str(n):12} {hop}")A run of this code prints:
10.1.2.9 -> 10.1.2.0/24 branch office
10.1.9.9 -> 10.1.0.0/16 region A
10.9.9.9 -> 10.0.0.0/8 corporate core
8.8.8.8 -> 0.0.0.0/0 default via ISPMetric is not part of this decision. It breaks ties between routes with the same prefix length, and the preference between routing sources (administrative distance on many platforms) likewise applies only to the same prefix. A /24 learned with a poor metric still beats a /16 with a perfect one, because specificity is compared first.
For where ARP and Ethernet frames sit relative to IP, see the encapsulation discussion in OSI vs TCP/IP Model; for the name-to-address step that comes before any of this, see How DNS Resolution Works.
07Special ranges and the traps around them
RFC 1918 reserves three private blocks: 10.0.0.0/8, 172.16.0.0/12 (172.16.0.0 to 172.31.255.255) and 192.168.0.0/16. Section 3 of the RFC says private hosts "cannot have IP connectivity to any host outside of the enterprise", though they can still reach outside services through mediating gateways such as application-layer gateways. The same section says routing information about private networks shall not be propagated on inter-enterprise links, and that ISP routers are expected to filter it out. The most common slip is stretching the middle block: 172.32.0.1 is outside it.
Other ranges look private but are not RFC 1918. 100.64.0.0/10 is shared address space reserved by RFC 6598 for carrier-grade NAT. 169.254.0.0/16 is link-local (RFC 3927), and routers must not forward packets to or from it. 127.0.0.0/8 is loopback. The IANA special-purpose registry lists each with whether it is forwardable and globally reachable.
Python's flags follow that registry rather than RFC 1918, which produces surprises. The Python version matters because the lists change between releases.
import sys
import ipaddress as ip
print(sys.version.split()[0])
for a in ("10.1.2.3", "172.31.255.1", "172.32.0.1", "100.64.0.1",
"169.254.1.5", "127.0.0.1", "192.0.2.5", "8.8.8.8"):
x = ip.ip_address(a)
print(f"{a:13} private={x.is_private!s:5} global={x.is_global!s:5} link_local={x.is_link_local}")A run of this code prints:
3.13.16
10.1.2.3 private=True global=False link_local=False
172.31.255.1 private=True global=False link_local=False
172.32.0.1 private=False global=True link_local=False
100.64.0.1 private=False global=False link_local=False
169.254.1.5 private=True global=False link_local=True
127.0.0.1 private=True global=False link_local=False
192.0.2.5 private=True global=False link_local=False
8.8.8.8 private=False global=True link_local=False172.31.255.1 is private and 172.32.0.1 is global, so the 172 boundary is real. 100.64.0.1 is neither private nor global: a filter built on is_private alone misses it. Loopback, link-local and the documentation address 192.0.2.5 all report as private here too, although most people saying "private" mean only the three RFC 1918 blocks. If your real question is whether an address can be reached from the public internet, ask is_global. is_private answers a looser question.
This matters when you write allow-lists and outbound request filters. A server-side check that blocks only the three RFC 1918 blocks lets 100.64/10 and 169.254/16 through. Parse the address with a library, normalise it, then test it against the ranges you actually mean. A private address is also not a security boundary by itself; it only says the address is not meant to be reached from outside.
08Common interview questions
Q: How many usable hosts are in a /26, and why minus two?
A: A /26 has 6 host bits, so 2^6 = 64 addresses. The all-zeros host value is the network address and the all-ones value is the broadcast, leaving 62. A /31 keeps both addresses on a point-to-point link (RFC 3021) and a /32 is a single host.
Q: Are 10.1.2.3/24 and 10.1.3.4/24 in the same subnet? What changes with /23?
A: AND each with the mask. With /24 you get 10.1.2.0 and 10.1.3.0, so they differ. With /23 both give 10.1.2.0, so they are the same subnet. By block size: a /23 spans two values of the third octet, starting on an even one.
Q: Split 198.51.100.0/24 into four equal subnets, then into subnets for 100, 50, 20 and 2 hosts.
A: Four /26s starting at .0, .64, .128 and .192. For the uneven plan, allocate largest first: 198.51.100.0/25, 198.51.100.128/26, 198.51.100.192/27 and 198.51.100.224/30, keeping each block aligned to its own size.
Q: What is CIDR and what does aggregation buy you?
A: CIDR drops fixed class boundaries so any prefix length can be used. That conserves address space and lets a provider advertise several contiguous aligned blocks as one shorter prefix, shrinking routing tables. Four /24s from 10.0.0.0 become 10.0.0.0/22, but 203.0.113.0/24 and 203.0.114.0/24 do not merge.
Q: A router has 0.0.0.0/0, 10.0.0.0/8, 10.1.0.0/16 and 10.1.2.0/24. Where does 10.1.9.9 go?
A: To the 10.1.0.0/16 route. Longest matching prefix wins; 10.1.2.0/24 does not contain the address. Metric only breaks ties between equal prefix lengths.
Q: Is 172.32.0.1 private? Is 100.64.0.1?
A: Neither is RFC 1918. The 172.16.0.0/12 block ends at 172.31.255.255, and 100.64.0.0/10 is carrier-grade NAT space that is not globally reachable.