Python for system administration

Listen to this lesson

Episode 38 · 62:14

This episode is a study companion for CompTIA Linux+ XK0-006 and is not produced by or endorsed by CompTIA.

Objective 4.3 · Automation, Orchestration, and Scripting · 17% of the exam

Why this matters

Bash is superb at running commands in sequence. It is poor at anything else — arithmetic that is not integers, structured data, error handling, anything you will still want to read in a year.

The honest rule is a length one: when a shell script passes about a hundred lines, or the moment it starts parsing JSON, rewrite it in Python. Ansible is written in Python, so is dnf, so is most of the Linux automation ecosystem, and a great deal of what a Linux administrator does with Python is gluing existing tools together rather than writing programs from scratch.

The exam does not ask you to be a software engineer. It asks whether you know the data types, what a virtual environment is for, and why installing a package with sudo pip is a mistake.

The lesson

Current versions, and which Python you are running

Python 3 is the only Python. Python 2 reached end of life on 1 January 2020 and has had no security fixes since. On a modern system python may not exist at all, precisely so that a script written for Python 2 fails loudly rather than running under 3 and doing something subtly different.

python3 --version
which python3
ls -l /usr/bin/python*

The current versions you will meet on supported distributions run from 3.9 on RHEL 9 through 3.11 on RHEL 10 and Debian 12 to 3.12 and 3.13 on current Ubuntu and Fedora. The version matters more than it used to because the language has been adding syntax steadily: f-strings need 3.6, the walrus operator 3.8, structural pattern matching 3.10, and tomllib 3.11. A script that runs on your workstation and fails on a RHEL 9 server is usually this.

Always write python3 in a shebang, never python:

#!/usr/bin/env python3

RHEL adds a wrinkle worth knowing: /usr/libexec/platform-python is the interpreter the system's own tooling uses, deliberately kept separate. Do not write scripts against it and do not install packages into it. It exists so that whatever you do to "your" Python cannot break dnf.

Python fundamentals

The parts that differ enough from bash to catch you out.

Indentations are the syntax. Python has no fi, no esac, no braces — the indentation is the block structure:

for host in hosts:
    if ping(host):
        print(f"{host} is up")
    else:
        print(f"{host} is DOWN")
print("done")          # outside the loop, because it is not indented

Four spaces per level, by convention and near-universally. Never mix tabs and spaces — Python 3 rejects it outright with TabError, which is a kindness, because in Python 2 it produced code that looked correct and ran wrongly. Set your editor to expand tabs and the problem disappears permanently.

The corollary is that a badly indented Python file is not merely ugly, it is a different program. Moving one line's indentation by four spaces moves it out of a loop.

Variables need no declaration and no $:

count = 0
name = "web01"
hosts = ["web01", "web02"]
count += 1

Comments are #. Strings take single or double quotes interchangeably; triple quotes span lines and are how docstrings are written.

f-strings are the modern way to build strings, and much the most readable:

disk = 91
print(f"root is {disk}% full")
print(f"{disk / 100:.1%}")        # formatting inside the braces

Data types and structures

The exam names six, and the distinctions are exactly the ones that matter in practice.

Integer — whole numbers, and in Python of unlimited size. count = 42.

Floating point — numbers with a decimal part. load = 1.75. Carrying the same caveat as everywhere else: 0.1 + 0.2 is 0.30000000000000004, because binary floating point cannot represent 0.1 exactly. Never compare floats with ==, and never use one for money — decimal.Decimal exists for that.

BooleanTrue or False, capitalised, which is a common typo coming from other languages. Python also treats empty things as false — 0, "", [], {}, None are all falsy — so if not hosts: is the idiomatic way to ask whether a list is empty.

String — text, and immutable: every operation that "changes" a string returns a new one.

line = "  web01:running  "
line.strip()                 # remove surrounding whitespace
line.strip().split(":")      # ['web01', 'running']
"web01".upper()
"error" in log_line          # substring test
",".join(["a", "b", "c"])    # 'a,b,c'
f"{host}.example.com"

split() and join() between them replace an enormous amount of awk and cut.

List — an ordered, changeable sequence, written with square brackets:

hosts = ["web01", "web02", "db01"]
hosts.append("db02")
hosts[0]                     # 'web01' -- zero-indexed
hosts[-1]                    # 'db02'  -- negative counts from the end
hosts[1:3]                   # ['web02', 'db01'] -- a slice
len(hosts)
for h in hosts:
    print(h)
[h for h in hosts if h.startswith("web")]     # a list comprehension

Dictionary — key/value pairs, in braces, and the structure you will use most because it is what JSON becomes:

server = {"name": "web01", "cpus": 4, "roles": ["web", "cache"]}
server["cpus"]               # 4
server.get("ram", 0)         # 0 -- a default instead of a KeyError
server["ram"] = 8192
for key, value in server.items():
    print(f"{key}: {value}")

server["ram"] on a missing key raises KeyError and stops the script; server.get("ram") returns None. That single difference is behind a large share of crashes in scripts that read someone else's JSON.

Two more you will meet: a tuple is an immutable list in parentheses, (1, 2), used for things that should not change; a set in braces holds unique values and makes membership tests and deduplication fast.

The reason to care about the distinction generally: lists and dictionaries are mutable, strings and tuples are not. Passing a list to a function and having the function modify it changes the caller's list, which is a long-standing source of confusion for people arriving from shell scripting, where nothing behaves that way.

PEP 8

A Python Enhancement Proposal is the mechanism by which the language changes; each is a numbered document. PEP 8 is the style guide, and it is worth knowing by name because it is the reason Python code from different authors looks the same.

The rules you will actually be judged on:

  • Four spaces per indentation level, never tabs
  • Lines under 79 characters (many projects relax this to 88 or 100)
  • snake_case for variables and functions, CapWords for classes, UPPER_CASE for constants
  • Two blank lines between top-level functions, one between methods
  • Spaces around operators — x = 1, not x=1
  • Imports at the top, one per line, standard library first

Nobody memorises it. You run a tool:

python3 -m pip install --user ruff
ruff check script.py            # lint
ruff format script.py           # reformat in place

ruff has largely replaced the older flake8, pylint and black for this, being one tool and much faster. The point for an administrator is not aesthetics: consistent style is what makes a diff show the change you made rather than a reformatting storm.

Built-in modules

Python's built-in modules — its standard library — are why it suits system administration. Almost everything a script needs ships with the interpreter and needs no installation at all:

import os          # environment, process, path operations
import sys         # arguments, exit codes, stdin/stdout
import subprocess  # run external commands
import json        # parse and produce JSON
import re          # regular expressions
import shutil      # copy, move, disk usage
import logging     # proper logging instead of print
import argparse    # command-line argument parsing
import datetime    # dates and times
from pathlib import Path

The ones that earn their place immediately:

import os, sys, json, subprocess
from pathlib import Path

os.environ.get("HOME")                  # read the environment
sys.argv[1]                             # first argument
sys.exit(1)                             # exit status, as in the shell

p = Path("/etc/hosts")
p.exists(), p.is_file(), p.read_text()  # pathlib beats string paths
for cfg in Path("/etc/nginx").rglob("*.conf"):
    print(cfg)

data = json.loads(output)               # text  -> dict
print(json.dumps(data, indent=2))       # dict  -> text

subprocess is the bridge back to the shell, and the way you call it matters:

r = subprocess.run(
    ["systemctl", "is-active", "nginx"],
    capture_output=True, text=True,
)
if r.returncode == 0:
    print(r.stdout.strip())

Pass a list, not a string, and leave shell=True alone. With shell=True, a value that came from a filename or a web request is handed to a shell to interpret, and a hostname of db01; rm -rf / does what it says. The list form has no shell to inject into. This is the same lesson as quoting in bash, with sharper teeth.

argparse is worth the ten minutes it takes to learn — it gives you --help, type checking and required arguments for free, and it is the difference between a script only you can run and one you can hand over.

Extensibility: modules and packages

Python is extensible using modules and packages — that is the property that makes it the automation language. A module is one .py file; a package is a directory of them. Your own code becomes a module the moment another file imports it:

# utils.py
def human_bytes(n):
    for unit in ("B", "KiB", "MiB", "GiB"):
        if n < 1024:
            return f"{n:.1f}{unit}"
        n /= 1024
    return f"{n:.1f}TiB"
# report.py
from utils import human_bytes
print(human_bytes(1536000))

Third-party packages come from PyPI, the Python Package Index, and there are several hundred thousand of them. The ones an administrator reaches for: requests for HTTP, paramiko for SSH, PyYAML for YAML, psutil for process and system metrics, jinja2 for templating configuration files.

Guard the entry point of anything importable:

def main():
    ...

if __name__ == "__main__":
    main()

Without it, importing your script runs it — which is exactly the generators-write-on-import failure, and it is why every well-behaved Python file has that line.

Setting up a virtual environment

A virtual environment is a self-contained directory holding its own Python interpreter link and its own site-packages. Setting up a virtual environment is the first step of any Python work on a server, and skipping it is the single most damaging Python mistake an administrator can make.

python3 -m venv ~/.venvs/backup-tool
source ~/.venvs/backup-tool/bin/activate
# prompt now shows (backup-tool)
python -m pip install --upgrade pip
deactivate

Inside an activated environment, python and pip are that environment's, and python correctly means Python 3.

