Learn IP Address Concepts With Python's ipaddress Module

Learn IP Address Concepts With Python's ipaddress Module

by Brad Solomon intermediate

Python’s ipaddress module is an underappreciated gem from the Python standard library. You don’t have to be a full-blown network engineer to have been exposed to IP addresses in the wild. IP addresses and networks are ubiquitous in software development and infrastructure. They underpin how computers, well, address each other.

Learning through doing is an effective way to master IP addresses. The ipaddress module allows you to do just that by viewing and manipulating IP addresses as Python objects. In this tutorial, you’ll get a better grasp of IP addresses by using some of the features of Python’s ipaddress module.

In this tutorial, you’ll learn:

  • How IP addresses work, both in theory and in Python code
  • How IP networks represent groups of IP addresses and how you can inspect relationships between the two
  • How Python’s ipaddress module cleverly uses a classic design pattern to allow you to do more with less

To follow along, you just need Python 3.3 or higher since ipaddress was added to the Python standard library in that version. The examples in this tutorial were generated using Python 3.8.

IP Addresses in Theory and Practice

If you remember only one concept about IP addresses, then remember this: an IP address is an integer. This piece of information will help you better understand both how IP addresses function and how they can be represented as Python objects.

Before you jump into any Python code, it can be helpful to see this concept fleshed out mathematically. If you’re here just for some examples of how to use the ipaddress module, then you can skip down to the next section, on using the module itself.

Mechanics of IP Addresses

You saw above that an IP address boils down to an integer. A fuller definition is that an IPv4 address is a 32-bit integer used to represent a host on a network. The term host is sometimes used synonymously with an address.

It follows that there are 232 possible IPv4 addresses, from 0 to 4,294,967,295 (where the upper bound is 232 - 1). But this is a tutorial for human beings, not robots. No one wants to ping the IP address 0xdc0e0925.

The more common way to express an IPv4 address is using quad-dotted notation, which consists of four dot-separated decimal integers:

220.14.9.37

It’s not immediately obvious what underlying integer the address 220.14.9.37 represents, though. Formulaically, you can break the IP address 220.14.9.37 into its four octet components:

Python
>>> (
...     220 * (256 ** 3) +
...      14 * (256 ** 2) +
...       9 * (256 ** 1) +
...      37 * (256 ** 0)
... )
3691907365

As shown above, the address 220.14.9.37 represents the integer 3,691,907,365. Each octet is a byte, or a number from 0 to 255. Given this, you can infer that the maximum IPv4 address is 255.255.255.255 (or FF.FF.FF.FF in hex notation), while the minimum is 0.0.0.0.

Next, you’ll see how Python’s ipaddress module does this calculation for you, allowing you to work with the human-readable form and let the address arithmetic occur out of sight.

The Python ipaddress Module

To follow along, you can fetch your computer’s external IP address to work with at the command line:

Shell
$ curl -sS ifconfig.me/ip
220.14.9.37

This requests your IP address from the site ifconfig.me, which can be used to show an array of details about your connection and network.

Now open up a Python REPL. You can use the IPv4Address class to build a Python object that encapsulates an address:

Python
>>> from ipaddress import IPv4Address

>>> addr = IPv4Address("220.14.9.37")
>>> addr
IPv4Address('220.14.9.37')

Passing a str such as "220.14.9.37" to the IPv4Address constructor is the most common approach. However, the class can also accept other types:

Python
>>> IPv4Address(3691907365)  # From an int
IPv4Address('220.14.9.37')

>>> IPv4Address(b"\xdc\x0e\t%")  # From bytes (packed form)
IPv4Address('220.14.9.37')

While constructing from a human-readable str is probably the more common way, you might see bytes input if you’re working with something like TCP packet data.

The conversions above are possible in the other direction as well:

Python
>>> int(addr)
3691907365
>>> addr.packed
b'\xdc\x0e\t%'

In addition to allowing round-trip input and output to different Python types, instances of IPv4Address are also hashable. This means you can use them as keys in a mapping data type such as a dictionary:

