subprocess: Wrapping ping, traceroute, and nslookup in Python

Some tools aren’t worth reimplementing. ping, traceroute, nslookup, dig — they are battle-tested, they live on every box, and their output is already trusted. Python’s subprocess module runs them from inside a script, captures what they print, checks whether they succeeded, and reacts. That is the bridge between shell muscle memory and real automation.

This is also the most dangerous module so far, because running external commands carelessly is how scripts get command-injection bugs. The examples here do it the safe way from the start.

subprocess.run: The One Function to Learn

Forget the older os.system and os.popen. Modern Python has one front door: subprocess.run().

import subprocess

result = subprocess.run(
    ["ping", "-c", "3", "8.8.8.8"],   # command as a LIST, not a string
    capture_output=True,               # grab stdout and stderr
    text=True,                         # decode bytes to str for us
    timeout=10,                        # never hang forever
)

print("Return code:", result.returncode)   # 0 means success
print("STDOUT:\n", result.stdout)
print("STDERR:\n", result.stderr)

Four habits to lock in, visible in that one call:

  • Command as a list. ["ping", "-c", "3", target] — never one big string. This is the single most important safety rule (next section).
  • capture_output=True so the text is captured instead of scrolling past.
  • text=True so stdout is a str, not raw bytes.
  • timeout so a black-holed host cannot freeze the run forever.

The returncode Is the Pass/Fail Signal

Unix convention: 0 means success, anything else means failure. ping follows it — a reachable host gives 0, unreachable gives non-zero. That makes a reachability check trivial:

import subprocess

def is_reachable(host, count=2, timeout=5):
    result = subprocess.run(
        ["ping", "-c", str(count), "-W", "1", host],
        capture_output=True, text=True, timeout=timeout,
    )
    return result.returncode == 0

for host in ["8.8.8.8", "192.0.2.1"]:   # second is TEST-NET, won't answer
    status = "UP" if is_reachable(host) else "DOWN"
    print(f"{host:15} {status}")

Note str(count) — every element of the command list must be a string. Passing an int raises a TypeError.

Why the List Form Matters: Shell Injection

Examples online often use shell=True with a single string. Here is why that is a trap:

# DANGEROUS — do not do this with untrusted input
host = "8.8.8.8; rm -rf ~"          # imagine this came from a CSV
subprocess.run(f"ping -c 1 {host}", shell=True)   # runs the rm too!

# SAFE — the list form treats host as a single argument, always
subprocess.run(["ping", "-c", "1", host])         # ping just fails, nothing deleted

With the list form there is no shell to interpret ;, |, or $(), so a malicious or malformed value can only ever be a (bad) argument to ping. Default to the list form. Reserve shell=True for cases that genuinely need shell features and where the input is fully controlled.

check=True and Catching Failures

To have a failed command raise an exception instead (like everything else in Python does on error), pass check=True and catch CalledProcessError:

import subprocess

try:
    subprocess.run(["traceroute", "-m", "5", "192.0.2.1"],
                   capture_output=True, text=True,
                   timeout=15, check=True)
except subprocess.CalledProcessError as e:
    print(f"Command failed (rc={e.returncode})")
except subprocess.TimeoutExpired:
    print("Command timed out")

This pairs perfectly with the exception handling from Week 1. TimeoutExpired and CalledProcessError are the two caught most often.

Cisco Context: A Mini Reachability Sweep with DNS

Combine ping and nslookup to sweep a device list, resolve names, and flag anything down — the kind of pre-change sanity check to run before a maintenance window.

import subprocess

def resolve(name):
    try:
        out = subprocess.run(["nslookup", name], capture_output=True,
                             text=True, timeout=5).stdout
        for line in out.splitlines():
            if line.startswith("Address:") and not line.endswith("#53"):
                return line.split()[-1]
    except subprocess.TimeoutExpired:
        return None
    return None

def reachable(host):
    return subprocess.run(["ping", "-c", "2", "-W", "1", host],
                          capture_output=True, text=True).returncode == 0

devices = ["dns.google", "one.one.one.one"]
for d in devices:
    ip = resolve(d)
    state = "UP" if (ip and reachable(ip)) else "DOWN/UNRESOLVED"
    print(f"{d:20} {str(ip):16} {state}")

A Note on Live vs. Captured Output

Sometimes output should stream to the terminal in real time (a long traceroute) rather than be captured. Omitting capture_output=True lets the child process inherit the terminal, so output appears as it happens. That gives up programmatic access to the text, so the choice depends on whether the script needs to read the output or just show it.

Exercises

  1. Warm-up. Run ping -c 1 against 127.0.0.1 and print only the return code.
  2. Reachability. Write ping_count(host) that returns how many of 4 pings succeeded by parsing the summary line (hint: look for "received" in the output).
  3. Safe wrapper. Write run_cmd(args_list) that runs any command safely with a 10-second timeout and returns a tuple (ok, stdout, stderr) where ok is a bool. Make sure a timeout returns (False, "", "timeout") instead of crashing.
  4. Sweep. Given a list of 5 IPs, ping each and print a two-column report of address and UP/DOWN, then print a final count of how many were up.
  5. Challenge. Run traceroute to a host and extract just the ordered list of hop IP addresses (skip the * * * timeouts). Reuse the regex skills from yesterday.

Answers

Show answers

1. Warm-up

import subprocess
r = subprocess.run(["ping", "-c", "1", "127.0.0.1"],
                   capture_output=True, text=True)
print(r.returncode)   # 0

2. Reachability count

import re, subprocess
def ping_count(host):
    out = subprocess.run(["ping", "-c", "4", "-W", "1", host],
                         capture_output=True, text=True).stdout
    m = re.search(r"(\d+) received", out)
    return int(m.group(1)) if m else 0

print(ping_count("127.0.0.1"))   # 4

3. Safe wrapper

import subprocess
def run_cmd(args_list):
    try:
        r = subprocess.run(args_list, capture_output=True,
                           text=True, timeout=10)
        return (r.returncode == 0, r.stdout, r.stderr)
    except subprocess.TimeoutExpired:
        return (False, "", "timeout")
    except FileNotFoundError:
        return (False, "", "command not found")

print(run_cmd(["echo", "hello"]))   # (True, 'hello\n', '')

Catching FileNotFoundError too is a nice touch — it fires when the binary is not installed.

This is Day 10 of the 21‑post Python for Network Engineers series.

4. Sweep

import subprocess
ips = ["127.0.0.1", "8.8.8.8", "192.0.2.1", "192.0.2.2", "1.1.1.1"]
up = 0
for ip in ips:
    ok = subprocess.run(["ping", "-c", "1", "-W", "1", ip],
                        capture_output=True).returncode == 0
    print(f"{ip:15} {'UP' if ok else 'DOWN'}")
    up += ok
print(f"{up}/{len(ips)} up")

up += ok works because True is 1 and False is 0 in Python — a clean way to count booleans.

5. Challenge

import re, subprocess
out = subprocess.run(["traceroute", "-m", "10", "8.8.8.8"],
                     capture_output=True, text=True, timeout=30).stdout
hops = []
for line in out.splitlines()[1:]:          # skip header line
    m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)", line)
    if m:
        hops.append(m.group(1))
print(hops)

traceroute prints each responding hop’s IP in parentheses; the regex grabs exactly those and naturally skips * * * lines that have no address.


Previously: Regex with re. Coming tomorrow — Socket programming: building a tiny TCP port reachability checker to replace reaching for telnet host 443.