CIDR and Subnetting Explained with Examples

Somebody hands you 10.20.3.64/28 and asks whether 10.20.3.80 is inside it. Or a cloud console refuses your subnet because it “overlaps an existing CIDR block”. Or a container network quietly swallows half your office range and nothing on 172.17.x.x can be reached any more. All three are the same small piece of maths, and once it clicks it stays clicked.

This is CIDR and subnetting the way I actually use it: enough theory to do it in your head, then the tools that do it for you, then the places it goes wrong on real systems. Every command below was run on Ubuntu 24.04 and Red Hat Enterprise Linux 9.7, and the two ship different ipcalc programs with the same name, which is its own small trap. If you want the ground floor first, addresses, octets, what a mask is for, start with IP addressing and subnetting for Linux and come back. This one goes further.

What the slash actually says

An IPv4 address is 32 bits. CIDR notation, the /24 on the end, tells you how many of those bits, counting from the left, belong to the network. The rest belong to hosts. That’s it. Everything else is arithmetic on that one number.

$ ipcalc 192.168.10.0/24
Address:   192.168.10.0         11000000.10101000.00001010. 00000000
Netmask:   255.255.255.0 = 24   11111111.11111111.11111111. 00000000
Wildcard:  0.0.0.255            00000000.00000000.00000000. 11111111
=>
Network:   192.168.10.0/24      11000000.10101000.00001010. 00000000
HostMin:   192.168.10.1         11000000.10101000.00001010. 00000001
HostMax:   192.168.10.254       11000000.10101000.00001010. 11111110
Broadcast: 192.168.10.255       11000000.10101000.00001010. 11111111
Hosts/Net: 254                   Class C, Private Internet

Look at the binary column, that’s the whole lesson in one screen. The netmask is 24 ones followed by 8 zeros. The gap ipcalc draws in each line sits exactly where the ones stop. Left of the gap never changes inside this network; right of the gap is what varies from host to host.

Three things fall out of “8 host bits”:

  • 28 = 256 addresses in the block.
  • The first one (all host bits zero) is the network address, the last (all ones) is broadcast. You can’t assign either, so 254 usable.
  • The mask in decimal is 255.255.255.0, and the wildcard, which Cisco ACLs and some firewalls want, is the mask inverted: 0.0.0.255.

The word “classless” in CIDR is the historical bit. Before 1993 you got a Class A, B or C, which meant /8, /16 or /24 and nothing in between. Need 300 addresses? That’s a /16, enjoy your 65,000 spares. CIDR let the prefix be any length, so a /23 (510 hosts) became a thing. ipcalc still prints “Class C” out of habit. Ignore it.

The prefixes you’ll actually meet

You don’t need all 32. In practice you keep bumping into the same dozen, and it’s worth knowing these cold:

Prefix Mask Addresses Usable Where you see it
/8 255.0.0.0 16,777,216 16,777,214 10.0.0.0/8 private space
/16 255.255.0.0 65,536 65,534 a whole VPC, docker0 default
/20 255.255.240.0 4,096 4,094 AWS default VPC subnets
/22 255.255.252.0 1,024 1,022 a site or a big VLAN
/23 255.255.254.0 512 510 two /24s glued together
/24 255.255.255.0 256 254 the everyday LAN
/25 255.255.255.128 128 126 half a /24
/26 255.255.255.192 64 62 a quarter of a /24
/27 255.255.255.224 32 30 small server segment
/28 255.255.255.240 16 14 a rack, a DMZ
/29 255.255.255.248 8 6 what an ISP gives a business
/30 255.255.255.252 4 2 router-to-router link
/31 255.255.255.254 2 2 point-to-point, RFC 3021
/32 255.255.255.255 1 1 one host, a loopback, a route

Two rows deserve a note. A /31 has no network or broadcast address at all; both addresses are usable, and every modern router and Linux kernel handles it. It exists so point-to-point links stop wasting half their addresses. And a /32 isn’t really a “network”, it’s how you write a single address in CIDR form, which is why you see it in routing tables, firewall rules and Kubernetes flannel.1 interfaces.