Python
>>> hash(IPv4Address("220.14.9.37"))
4035855712965130587

>>> num_connections = {
...     IPv4Address("220.14.9.37"): 2,
...     IPv4Address("100.201.0.4"): 16,
...     IPv4Address("8.240.12.2"): 4,
... }

On top of that, IPv4Address also implements methods that allow for comparisons using the underlying integer:

Python
>>> IPv4Address("220.14.9.37") > IPv4Address("8.240.12.2")
True

>>> addrs = (
...     IPv4Address("220.14.9.37"),
...     IPv4Address("8.240.12.2"),
...     IPv4Address("100.201.0.4"),
... )
>>> for a in sorted(addrs):
...     print(a)
...
8.240.12.2
100.201.0.4
220.14.9.37

You can use any of the standard comparison operators to compare the integer values of address objects.

As you’ve seen above, the constructor itself for IPv4Address is short and sweet. It’s when you start lumping addresses into groups, or networks, that things become more interesting.

IP Networks and Interfaces

A network is a group of IP addresses. Networks are described and displayed as contiguous ranges of addresses. For example, a network may be composed of the addresses 192.4.2.0 through 192.4.2.255, a network containing 256 addresses.

You could recognize a network by its upper and lower IP addresses, but how can you display this with a more succinct convention? That’s where CIDR notation comes in.

CIDR Notation

A network is defined using a network address plus a prefix in Classless Inter-Domain Routing (CIDR) notation:

Python
>>> from ipaddress import IPv4Network
>>> net = IPv4Network("192.4.2.0/24")
>>> net.num_addresses
256

CIDR notation represents a network as <network_address>/<prefix>. The routing prefix (or prefix length, or just prefix), which is 24 in this case, is the count of leading bits used to answer questions such as whether a certain address is part of a network or how many addresses reside in a network. (Here leading bits refers to the first N bits counting from the left of the integer in binary.)

You can find the routing prefix with the .prefixlen property:

Python
>>> net.prefixlen
24

Let’s jump right into an example. Is the address 192.4.2.12 in the network 192.4.2.0/24? The answer in this case is yes, because the leading 24 bits of 192.4.2.12 are the first three octets (192.4.2). With a /24 prefix, you can simply chop off the last octet and see that the 192.4.2.xxx parts match.

Pictured differently, the /24 prefix translates to a netmask that, as its name implies, is used to mask bits in the addresses being compared:

Python
>>> net.netmask
IPv4Address('255.255.255.0')

You compare leading bits to determine whether an address is part of a network. If the leading bits match, then the address is part of the network:

11000000 00000100 00000010 00001100  # 192.4.2.12  # Host IP address
11000000 00000100 00000010 00000000  # 192.4.2.0   # Network address
                          |
                          ^ 24th bit (stop here!)
|_________________________|
            |
      These bits match

Above, the final 8 bits of 192.4.2.12 are masked (with 0) and are ignored in the comparison. Once again, Python’s ipaddress saves you the mathematical gymnastics and supports idiomatic membership testing:

Python
>>> net = IPv4Network("192.4.2.0/24")

>>> IPv4Address("192.4.2.12") in net
True
>>> IPv4Address("192.4.20.2") in net
False

This is made possible by the treasure that is operator overloading, by which IPv4Network defines __contains__() to allow membership testing using the in operator.

In the CIDR notation 192.4.2.0/24, the 192.4.2.0 part is the network address, which is used to identify the network:

Python
>>> net.network_address
IPv4Address('192.4.2.0')

As you saw above, the network address 192.4.2.0 can be seen as the expected result when a mask is applied to a host IP address:

11000000 00000100 00000010 00001100  # Host IP address
11111111 11111111 11111111 00000000  # Netmask, 255.255.255.0 or /24
11000000 00000100 00000010 00000000  # Result (compared to network address)

When you think about it this way, you can see how the /24 prefix actually translates into a true IPv4Address:

Python
>>> net.prefixlen
24
>>> net.netmask
IPv4Address('255.255.255.0')  # 11111111 11111111 11111111 00000000

