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
stdoutis astr, 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
- Warm-up. Run
ping -c 1against127.0.0.1and print only the return code. - 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). - Safe wrapper. Write
run_cmd(args_list)that runs any command safely with a 10-second timeout and returns a tuple(ok, stdout, stderr)whereokis a bool. Make sure a timeout returns(False, "", "timeout")instead of crashing. - 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.
- Challenge. Run
tracerouteto 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.