Doing it in your head

You won’t always have ipcalc. Interviews, whiteboards, a console session on a box with nothing installed. The trick that works every time is the block size.

Find the octet the prefix boundary falls in, write down the mask value for that octet, and subtract it from 256. That’s the block size, and networks start at multiples of it. Two steps, no binary.

Say I’m asked which network 192.168.10.37/26 belongs to.

  1. /26 means 24 bits cover the first three octets, and 2 more bits reach into the fourth. Mask for the fourth octet: 2 bits set = 128 + 64 = 192.
  2. Block size = 256 – 192 = 64.
  3. Networks start at 0, 64, 128, 192. Thirty-seven sits in the 0 to 63 block.
  4. So: network 192.168.10.0, broadcast 192.168.10.63, usable .1 to .62.

Check it:

$ ipcalc 192.168.10.37/26
Address:   192.168.10.37        11000000.10101000.00001010.00 100101
Netmask:   255.255.255.192 = 26 11111111.11111111.11111111.11 000000
Wildcard:  0.0.0.63             00000000.00000000.00000000.00 111111
=>
Network:   192.168.10.0/26      11000000.10101000.00001010.00 000000
HostMin:   192.168.10.1         11000000.10101000.00001010.00 000001
HostMax:   192.168.10.62        11000000.10101000.00001010.00 111110
Broadcast: 192.168.10.63        11000000.10101000.00001010.00 111111
Hosts/Net: 62                    Class C, Private Internet

Same trick for the bigger prefixes, just in a different octet. A /20 puts the boundary in the third octet with 4 network bits there: mask 240, block size 16, so networks start at x.x.0.0, x.x.16.0, x.x.32.0 and so on. 172.16.0.0/20 therefore runs to 172.16.15.255. sipcalc, the other calculator worth installing on Ubuntu, lays it out with the hex and decimal forms too:

$ sipcalc 172.16.0.0/20
-[ipv4 : 172.16.0.0/20] - 0

[CIDR]
Host address            - 172.16.0.0
Host address (decimal)  - 2886729728
Host address (hex)      - AC100000
Network address         - 172.16.0.0
Network mask            - 255.255.240.0
Network mask (bits)     - 20
Network mask (hex)      - FFFFF000
Broadcast address       - 172.16.15.255
Cisco wildcard          - 0.0.15.255
Addresses in network    - 4096
Network range           - 172.16.0.0 - 172.16.15.255
Usable range            - 172.16.0.1 - 172.16.15.254

Subnetting: cutting one block into several

Subnetting is borrowing host bits to make more, smaller networks. Every bit you borrow doubles the number of subnets and halves the size of each. A /24 borrowed by two bits gives four /26s:

$ ipcalc 192.168.10.0/24 --split 62 62 62 62
...
1. Requested size: 62 hosts
Netmask:   255.255.255.192 = 26 11111111.11111111.11111111.11 000000
Network:   192.168.10.0/26      11000000.10101000.00001010.00 000000
HostMin:   192.168.10.1         11000000.10101000.00001010.00 000001
HostMax:   192.168.10.62        11000000.10101000.00001010.00 111110
Broadcast: 192.168.10.63        11000000.10101000.00001010.00 111111
Hosts/Net: 62                    Class C, Private Internet
2. Requested size: 62 hosts
Network:   192.168.10.64/26     11000000.10101000.00001010.01 000000
HostMin:   192.168.10.65
HostMax:   192.168.10.126
Broadcast: 192.168.10.127
3. Requested size: 62 hosts
Network:   192.168.10.128/26    11000000.10101000.00001010.10 000000
...
4. Requested size: 62 hosts
Network:   192.168.10.192/26    11000000.10101000.00001010.11 000000
...
Needed size:  256 addresses.
Used network: 192.168.10.0/24
Unused:

Watch the two borrowed bits tick through 00, 01, 10, 11 in the binary column. That’s all a subnet number is. And note the cost: 254 usable addresses became 4 × 62 = 248, because each subnet pays its own network and broadcast tax.