The reason is not tidiness. The system Python belongs to the package manager. On RHEL, dnf itself is written in Python; on Debian, so is a great deal of the installer tooling. Run sudo pip install requests and pip overwrites a version of requests that dnf depends on, with no record in the RPM database. The distribution's package manager now has an inconsistent view of its own dependencies, and the failure arrives later, during an unrelated update, looking like something else entirely.

Modern distributions stop you. Python 3.11 onward, following PEP 668, marks the system interpreter as externally managed and refuses:

error: externally-managed-environment
× This environment is externally managed

The right responses, in order of preference: create a virtual environment; install the distribution's own package (dnf install python3-requests); or use pipx for a command-line tool you want on PATH without a venv of your own. The wrong response is pip install --break-system-packages, whose name is an accurate description and not a warning to be dismissed.

Installing dependencies

Installing dependencies reproducibly is the difference between a script that works and a deployment that works.

source ~/.venvs/backup-tool/bin/activate
python -m pip install requests boto3
python -m pip freeze > requirements.txt
python -m pip install -r requirements.txt      # on the next machine
python -m pip list --outdated

pip freeze records exactly what is installed, pinned to versions, and requirements.txt is how you reproduce it. Pin your versions — the same argument as pinning a container image tag: requests unpinned means a different library on every machine and a script that starts failing without anyone changing it.

Use python -m pip rather than bare pip. It guarantees you are installing into the interpreter you think you are, which bare pip does not when several Pythons exist.

Newer projects declare dependencies in pyproject.toml instead, and uv is a much faster drop-in for pip and venv that is worth knowing exists. The concepts are unchanged.

The venv directory itself is disposable and machine-specific: put it in .gitignore and commit requirements.txt. Anyone who needs it rebuilds it in one command.

On the exam

  • Python 3 only. Python 2 died on 1 January 2020. Write python3 in the shebang; python may not exist.
  • Indentation is syntax, four spaces, and mixing tabs with spaces is a TabError.
  • Types: integer, floating point (never ==, never money), boolean (True/False, capitalised), string (immutable), list ([], ordered, mutable), dictionary ({}, key/value).
  • d["missing"] raises KeyError; d.get("missing") returns None.
  • Lists and dictionaries are mutable; strings and tuples are not.
  • PEP 8 is the style guide: four spaces, snake_case, imports at the top.
  • Built-in modules cover most of the job — os, sys, subprocess, json, re, pathlib, argparse, logging.
  • subprocess.run takes a list; shell=True is a command-injection route.
  • A module is a file, a package is a directory. Guard with if __name__ == "__main__":.
  • python3 -m venv, then activate. Never sudo pip install — it corrupts the interpreter the package manager depends on, and PEP 668 now blocks it with externally-managed-environment.
  • pip freeze > requirements.txt to record dependencies, pip install -r requirements.txt to reproduce them. Commit the file, not the venv.

Practise what you just read

1. Why does "sudo pip install requests" now fail on RHEL 9 with "externally-managed-environment"?

Select one

  1. The system interpreter is marked as managed by the distribution, per PEP 668
  2. requests conflicts with an existing package of the same name
  3. pip has been removed from the distribution's package set
  4. sudo strips the environment pip needs to locate site-packages
Show answer

A. Installing into the system Python can overwrite a library dnf itself depends on, with no record in the RPM database, so the failure appears later during an unrelated update. PEP 668 makes the interpreter refuse. The right answers are a virtual environment, the distribution's own package, or pipx -- not --break-system-packages, whose name is a description.

2. Which two commands set up and enter a virtual environment?

Select one

  1. python3 -m pip --isolated, then source activate
  2. python3 -m virtualenv, then export PYTHONPATH
  3. python3 -m venv ~/.venvs/tool, then source ~/.venvs/tool/bin/activate
  4. pip install venv, then venv activate ~/.venvs/tool
Show answer

C. venv ships with Python 3 and creates a self-contained directory with its own interpreter link and site-packages. Activating it puts that environment's python and pip first on PATH, so python correctly means Python 3 and installs land inside it. deactivate leaves. Setting one up is the first step of any Python work on a server.

3. How does Python delimit a block of code?

Select one

  1. By blank lines that separate the sections
  2. By braces, in the way that C does
  3. By a matching end keyword after each block
  4. By indentation
Show answer

D. Indentation is the block structure -- there is no fi, esac or closing brace. Four spaces per level is the near-universal convention, and Python 3 rejects mixed tabs and spaces outright with TabError. That rejection is a kindness: in Python 2 the same file looked correct and ran wrongly.

9 more questions on this objective are part of the full course.

Practise the full question bank in the exam simulator

Hands-on labs

All hands-on labs