Every meaningful network script revolves around a small data structure: a list of devices, a dict of interface counters, a set of allowed VLANs. Today covers the four built‑in containers Python ships with — list, tuple, dict, set — and the right one to reach for in each network situation. The result is a working device inventory in pure Python, ready to loop over starting tomorrow.
This is Day 4 of the 21‑post Python for Network Engineers series.
The four containers, in one table
| Type | Looks like | Ordered? | Mutable? | Duplicates? | Use it for |
|---|---|---|---|---|---|
list |
[1, 2, 3] |
Yes | Yes | Yes | A sequence to add to / iterate over |
tuple |
(1, 2, 3) |
Yes | No | Yes | A fixed record (e.g. an IP/port pair) |
dict |
{"k": "v"} |
Yes (3.7+) | Yes | Keys unique | Lookup by name (device by hostname) |
set |
{1, 2, 3} |
No | Yes | No | Membership tests, deduping, set algebra |
Ten seconds spent memorizing this table saves many wrong choices later.
Lists — the workhorse
vlans = [10, 20, 30, 99]
vlans.append(100) # add to end
vlans.insert(0, 1) # insert at index 0
vlans.remove(99) # remove first occurrence by value
last = vlans.pop() # remove and return last
print(len(vlans), vlans)
# 5 [1, 10, 20, 30]
Lists are indexable and sliceable just like strings: vlans[0], vlans[-1], vlans[1:3]. They can hold mixed types, but in practice elements should stay homogeneous — a list of ints, a list of dicts, a list of devices. A mixed‑type list is usually a sign the data wants to be a dict instead.
The list comprehension is the idiom that separates beginner Python from intermediate Python. Two equivalent ways to build “interfaces with VLAN > 100”:
# Verbose:
high_vlans = []
for v in vlans:
if v > 100:
high_vlans.append(v)
# Idiomatic:
high_vlans = [v for v in vlans if v > 100]
Once the second form reads fluently, half the Python in the wild becomes legible. The shape is always [expression for item in iterable if condition], where the if is optional.
Tuples — the immutable record
target = ("10.0.0.1", 22) # IP and port — order matters, never changes
ip, port = target # unpack into two variables
print(ip, port)
# 10.0.0.1 22
Tuples have one job: bundle a fixed number of related values that won’t change. The classic network use is an (ip, port) pair for sockets. Functions that return “two related things” — like a Netmiko prompt and the rest of the output — return a tuple, which then gets unpacked.
Because tuples are immutable, they’re hashable and can serve as dict keys or set members. Lists can’t. That property comes in handy for (vrf, prefix) → next‑hop lookups later in the series.
Dicts — the heart of every Netmiko/NAPALM call
If only one container is worth learning deeply, it’s the dict. Every modern network library uses dicts to represent devices, configurations, and parsed output.
device = {
"device_type": "cisco_ios",
"host": "10.0.0.1",
"username": "admin",
"password": "C1sco12345",
"secret": "enable_pw",
}
print(device["host"]) # 10.0.0.1 — KeyError if missing
print(device.get("port", 22)) # 22 — never raises, returns default
device["port"] = 2222 # add or update a key
del device["secret"] # remove
print("host" in device) # True — membership is by key, not value
print(list(device.keys()))
print(list(device.values()))
print(list(device.items())) # list of (key, value) tuples
Cisco context: a call to ConnectHandler(**device) in Netmiko uses ** to spread the dict into keyword arguments. So the dict above becomes ConnectHandler(device_type="cisco_ios", host="10.0.0.1", ...) — the keys must match the parameter names exactly. This is also why YAML inventories (Day 15) deserialize cleanly into Python dicts and feed straight into the connection libraries.
Iterating dicts the right way
counters = {"Gi0/0": 12345, "Gi0/1": 67890, "Gi0/2": 0}
for name, pkts in counters.items():
print(f"{name}: {pkts:,} packets")
# Gi0/0: 12,345 packets
# Gi0/1: 67,890 packets
# Gi0/2: 0 packets
dict.items() yields the (key, value) pairs in one go. Avoid the beginner pattern of looping over keys and indexing the dict on each iteration (for k in d: d[k]) — it’s two lookups instead of one and reads worse.
Nested dicts — the natural shape of an inventory
inventory = {
"core-sw01": {
"host": "10.0.0.1",
"device_type": "cisco_ios",
"site": "hq",
},
"edge-rtr02": {
"host": "10.0.0.2",
"device_type": "cisco_ios",
"site": "hq",
},
"branch-sw01": {
"host": "10.1.0.1",
"device_type": "cisco_nxos",
"site": "branch-a",
},
}
print(inventory["core-sw01"]["host"]) # 10.0.0.1
This shape — outer dict keyed by hostname, inner dict of attributes — is exactly what Nornir, Ansible, and most home‑grown inventories use. Today it’s hardcoded; on Day 15 it loads from a YAML file with one line.
Sets — for “is this in the allowed VLAN list?”
allowed_vlans = {10, 20, 30, 100, 200}
trunked_vlans = {10, 20, 50, 200}
print(50 in allowed_vlans) # False
print(allowed_vlans & trunked_vlans) # intersection: {10, 20, 200}
print(trunked_vlans - allowed_vlans) # difference: {50} — VLANs trunked but not allowed
print(allowed_vlans | trunked_vlans) # union
Sets dedupe automatically (set([1, 1, 2, 3]) == {1, 2, 3}) and give O(1) membership tests — checking x in some_set doesn’t slow down with size, while x in some_list does. For “list of allowed VLANs” or “set of devices already polled,” a set is the first choice.
The & | - operators map to set algebra and are the cleanest way to answer questions like “which VLANs are trunked but not allowed” — that one line of code is a complete config‑drift check.
Putting it together: filter an inventory by site
inventory = {
"core-sw01": {"host": "10.0.0.1", "site": "hq"},
"edge-rtr02": {"host": "10.0.0.2", "site": "hq"},
"branch-sw01": {"host": "10.1.0.1", "site": "branch-a"},
"branch-rt01": {"host": "10.1.0.2", "site": "branch-a"},
}
hq_hosts = [d["host"] for d in inventory.values() if d["site"] == "hq"]
print(hq_hosts)
# ['10.0.0.1', '10.0.0.2']
One list comprehension, no helper function, no temporary variables. This is the shape of nearly every “do X to a subset of devices” loop in this series.
Exercises
- Create a list of five interface names. Print just the third one (use indexing) and the last two (use a slice).
- Given
vlans = [10, 20, 20, 30, 30, 30, 40], use a set to print just the unique VLAN IDs (no duplicates). Compare the length of the original list and the set. - Build a dict for a single device with at least four keys (
host,device_type,username,password). Then print just itshostsafely — so that a missing key returns the string"unknown"instead of raising. - Given the
inventorydict from the worked example above, write one comprehension that returns a list of hostnames whosedevice_typeis"cisco_ios". Hint: iterate overinventory.items(). - Stretch: given two sets —
configured = {"Gi0/0", "Gi0/1", "Gi0/2"}andoperational = {"Gi0/0", "Gi0/2", "Gi0/3"}— print three lines: which interfaces are both configured and operational, which are configured but not operational (probably down), and which are operational but not configured (a port added without notice).
Answers
Show answer 1
intfs = ["Gi0/0", "Gi0/1", "Gi0/2", "Gi0/3", "Gi0/4"]
print(intfs[2]) # Gi0/2
print(intfs[-2:]) # ['Gi0/3', 'Gi0/4']
Show answer 2
vlans = [10, 20, 20, 30, 30, 30, 40]
unique = set(vlans)
print(unique) # {10, 20, 30, 40} (order not guaranteed)
print(len(vlans), len(unique)) # 7 4
Wrapping a list in set() is the canonical Python way to dedupe. To preserve order, list(dict.fromkeys(vlans)) works in 3.7+.
Show answer 3
device = {
"host": "10.0.0.1",
"device_type": "cisco_ios",
"username": "admin",
"password": "C1sco12345",
}
print(device.get("host", "unknown")) # 10.0.0.1
print(device.get("vrf", "unknown")) # unknown
dict.get(key, default) returns the default instead of raising KeyError — the right choice whenever a key might legitimately be absent.
Show answer 4
ios_devices = [name for name, attrs in inventory.items()
if attrs["device_type"] == "cisco_ios"]
print(ios_devices)
# ['core-sw01', 'edge-rtr02', 'branch-rt01'] (depending on inventory contents)
Unpacking the (key, value) pair right in the for clause keeps the comprehension readable. To also guard against a missing key, use attrs.get("device_type") == "cisco_ios".
Show answer 5
configured = {"Gi0/0", "Gi0/1", "Gi0/2"}
operational = {"Gi0/0", "Gi0/2", "Gi0/3"}
print("OK (both): ", configured & operational) # {'Gi0/0', 'Gi0/2'}
print("Down (cfg only): ", configured - operational) # {'Gi0/1'}
print("Rogue (op only): ", operational - configured) # {'Gi0/3'}
Three lines of set algebra produce a complete configuration‑vs‑reality drift report. This idea — compare intent with what’s actually running — is the core of every config compliance tool.
Coming tomorrow
Day 5: Control Flow — Looping Through Interfaces and Devices. Next up: wiring for, while, if/elif/else, break, and continue against the inventory built today. By the end of tomorrow, a script walks every device in the inventory and prints a per‑site summary.