Network devices speak in tables meant for human eyes. show ip interface brief, show cdp neighbors, show ip bgp summary — every one is a little report with columns, headers, and just enough inconsistency to ruin a naive split(). The moment you try to do something with that output at scale, you need structure: a list of dictionaries you can filter, count, and hand to a template.
You can hand-roll regular expressions for this. I did, for years. TextFSM plus the ntc-templates library is the point where I stopped, because it turns “write and maintain a regex per command per platform” into “call a function.” This post walks from the raw scrape to clean JSON, then to a report — without writing a single regex by hand.
The problem with the obvious approach
Here’s the output we want to work with:
Interface IP-Address OK? Method Status Protocol
GigabitEthernet0/0 10.0.0.1 YES NVRAM up up
GigabitEthernet0/1 unassigned YES NVRAM administratively down down
GigabitEthernet0/2 10.0.5.1 YES manual up down
Loopback0 10.255.0.1 YES NVRAM up upThe tempting first move is string splitting:
for line in output.splitlines()[1:]:
fields = line.split()
name, ip, *_ = fieldsIt breaks immediately. “administratively down” contains a space, so the column count changes per row. “unassigned” isn’t an IP. The header line needs skipping. Every edge case you patch spawns another. This is exactly the kind of code that works on your laptop and pages you at 2am.
What TextFSM actually does
TextFSM is a template-driven parser: you describe the shape of the output once, in a template file, and TextFSM walks the text as a small state machine, emitting rows of named values. The regex lives in the template, written once, tested once, reused forever.
A template for the command above looks like this:
Value INTERFACE (\S+)
Value IP_ADDRESS (\S+)
Value STATUS (up|down|administratively down)
Value PROTOCOL (up|down)
Start
^Interface\s+IP-Address -> Records
^${INTERFACE}\s+${IP_ADDRESS}\s+\S+\s+\S+\s+${STATUS}\s+${PROTOCOL} -> RecordValue lines define columns and their capture patterns. The Start state matches lines and Record emits a row. You write this once per command. But here’s the better news: for hundreds of common commands across dozens of platforms, someone already did.
ntc-templates: the library you don’t have to maintain
ntc-templates is a community-maintained collection of TextFSM templates covering Cisco IOS/NX-OS/IOS-XR, Arista EOS, Juniper, Palo Alto, and many more. You install it and get a parser for show commands you actually run.
pip install ntc-templatesThe high-level entry point is parse_output, which picks the right template from the platform and command for you:
from ntc_templates.parse import parse_output
output = """
Interface IP-Address OK? Method Status Protocol
GigabitEthernet0/0 10.0.0.1 YES NVRAM up up
GigabitEthernet0/1 unassigned YES NVRAM administratively down down
GigabitEthernet0/2 10.0.5.1 YES manual up down
Loopback0 10.255.0.1 YES NVRAM up up
"""
parsed = parse_output(
platform="cisco_ios",
command="show ip interface brief",
data=output,
)
for row in parsed:
print(row)The result is exactly the structure you wanted in the first place:
{'interface': 'GigabitEthernet0/0', 'ipaddr': '10.0.0.1', 'status': 'up', 'proto': 'up'}
{'interface': 'GigabitEthernet0/1', 'ipaddr': 'unassigned', 'status': 'administratively down', 'proto': 'down'}
{'interface': 'GigabitEthernet0/2', 'ipaddr': '10.0.5.1', 'status': 'up', 'proto': 'down'}
{'interface': 'Loopback0', 'ipaddr': '10.255.0.1', 'status': 'up', 'proto': 'up'}No header line, “administratively down” captured intact, “unassigned” handled without special-casing. That’s the whole pitch.
Wiring it to a live device
You rarely paste output by hand. In practice you collect it with a connection library. Netmiko and TextFSM are built to work together — Netmiko will run the parse for you if you pass use_textfsm=True:
from netmiko import ConnectHandler
device = {
"device_type": "cisco_ios",
"host": "10.0.0.1",
"username": "netops",
"password": "not-in-git", # pull from a vault or env var
}
with ConnectHandler(**device) as conn:
interfaces = conn.send_command(
"show ip interface brief",
use_textfsm=True,
)
# interfaces is already a list of dicts
down = [i for i in interfaces if i["proto"] == "down"]
print(f"{len(down)} interfaces with protocol down")That single use_textfsm=True flag is the difference between getting a string and getting data. Now ordinary Python does the work:
# Interfaces that are administratively up but not passing traffic
suspect = [
i for i in interfaces
if i["status"] == "up" and i["proto"] == "down"
]From data to JSON to report
Once output is structured, the rest is composition. Collect from several devices, tag each row with its source, and you have a dataset:
import json
inventory = ["10.0.0.1", "10.0.0.2", "10.0.0.3"]
report = []
for host in inventory:
device["host"] = host
with ConnectHandler(**device) as conn:
rows = conn.send_command("show ip interface brief", use_textfsm=True)
for r in rows:
r["device"] = host
report.append(r)
# Persist the structured result
with open("interface_report.json", "w") as f:
json.dump(report, f, indent=2)The JSON is clean enough to hand to anything downstream — a dashboard, a diff against last week, a ticket:
[
{
"interface": "GigabitEthernet0/2",
"ipaddr": "10.0.5.1",
"status": "up",
"proto": "down",
"device": "10.0.0.1"
}
]And a human-readable summary is a few lines of ordinary iteration:
problem = [r for r in report if r["status"] == "up" and r["proto"] == "down"]
print(f"Interfaces up/down across {len(inventory)} devices: {len(problem)}\n")
for r in sorted(problem, key=lambda x: (x["device"], x["interface"])):
print(f" {r['device']:<12} {r['interface']:<22} {r['ipaddr']}")Interfaces up/down across 3 devices: 2
10.0.0.1 GigabitEthernet0/2 10.0.5.1
10.0.0.3 GigabitEthernet0/1 10.20.1.1That report took no regex, no column counting, and no per-platform special cases in your own code.
When there’s no template
Occasionally you’ll run a command ntc-templates doesn’t cover, or a vendor formats something oddly. You have two honest options. Write a template — it’s the same four-line pattern from earlier, dropped into a .textfsm file and pointed at with the textfsm module directly:
import textfsm
from io import StringIO
with open("my_command.textfsm") as tpl:
fsm = textfsm.TextFSM(tpl)
rows = fsm.ParseText(raw_output)
data = [dict(zip(fsm.header, row)) for row in rows]Or, if the output is genuinely structured on the box, skip parsing entirely. Increasingly, devices support | json (NX-OS), NETCONF, or gNMI — and structured-by-design always beats structured-after-the-fact. TextFSM is the right tool for the enormous installed base of gear that only speaks CLI. When the device can hand you JSON directly, take it.
Why this pattern holds up
The reason I reach for TextFSM every time now isn’t cleverness — it’s maintenance. Hand-written parsing spreads regex across your codebase, and each one is a small liability that breaks on the next IOS version. TextFSM concentrates that fragility into template files that a community stress-tests against real output from real boxes. Your code stays about intent — “find interfaces that are up but not forwarding” — instead of mechanics.
Get the scrape into a list of dicts as early as possible, and everything after that is just Python. That’s the whole game: stop parsing text in your business logic, and start working with data.