In fact, if it strikes your fancy, you can construct an IPv4Network directly from two addresses:

Python
>>> IPv4Network("192.4.2.0/255.255.255.0")
IPv4Network('192.4.2.0/24')

Above, 192.4.2.0 is the network address while 255.255.255.0 is the netmask.

At the other end of the spectrum in a network is its final address, or broadcast address, which is a single address that can be used to communicate to all the hosts on its network:

Python
>>> net.broadcast_address
IPv4Address('192.4.2.255')

There’s one more point worth mentioning about the netmask. You’ll most often see prefix lengths that are multiples of 8:

Prefix Length Number of Addresses Netmask
8 16,777,216 255.0.0.0
16 65,536 255.255.0.0
24 256 255.255.255.0
32 1 255.255.255.255

However, any integer between 0 and 32 is valid, though less common:

Python
>>> net = IPv4Network("100.64.0.0/10")
>>> net.num_addresses
4194304
>>> net.netmask
IPv4Address('255.192.0.0')

In this section, you saw how to construct an IPv4Network instance and test whether a certain IP address sits within it. In the next section, you’ll learn how to loop over the addresses within a network.

Looping Through Networks

The IPv4Network class supports iteration, meaning that you can iterate over its individual addresses in a for loop:

Python
>>> net = IPv4Network("192.4.2.0/28")
>>> for addr in net:
...     print(addr)
...
192.4.2.0
192.4.2.1
192.4.2.2
...
192.4.2.13
192.4.2.14
192.4.2.15

Similarly, net.hosts() returns a generator that will yield the addresses shown above, excluding the network and broadcast addresses:

Python
>>> h = net.hosts()
>>> type(h)
<class 'generator'>
>>> next(h)
IPv4Address('192.4.2.1')
>>> next(h)
IPv4Address('192.4.2.2')

In the next section, you’ll dive in to a concept closely related to networks: the subnet.

Subnets

A subnet is a subdivision of an IP network:

Python
>>> small_net = IPv4Network("192.0.2.0/28")
>>> big_net = IPv4Network("192.0.0.0/16")
>>> small_net.subnet_of(big_net)
True
>>> big_net.supernet_of(small_net)
True

Above, small_net contains only 16 addresses, which is sufficient for you and a few cubicles around you. Conversely, big_net contains 65,536 addresses.

A common way to achieve subnetting is to take a network and increase its prefix length by 1. Let’s take this example from Wikipedia:

IPv4 Network Subnetting Diagram from Wikipedia
IPv4 Network Subnetting (Image source)

This example starts with a /24 network:

Python
net = IPv4Network("200.100.10.0/24")

Subnetting by increasing the prefix length from 24 to 25 involves shifting bits around to break up the network into smaller parts. This is a bit mathematically hairy. Luckily, IPv4Network makes it a cinch because .subnets() returns an iterator over the subnets:

Python
>>> for sn in net.subnets():
...     print(sn)
...
200.100.10.0/25
200.100.10.128/25

You can also tell .subnets() what the new prefix should be. A higher prefix means more and smaller subnets:

Python
>>> for sn in net.subnets(new_prefix=28):
...     print(sn)
...
200.100.10.0/28
200.100.10.16/28
200.100.10.32/28
...
200.100.10.208/28
200.100.10.224/28
200.100.10.240/28

Besides addresses and networks, there’s a third core part of the ipaddress module that you’ll see next.

Host Interfaces

Last but certainly not least, Python’s ipaddress module exports an IPv4Interface class for representing a host interface. A host interface is a way to describe, in a single compact form, both a host IP address and a network that it sits in:

Python
>>> from ipaddress import IPv4Interface

>>> ifc = IPv4Interface("192.168.1.6/24")
>>> ifc.ip  # The host IP address
IPv4Address('192.168.1.6')
>>> ifc.network  # Network in which the host IP resides
IPv4Network('192.168.1.0/24')

Above, 192.168.1.6/24 means “the IP address 192.168.1.6 in the network 192.168.1.0/24.”