Python can do the same split, which is handy inside scripts and Ansible filters:

$ python3 -c 'import ipaddress; print([str(s) for s in ipaddress.ip_network("10.20.3.0/24").subnets(new_prefix=26)])'
['10.20.3.0/26', '10.20.3.64/26', '10.20.3.128/26', '10.20.3.192/26']

VLSM: subnets of different sizes

Equal splits are neat but real networks aren’t. You’ve got a user VLAN that needs 500 addresses, servers that need 200, a management network of 50 and a couple of firewall links. Variable Length Subnet Masking just means: sort the requirements largest first, carve each one off the front of the space, and keep going.

Here’s a /22 (1,022 usable) carved for exactly that. ipcalc’s -s does the allocation and, importantly, keeps every subnet aligned to its own block size:

$ ipcalc 10.20.0.0/22 -s 500 200 50 10
...
Network:   10.20.0.0/23         00001010.00010100.0000000 0.00000000
HostMin:   10.20.0.1
HostMax:   10.20.1.254
Hosts/Net: 510                   <- users (asked 500)

Network:   10.20.2.0/24         00001010.00010100.00000010. 00000000
HostMin:   10.20.2.1
HostMax:   10.20.2.254
Hosts/Net: 254                   <- servers (asked 200)

Network:   10.20.3.0/26         00001010.00010100.00000011.00 000000
HostMin:   10.20.3.1
HostMax:   10.20.3.62
Hosts/Net: 62                    <- management (asked 50)

Network:   10.20.3.64/28        00001010.00010100.00000011.0100 0000
HostMin:   10.20.3.65
HostMax:   10.20.3.78
Hosts/Net: 14                    <- links (asked 10)

Needed size:  848 addresses.
Used network: 10.20.0.0/22

A few things to notice, because this is where hand-rolled plans go wrong:

  • Each subnet starts on a multiple of its own size. The /26 couldn’t start at 10.20.3.1; it has to start at a multiple of 64. That alignment rule is what people forget when they carve by hand, and a misaligned subnet is one a router will refuse or, worse, accept and misroute.
  • Largest first. If you’d allocated the /28 first at 10.20.0.0, the /23 would then need to start at 10.20.2.0 and no longer fit.
  • 848 of 1,024 used, so 10.20.3.80 to 10.20.3.255 is still free for later. Write that down somewhere. Future you will thank you.

And the question from the top of the article, is 10.20.3.80 inside 10.20.3.64/28? No. The /28 runs .64 to .79, so .80 is the first address of the next block:

$ python3 -c 'import ipaddress; n=ipaddress.ip_network("10.20.3.64/28"); print(ipaddress.ip_address("10.20.3.70") in n, ipaddress.ip_address("10.20.3.80") in n)'
True False

Supernetting: the other direction

Aggregation is subnetting in reverse. Two adjacent, aligned /24s can be advertised as one /23, which is how the internet’s routing table stays at under a million entries instead of tens of millions. Python’s collapse_addresses will tell you whether a set of blocks can be summarised, and overlaps() is the check every cloud console runs before it rejects your subnet:

$ python3 -c 'import ipaddress as ip; print(list(ip.collapse_addresses([ip.ip_network("10.20.0.0/24"), ip.ip_network("10.20.1.0/24")])))'
[IPv4Network('10.20.0.0/23')]

$ python3 -c 'import ipaddress as ip; print(ip.ip_network("10.20.0.0/22").overlaps(ip.ip_network("10.20.2.0/24")))'
True

10.20.1.0/24 and 10.20.2.0/24 would not collapse, by the way. They’re adjacent but not aligned: a /23 has to start on an even third octet. Adjacent isn’t enough.

How Linux uses all this

Every address on a Linux box carries a prefix, and the kernel uses it for one decision: is the destination on my link, or do I hand it to a gateway? Have a look at a machine running Docker and a small Kubernetes node:

$ ip -br addr show | grep -v '^lo\|veth'
eth0             UP             172.21.3.249/20 fe80::215:5dff:fed3:5fcc/64
docker0          DOWN           172.17.0.1/16
flannel.1        UNKNOWN        10.42.0.0/32 fe80::d098:c1ff:fe43:b2a5/64
cni0             UP             10.42.0.1/24 fe80::409a:6aff:fe1c:d22c/64

$ ip route
default via 172.21.0.1 dev eth0 proto kernel
10.42.0.0/24 dev cni0 proto kernel scope link src 10.42.0.1
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown
172.21.0.0/20 dev eth0 proto kernel scope link src 172.21.3.249

Each scope link route was created automatically from the prefix on the interface. The kernel picks a route by longest prefix match: the most specific route that contains the destination wins, and default (which is 0.0.0.0/0, the shortest possible prefix) only wins when nothing else does. ip route get shows the decision without sending a packet:

$ ip route get 10.42.0.9
10.42.0.9 dev cni0 src 10.42.0.1 uid 1000
$ ip route get 172.17.0.9
172.17.0.9 dev docker0 src 172.17.0.1 uid 1000
$ ip route get 172.20.0.5
172.20.0.5 via 172.21.0.1 dev eth0 src 172.21.3.249 uid 1000

That last one is the classic. 172.20.0.5 looks like it’s “near” 172.21.3.249, but 172.21.0.0/20 covers 172.21.0.0 to 172.21.15.255 and no further, so off to the gateway it goes. Get the prefix wrong on an interface, say a /16 where it should be a /20, and the kernel will try to ARP for hosts that are actually behind a router, and they’ll be unreachable while everything looks configured. When ping works to some hosts on a segment and not others, check the prefix before anything else. ss and nmap both take CIDR arguments too; nmap -sn 10.20.3.64/28 sweeps exactly the 16 addresses of that block and nothing else.

The tools, and the Ubuntu vs RHEL trap

On Ubuntu, apt install ipcalc sipcalc gets you both calculators used above. ipcalc there is the Perl script by Krischan Jodies, the one with the binary column and the --split and -s options.

RHEL 9 also has a command called ipcalc, and it’s a completely different program from the initscripts family. Same name, different flags, different output, and it’s already installed:

$ rpm -q ipcalc
ipcalc-1.0.0-5.el9.x86_64

$ ipcalc --all 10.20.3.64/28
Network:        10.20.3.64/28
Netmask:        255.255.255.240 = 28
Broadcast:      10.20.3.79
Reverse DNS:    64-79.3.20.10.in-addr.arpa.

Address space:  Private Use
Address class:  Class A
HostMin:        10.20.3.65
HostMax:        10.20.3.78
Hosts/Net:      14

$ ipcalc -np 192.168.10.37/26
PREFIX=26
NETWORK=192.168.10.0

That KEY=VALUE output is by design: it’s meant to be eval‘d in shell scripts, which is what the old network-scripts did with it. It won’t split or do VLSM. Copy the Ubuntu one-liners onto a RHEL box and --split fails with “bad IPv4 prefix”, while -s means silent here, so it prints nothing at all and you sit there wondering. Neither sipcalc nor the Perl ipcalc is packaged for RHEL 9, not in BaseOS, AppStream or EPEL at the time of writing. Use Python instead, which behaves identically on both:

$ python3 -c 'import ipaddress; n=ipaddress.ip_network("10.20.3.64/28"); print(n.netmask, n.num_addresses-2, list(n.hosts())[0], list(n.hosts())[-1], n.broadcast_address)'
255.255.255.240 14 10.20.3.65 10.20.3.78 10.20.3.79

The ipaddress module has been in the standard library since 3.3. It’s what I reach for in Ansible filters and Terraform pre-checks, and it’s the one that’s guaranteed to be there.

IPv6 in one section

Everything above works the same way with 128 bits instead of 32, the notation is identical, and there’s much less arithmetic because the conventions are rigid. A site gets a /48. Every LAN inside it is a /64, no exceptions, because SLAAC needs 64 host bits. That leaves 16 bits between them, so a /48 holds 65,536 /64s, and nobody is ever going to run out:

$ sipcalc 2001:db8:abcd::/48
-[ipv6 : 2001:db8:abcd::/48] - 0

[IPV6 INFO]
Expanded Address        - 2001:0db8:abcd:0000:0000:0000:0000:0000
Compressed address      - 2001:db8:abcd::
Subnet prefix (masked)  - 2001:db8:abcd:0:0:0:0:0/48
Prefix length           - 48
Address type            - Aggregatable Global Unicast Addresses
Network range           - 2001:0db8:abcd:0000:0000:0000:0000:0000 -
                          2001:0db8:abcd:ffff:ffff:ffff:ffff:ffff

$ python3 -c 'import ipaddress; print(ipaddress.ip_network("2001:db8:abcd::/48").num_addresses // 2**64)'
65536

There’s no broadcast address in IPv6 and no “usable minus two”. The fe80::/64 addresses you saw on every interface earlier are link-local, auto-generated, and never routed; every IPv6-capable interface has one whether you configured it or not.

Where it bites in practice

Cloud subnets are smaller than they look. AWS reserves the first four addresses and the last one in every subnet, so a /28 gives you 11 hosts, not 14, and a /24 gives 251. Azure reserves five as well. Plan a /27 for anything that might autoscale. The ALB setup I wrote up needs a subnet in at least two availability zones, and each one wants a /27 or larger.

Docker and Kubernetes pick ranges for you. The docker0 bridge takes 172.17.0.0/16 by default and will happily do so on a corporate network that also uses 172.17. The symptom is that one office subnet becomes unreachable from every Docker host, and only from Docker hosts. Set "bip" or "default-address-pools" in /etc/docker/daemon.json before you get there. Same story with the pod and service CIDRs when you bootstrap a cluster with kubeadm: --pod-network-cidr is a decision you make once and can’t change without rebuilding.

VPN and VPC peering want no overlap, ever. Two networks both using 10.0.0.0/16 cannot be joined, full stop, and renumbering one of them later is a project measured in weekends. If you’re allocating fresh, spread across the private ranges: 10.x for one region or cloud, 172.16 to 172.31 for another, 192.168 for offices. overlaps() in a pre-commit check costs nothing.

Leave gaps. That VLSM plan used 848 of 1,024 addresses. The spare /28s at the end are how you add a subnet next year without touching anything that exists.

Frequently asked questions

What’s the difference between CIDR and a subnet mask?

They say the same thing two ways. /26 and 255.255.255.192 are one fact: 26 network bits. CIDR is shorter and is what routing tables, cloud consoles and ip use; dotted masks survive in older configs, Windows dialogs and some firewalls.

Why is a /24 only 254 hosts and not 256?

The all-zeros host address names the network itself and the all-ones address is broadcast. Neither can be given to a machine. The exception is /31, where both addresses are usable because there’s no room for anything else and point-to-point links don’t need broadcast.

Is 10.0.0.0/8 one network?

It’s one allocation, sixteen million private addresses reserved by RFC 1918. Nobody runs it as one flat network. You subnet it, usually into /16s per site or region and /24s below that.

How do I find out which subnet an IP is in?

On any Linux box: ip route get ADDRESS tells you what the kernel would do with it. To test against an arbitrary block, python3 -c 'import ipaddress; print(ipaddress.ip_address("A") in ipaddress.ip_network("B"))'. By hand: block size, then find which multiple the address falls after.

Which subnet size should I use for a new server VLAN?

Count what you’ll have in three years, double it, then pick the next prefix up. A /24 for anything that might grow, a /26 or /27 for things that won’t. Address space is free; renumbering isn’t.

Do I still need to learn this if I only use the cloud?

More than ever. VPC CIDRs, subnet CIDRs, security group rules, peering, VPN routes and Kubernetes pod ranges are all CIDR, and the console will not tell you why an overlap is an overlap. The arithmetic above is the explanation.

Avatar photo

Asif Khan

I am a DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.