Lists, Tuples, Dicts, Sets — Modeling Devices and Inventories

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

  1. Create a list of five interface names. Print just the third one (use indexing) and the last two (use a slice).
  2. 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.
  3. Build a dict for a single device with at least four keys (host, device_type, username, password). Then print just its host safely — so that a missing key returns the string "unknown" instead of raising.
  4. Given the inventory dict from the worked example above, write one comprehension that returns a list of hostnames whose device_type is "cisco_ios". Hint: iterate over inventory.items().
  5. Stretch: given two sets — configured = {"Gi0/0", "Gi0/1", "Gi0/2"} and operational = {"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.