Put differently, an IP address alone doesn’t tell you which network(s) that address sits in, and a network address is a group of IP addresses rather than a single one. The IPv4Interface gives you a way of simultaneously expressing, through CIDR notation, a single host IP address and its network.

Special Address Ranges

Now that you know about both IP addresses and networks at a high level, it’s also important to know that not all IP addresses are created equal—some are special.

The Internet Assigned Numbers Authority (IANA), in tandem with the Internet Engineering Task Force (IETF), oversees the allocation of different address ranges. IANA’s IPv4 Special-Purpose Address Registry is a very important table dictating that certain address ranges should have special meanings.

A common example is that of a private address. A private IP address is used for internal communication between devices on a network that doesn’t require connectivity to the public Internet. The following ranges are reserved for private use:

Range Number of Addresses Network Address Broadcast Address
10.0.0.0/8 16,777,216 10.0.0.0 10.255.255.255
172.16.0.0/12 1,048,576 172.16.0.0 172.31.255.255
192.168.0.0/16 65,536 192.168.0.0 192.168.255.255

A randomly chosen example is 10.243.156.214. So, how do you know that this address is private? You can confirm that it falls in the 10.0.0.0/8 range:

Python
>>> IPv4Address("10.243.156.214") in IPv4Network("10.0.0.0/8")
True

A second special address type is a link-local address, which is one reachable only from within a given subnet. An example is the Amazon Time Sync Service, which is available for AWS EC2 instances at the link-local IP 169.254.169.123. If your EC2 instance sits in a virtual private cloud (VPC), then you don’t need an Internet connection to tell your instance what time it is. The block 169.254.0.0/16 is reserved for link-local addresses:

Python
>>> timesync_addr = IPv4Address("169.254.169.123")
>>> timesync_addr.is_link_local
True

Above, you can see that one way to confirm that 10.243.156.214 is a private-use address is to test that it sits in the 10.0.0.0/8 range. But Python’s ipaddress module also provides a set of properties for testing whether an address is a special type:

Python
>>> IPv4Address("10.243.156.214").is_private
True
>>> IPv4Address("127.0.0.1").is_loopback
True

>>> [i for i in dir(IPv4Address) if i.startswith("is_")]  # "is_X" properties
['is_global',
 'is_link_local',
 'is_loopback',
 'is_multicast',
 'is_private',
 'is_reserved',
 'is_unspecified']

One thing to note about .is_private, though, is that it uses a broader definition of private network than the three IANA ranges shown in the table above. Python’s ipaddress module also lumps in other addresses that are allocated for private networks:

This is not an exhaustive list, but it covers the most common cases.

The Python ipaddress Module Under the Hood

In addition to its documented API, the CPython source code for the ipaddress module and its IPv4Address class gives some great insights into how you can use a pattern called composition to lend your own code an idiomatic API.

Composition’s Core Role

The ipaddress module takes advantage of an object-oriented pattern called composition. Its IPv4Address class is a composite that wraps a plain Python integer. IP addresses are fundamentally integers, after all.

Each IPv4Address instance has a quasi-private ._ip attribute that is itself an int. Many of the other properties and methods of the class are driven by the value of this attribute:

Python
>>> addr = IPv4Address("220.14.9.37")
>>> addr._ip
3691907365

The ._ip attribute is actually what’s responsible for producing int(addr). The chain of calls is that int(my_addr) calls my_addr.__int__(), which IPv4Address implements as just my_addr._ip:

Call stack for int(my_addr)

If you asked the CPython developers about this, then they might tell you that ._ip is an implementation detail. While nothing is truly private in Python, the leading underscore denotes that ._ip is quasi-private, not part of the public ipaddress API, and subject to change without notice. That’s why it’s more stable to extract the underlying integer with int(addr).

With all that said, though, it’s the underlying ._ip that gives the IPv4Address and IPv4Network classes their magic.

Extending IPv4Address

You can demonstrate the power of the underlying ._ip integer by extending the IPv4 address class:

Python
from ipaddress import IPv4Address

class MyIPv4(IPv4Address):
    def __and__(self, other: IPv4Address):
        if not isinstance(other, (int, IPv4Address)):
            raise NotImplementedError
        return self.__class__(int(self) & int(other))

