Python for Network Engineers — Part 3: Dictionaries, Sets, Comprehensions, and Exceptions
At the end of Part 2, the interface categoriser script built a dictionary manually — creating an empty dict, checking for a key, creating a list if missing, then appending. It worked, but it was four lines for something Python can express in one. This post covers that properly, and then some.
Dictionaries are the data structure you’ll use most in network automation. API responses come back as dicts. Device state — interfaces, neighbours, routes — is naturally represented as a dict. NAPALM and Nornir return dicts. Once you understand dictionaries well, you’ll recognise that half the work of network automation is just transforming one dict into another.
Dictionaries
A dictionary maps keys to values. Create one with curly braces:
device = {
"hostname": "core-sw-01",
"ip": "192.168.1.1",
"platform": "cisco_ios",
"reachable": True,
}
Keys are usually strings. Values can be anything — strings, numbers, booleans, lists, or even other dicts.
Accessing Values
Use the key in square brackets:
>>> device["hostname"]
'core-sw-01'
>>> device["reachable"]
True
If the key doesn’t exist, you get a KeyError:
>>> device["location"]
KeyError: 'location'
To avoid that, use .get() — it returns None by default, or a value you specify:
>>> device.get("location")
None
>>> device.get("location", "Unknown")
'Unknown'
In network automation, .get() is almost always the right choice when a key might be absent. Device APIs don’t always include every field for every device.
Adding, Updating, and Deleting
device["location"] = "DC1-Row3-Rack12" # add a new key
device["reachable"] = False # update an existing key
del device["location"] # delete a key
Check if a key exists:
>>> "hostname" in device
True
>>> "serial" in device
False
Iterating Over a Dictionary
.keys(), .values(), and .items() give you views of a dict’s contents:
# Just keys
for key in device.keys():
print(key)
# Just values
for value in device.values():
print(value)
# Both — the most useful form
for key, value in device.items():
print(f"{key}: {value}")
Iterating with .items() and unpacking into two variables is the pattern you’ll use most:
>>> for key, value in device.items():
... print(f" {key:<12} → {value}")
hostname → core-sw-01
ip → 192.168.1.1
platform → cisco_ios
reachable → False
Nested Dictionaries
Real device state is nested. A device has multiple interfaces; each interface has multiple attributes. A nested dict captures that naturally:
device = {
"hostname": "core-sw-01",
"interfaces": {
"GigabitEthernet0/0/1": {
"ip": "10.0.0.1",
"mask": "255.255.255.252",
"status": "up",
"description": "Link to edge-rtr-01",
},
"GigabitEthernet0/0/2": {
"ip": "10.0.0.5",
"mask": "255.255.255.252",
"status": "down",
"description": "Link to edge-rtr-02",
},
},
}
Access nested values by chaining keys:
>>> device["interfaces"]["GigabitEthernet0/0/1"]["status"]
'up'
>>> device["interfaces"]["GigabitEthernet0/0/2"]["description"]
'Link to edge-rtr-02'
This structure — a dict of dicts — is how NAPALM returns get_interfaces() output and how most vendor REST APIs represent interface state. Getting comfortable with it now pays off when you reach those tools.
Useful Dictionary Methods
# Merge two dicts (Python 3.9+)
defaults = {"timeout": 30, "port": 22, "verbose": False}
overrides = {"timeout": 60, "verbose": True}
config = defaults | overrides
# {"timeout": 60, "port": 22, "verbose": True}
# Get with a fallback and set in one operation
device.setdefault("tags", []) # sets "tags" to [] only if not already present
device["tags"].append("core")
# All keys as a list
list(device.keys())
# Number of key-value pairs
len(device)
defaultdict
Python’s collections module provides defaultdict — a dict that automatically creates a default value when you access a key that doesn’t exist. This eliminates the common pattern of “check if key exists, create if not, then append”:
# Without defaultdict — verbose
grouped = {}
for name in interfaces:
category = categorise_interface(name)
if category not in grouped:
grouped[category] = []
grouped[category].append(name)
# With defaultdict — clean
from collections import defaultdict
grouped = defaultdict(list)
for name in interfaces:
category = categorise_interface(name)
grouped[category].append(name) # no key check needed
The argument to defaultdict is a callable that produces the default value: list for an empty list, int for zero, set for an empty set.
This is the clean version of the interface categoriser from Part 2. Same result, four fewer lines.
Counter
collections.Counter counts occurrences of items in an iterable. It’s a dict subclass that maps items to their count:
from collections import Counter
# Count how often each prefix length appears in a route table
prefix_lengths = [24, 24, 32, 16, 24, 32, 28, 24]
counts = Counter(prefix_lengths)
# Counter({24: 4, 32: 2, 28: 1, 16: 1})
# Most common prefix lengths
counts.most_common(3)
# [(24, 4), (32, 2), (28, 1)]
A more network-relevant example: counting how many times each syslog severity appears in a log file:
from collections import Counter
with open("syslog.txt") as f:
lines = [line.strip() for line in f if line.strip()]
# Assume each line starts with severity: "ERROR: ...", "WARNING: ...", etc.
severities = [line.split(":")[0] for line in lines]
counts = Counter(severities)
for severity, count in counts.most_common():
print(f"{severity:<10} {count:>5}")
Sets
A set is an unordered collection of unique values. Duplicates are discarded automatically:
>>> vlans = {10, 20, 30, 10, 20}
>>> vlans
{10, 20, 30}
Create a set from a list (a common pattern when you want to deduplicate):
>>> all_vlans = [10, 20, 30, 10, 50, 20]
>>> unique_vlans = set(all_vlans)
{10, 20, 30, 50}
An empty set must use set(), not {} — the latter creates an empty dict:
empty_set = set()
empty_dict = {} # NOT an empty set
Set Operations
This is where sets earn their keep in network automation. Three operations you’ll use constantly:
switch_a_vlans = {10, 20, 30, 40, 100}
switch_b_vlans = {10, 20, 30, 50, 100}
# VLANs on A but not B — what does A have that B is missing?
switch_a_vlans - switch_b_vlans
# {40}
# VLANs on B but not A
switch_b_vlans - switch_a_vlans
# {50}
# VLANs on both — the intersection
switch_a_vlans & switch_b_vlans
# {10, 20, 30, 100}
# All VLANs across both — the union
switch_a_vlans | switch_b_vlans
# {10, 20, 30, 40, 50, 100}
# VLANs on one but not both — the symmetric difference
switch_a_vlans ^ switch_b_vlans
# {40, 50}
VLAN drift between switches, routes missing from a peer, ACL rules that don’t match between redundant firewalls — all of these reduce to set operations.
Membership Testing
Sets test membership in O(1) time, making them faster than lists for large lookups:
allowed_vlans = {10, 20, 30, 100, 200}
>>> 30 in allowed_vlans
True
>>> 999 in allowed_vlans
False
List Comprehensions
A list comprehension is a compact way to build a list by transforming or filtering another iterable. Here’s the pattern:
[expression for item in iterable]
[expression for item in iterable if condition]
The best way to understand it is to see what it replaces:
# Building a list with a loop:
hostnames = []
for device in devices:
hostnames.append(device["hostname"])
# The same thing as a list comprehension:
hostnames = [device["hostname"] for device in devices]
With a filter:
# Loop version — only reachable devices:
reachable = []
for device in devices:
if device["reachable"]:
reachable.append(device["hostname"])
# Comprehension version:
reachable = [device["hostname"] for device in devices if device["reachable"]]
More network examples:
# Extract all /32 routes from a list of prefix strings
routes = ["10.0.0.1/32", "10.0.0.0/24", "192.168.1.1/32", "172.16.0.0/16"]
host_routes = [r for r in routes if r.endswith("/32")]
# ['10.0.0.1/32', '192.168.1.1/32']
# Normalise interface names to lowercase
interfaces = ["GigabitEthernet0/0/1", "VLAN10", "Loopback0"]
normalised = [i.lower() for i in interfaces]
# ['gigabitethernet0/0/1', 'vlan10', 'loopback0']
# Extract the VLAN ID numbers from a list of interface names
vlan_interfaces = ["Vlan10", "Vlan20", "Vlan100"]
vlan_ids = [int(name.replace("Vlan", "")) for name in vlan_interfaces]
# [10, 20, 100]
Keep comprehensions readable. If you need more than one if clause or the expression is complex, a regular loop is clearer.
Dictionary Comprehensions
The same idea, but produces a dict:
{key_expression: value_expression for item in iterable}
# Build a hostname → IP mapping from a list of device dicts
devices = [
{"hostname": "core-sw-01", "ip": "192.168.1.1"},
{"hostname": "core-sw-02", "ip": "192.168.1.2"},
{"hostname": "edge-rtr-01", "ip": "10.0.0.1"},
]
ip_map = {d["hostname"]: d["ip"] for d in devices}
# {'core-sw-01': '192.168.1.1', 'core-sw-02': '192.168.1.2', 'edge-rtr-01': '10.0.0.1'}
# Quick lookup by hostname
>>> ip_map["core-sw-01"]
'192.168.1.1'
With a filter:
# Only devices that are reachable
reachable_ips = {
d["hostname"]: d["ip"]
for d in devices
if d.get("reachable", True)
}
Dict comprehensions are common when you have a list of records and need fast key-based lookups. Building the mapping once upfront is much faster than scanning the list every time.
Exception Handling
Exceptions are how Python signals that something went wrong. A script that connects to dozens of devices and crashes on the first unreachable one is not useful. Proper exception handling is what separates a script from automation you can actually trust.
The Basics
try:
result = int("not-a-number")
except ValueError:
print("That's not an integer")
If the code inside try raises a ValueError, execution jumps to the except block. Any other exception type is not caught and will still crash the script.
Catch the exception object to inspect it:
try:
result = int(user_input)
except ValueError as e:
print(f"Conversion failed: {e}")
Catching Multiple Exception Types
try:
with open(filename) as f:
data = f.read()
result = int(data.strip())
except FileNotFoundError:
print(f"File not found: {filename}")
except ValueError:
print(f"File contents are not a valid integer")
Or catch several in one except:
except (FileNotFoundError, PermissionError) as e:
print(f"Could not read file: {e}")
else and finally
try:
connection = open_ssh_connection(device_ip)
except ConnectionError as e:
print(f"Could not connect: {e}")
else:
# Runs only if no exception was raised
output = connection.send_command("show version")
connection.disconnect()
finally:
# Runs always — with or without an exception
print(f"Finished processing {device_ip}")
finally is useful for cleanup — closing connections, writing logs — that should happen regardless of success or failure.
Don’t Catch Everything
This is a common mistake:
# BAD — catches every possible exception, including ones you didn't expect
try:
connect_and_run(device)
except Exception:
print("Something went wrong")
A bare except Exception hides bugs. If there’s a typo in your code, a missing import, or a logic error, it’ll be swallowed silently. Catch the specific exceptions you know about and let unexpected ones surface.
Exception Chaining
When you catch one exception and raise a more informative one, Python lets you attach the original as the cause. This preserves the full context in tracebacks:
def parse_device_line(line, line_number):
try:
hostname, ip_str = line.strip().split(",")
ip = ipaddress.ip_address(ip_str.strip())
return hostname.strip(), ip
except ValueError as e:
raise ValueError(
f"Invalid device entry on line {line_number}: {line.strip()!r}"
) from e
The from e attaches the original ValueError to your new one. When the script crashes, the traceback shows both — what went wrong in your code and what Python’s internal function reported. Without from e, that context is lost.
Common Exceptions in Network Automation
| Exception | When you’ll see it |
|---|---|
ValueError | Bad data — int("abc"), invalid IP address |
KeyError | Accessing a dict key that doesn’t exist |
FileNotFoundError | Opening a file that isn’t there |
TypeError | Wrong type — adding a string to an integer |
IndexError | Accessing a list position beyond its length |
AttributeError | Calling a method on None or the wrong type |
ConnectionError | SSH or socket connection failure (from Netmiko and others) |
TimeoutError | Device didn’t respond in time |
Once you’re connecting to real devices (Part 5), ConnectionError and TimeoutError become important. A good automation script catches them per-device, logs the failure, and continues rather than halting the entire run.
Putting It Together: VLAN Drift Checker
Here’s a script that uses everything from this post. It reads VLAN databases from two switches, identifies the drift between them, and reports clearly.
Create switch-a-vlans.txt:
10,Management
20,Servers
30,Voice
40,IoT
100,Native
Create switch-b-vlans.txt:
10,Management
20,Servers
30,Voice
50,Guest
100,Native
999,notanumber
The last line of switch B is deliberately malformed. A good script handles it.
Create vlan-drift.py:
import sys
from collections import defaultdict
VLAN_FILES = {
"switch-a": "switch-a-vlans.txt",
"switch-b": "switch-b-vlans.txt",
}
def load_vlans(filepath):
"""Read a 'vlan_id,name' file. Returns {vlan_id: name} dict.
Skips blank lines and comments. Reports bad lines but does not abort.
"""
vlans = {}
errors = []
with open(filepath, encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
line = line.strip()
if not line or line.startswith("#"):
continue
try:
parts = line.split(",", maxsplit=1)
if len(parts) != 2:
raise ValueError("expected 'vlan_id,name' format")
vlan_id = int(parts[0].strip())
name = parts[1].strip()
if not (1 <= vlan_id <= 4094):
raise ValueError(f"VLAN ID {vlan_id} is out of range (1–4094)")
vlans[vlan_id] = name
except ValueError as e:
errors.append((line_number, line, str(e)))
return vlans, errors
def compare_vlans(a_vlans, b_vlans, a_name, b_name):
"""Return a dict describing the drift between two VLAN sets."""
a_ids = set(a_vlans.keys())
b_ids = set(b_vlans.keys())
return {
"only_on_a": {vid: a_vlans[vid] for vid in sorted(a_ids - b_ids)},
"only_on_b": {vid: b_vlans[vid] for vid in sorted(b_ids - a_ids)},
"name_mismatch": {
vid: (a_vlans[vid], b_vlans[vid])
for vid in sorted(a_ids & b_ids)
if a_vlans[vid] != b_vlans[vid]
},
"in_sync": sorted(
vid for vid in a_ids & b_ids
if a_vlans[vid] == b_vlans[vid]
),
}
def main():
all_vlans = {}
all_errors = defaultdict(list)
for switch_name, filepath in VLAN_FILES.items():
try:
vlans, errors = load_vlans(filepath)
except FileNotFoundError:
print(f"ERROR: Could not find {filepath} — skipping {switch_name}")
sys.exit(1)
all_vlans[switch_name] = vlans
all_errors[switch_name] = errors
# Report parse errors first
for switch_name, errors in all_errors.items():
if errors:
print(f"\nParse warnings for {switch_name}:")
for line_number, line, message in errors:
print(f" Line {line_number}: {message!r} — {line!r}")
# Compare
a_name, b_name = list(VLAN_FILES.keys())
drift = compare_vlans(all_vlans[a_name], all_vlans[b_name], a_name, b_name)
print(f"\n{'─' * 50}")
print(f"VLAN drift: {a_name} vs {b_name}")
print(f"{'─' * 50}")
if drift["only_on_a"]:
print(f"\nOnly on {a_name}:")
for vid, name in drift["only_on_a"].items():
print(f" VLAN {vid:<5} {name}")
if drift["only_on_b"]:
print(f"\nOnly on {b_name}:")
for vid, name in drift["only_on_b"].items():
print(f" VLAN {vid:<5} {name}")
if drift["name_mismatch"]:
print(f"\nName mismatch (same ID, different name):")
for vid, (a_name_val, b_name_val) in drift["name_mismatch"].items():
print(f" VLAN {vid:<5} {a_name}: {a_name_val!r} | {b_name}: {b_name_val!r}")
print(f"\nIn sync: {len(drift['in_sync'])} VLAN(s) — {drift['in_sync']}")
if __name__ == "__main__":
main()
Run it:
python3 vlan-drift.py
Expected output:
Parse warnings for switch-b:
Line 6: 'VLAN ID 999 is out of range (1–4094)' — '999,notanumber'
──────────────────────────────────────────────────
VLAN drift: switch-a vs switch-b
──────────────────────────────────────────────────
Only on switch-a:
VLAN 40 IoT
Only on switch-b:
VLAN 50 Guest
In sync: 4 VLAN(s) — [10, 20, 30, 100]
Everything in this script came from this post or Part 2: dicts, sets, defaultdict, dict comprehensions, list comprehensions, exception handling with from e chaining, and sys.exit to abort cleanly when a required file is missing.
What’s Coming in Part 4
Part 4 covers functions, regular expressions, and modules. We’ll write reusable functions for the scripts we’ve already built, start parsing real-looking CLI output with re, and structure the code into a small multi-file project. Regex is where a lot of network engineers get stuck — we’ll take it slowly with examples drawn from show command output you’ll recognise.
No Containerlab topology is needed for this post — all examples run without network devices.