Adding .__and__() lets you use the binary AND (&) operator. Now you can directly apply a netmask to a host IP:

Python
>>> addr = MyIPv4("100.127.40.32")
>>> mask = MyIPv4("255.192.0.0")  # A /10 prefix

>>> addr & mask
MyIPv4('100.64.0.0')

>>> addr & 0xffc00000  # Hex literal for 255.192.0.0
MyIPv4('100.64.0.0')

Above, .__and__() allows you to use either another IPv4Address or an int directly as the mask. Because MyIPv4 is a subclass of IPv4Address, the isinstance() check will return True in that case.

Besides operator overloading, you could add brand new properties as well:

Python
 1import re
 2from ipaddress import IPv4Address
 3
 4class MyIPv4(IPv4Address):
 5    @property
 6    def binary_repr(self, sep=".") -> str:
 7        """Represent IPv4 as 4 blocks of 8 bits."""
 8        return sep.join(f"{i:08b}" for i in self.packed)
 9
10    @classmethod
11    def from_binary_repr(cls, binary_repr: str):
12        """Construct IPv4 from binary representation."""
13        # Remove anything that's not a 0 or 1
14        i = int(re.sub(r"[^01]", "", binary_repr), 2)
15        return cls(i)

In .binary_repr (line 8), using .packed transforms the IP address into an array of bytes that is then formatted as the string representation of its binary form.

In .from_binary_repr, the call to int(re.sub(r"[^01]", "", binary_repr), 2) on line 14 has two parts:

  1. It removes anything besides 0s and 1s from the input string.
  2. It parses the result, assuming base 2, with int(<string>, 2).

Using .binary_repr() and .from_binary_repr() allows you to convert to and construct from a str of 1s and 0s in binary notation:

Python
>>> MyIPv4("220.14.9.37").binary_repr
'11011100.00001110.00001001.00100101'
>>> MyIPv4("255.255.0.0").binary_repr  # A /16 netmask
'11111111.11111111.00000000.00000000'

>>> MyIPv4.from_binary_repr("11011100 00001110 00001001 00100101")
MyIPv4('220.14.9.37')

These are just a few ways demonstrating how taking advantage of the IP-as-integer pattern can help you extend the functionality of IPv4Address with a small amount of additional code.

Conclusion

In this tutorial, you saw how Python’s ipaddress module can allow you to work with IP addresses and networks using common Python constructs.

Here are some important points that you can take away:

  • An IP address is fundamentally an integer, and this underlies both how you can do manual arithmetic with addresses and how the Python classes from ipaddress are designed using composition.
  • The ipaddress module takes advantage of operator overloading to allow you to infer relations between addresses and networks.
  • The ipaddress module uses composition, and you can extend that functionality as needed for added behavior.

As always, if you’d like to dive deeper, then reading the module source is a great way to do that.

Further Reading

Here are some in-depth resources that you can check out to learn more about the ipaddress module:

🐍 Python Tricks 💌

Get a short & sweet Python Trick delivered to your inbox every couple of days. No spam ever. Unsubscribe any time. Curated by the Real Python team.

Python Tricks Dictionary Merge

About Brad Solomon

Brad Solomon Brad Solomon

Brad is a software engineer and a member of the Real Python Tutorial Team.

» More about Brad

Each tutorial at Real Python is created by a team of developers so that it meets our high quality standards. The team members who worked on this tutorial are:

Master Real-World Python Skills With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

Master Real-World Python Skills
With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

What Do You Think?

Rate this article:

What’s your #1 takeaway or favorite thing you learned? How are you going to put your newfound skills to use? Leave a comment below and let us know.

Commenting Tips: The most useful comments are those written with the goal of learning from or helping out other students. Get tips for asking good questions and get answers to common questions in our support portal.


Looking for a real-time conversation? Visit the Real Python Community Chat or join the next “Office Hours” Live Q&A Session. Happy Pythoning!

Keep Learning

Related Tutorial Categories: intermediate