🐍 Python Quick Reference

Python cheat sheet / survival guide

Video: Python quick reference (Shell Scripting video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
python3 [options] [script.py] [arguments...]
python3 --version

Python is a readable, general-purpose language that is very popular for automation, scripting, web services, data work and
tooling. For sysadmins it is the natural next step when a bash script grows too big: real data structures, proper error
handling, JSON and API support, and a huge library collection. This sheet covers Python 3 (3.10 and newer, with notes on
newer features). Python 2 is dead: always use python3. See the Bash sheet for shell scripting and the AI-Assisted
Shell Scripting guide for reviewing generated code safely.

Install, run and explore

python3 --version                            # check what you have
sudo apt install python3 python3-pip python3-venv       # Debian/Ubuntu    (dnf install python3 python3-pip on Fedora/RHEL family)
brew install python                          # macOS       winget install Python.Python.3.12    Windows
python3 script.py arg1 arg2                  # run a script
python3 -c 'print("hello")'                  # run a one-liner
python3 -m module                            # run a module as a script (for example: python3 -m http.server)
python3 -i script.py                         # run a script, then stay in the interactive prompt with its variables
python3                                      # start the REPL (interactive prompt). Exit with exit() or Ctrl+D
python3 -m pip install requests              # always use "python3 -m pip" so it matches the interpreter you run
python3 -X dev script.py                     # development mode: extra warnings
python3 -B script.py                         # do not write .pyc files
python3 -u script.py                         # unbuffered output (good for logs and pipes)
python3 -W error script.py                   # turn warnings into errors
In the REPL:  help(len)   dir(obj)   type(x)   _ (the last result)   import this (the Zen of Python)

Virtual environments and packages (do this for every project)
python3 -m venv .venv                        # create an isolated environment in .venv
source .venv/bin/activate                    # activate it (Windows: .venv\Scripts\activate)
deactivate                                   # leave it
pip install requests                         # install into the active environment
pip install "requests>=2.31,<3"              # version limits
pip install -r requirements.txt              # install a list
pip freeze > requirements.txt                # save exact versions
pip list                                     pip list --outdated                pip show requests
pip install --upgrade pip                    pip install -U package             pip uninstall package
pip install -e .                             # editable install of your own project (uses pyproject.toml)
pip check                                    # find broken dependencies
pipx install httpie                          # install a command line APPLICATION in its own hidden environment (sudo apt install pipx)
uv venv && uv pip install requests           # uv is a very fast modern alternative to pip and venv (astral.sh/uv)
Never run "sudo pip install". On Debian 12, Ubuntu 23.04 and newer, pip refuses system-wide installs:
  "error: externally-managed-environment"    This protects your operating system's Python. Use a venv, pipx, or the apt package (python3-NAME)
Add .venv/ to .gitignore. Do not copy a venv to another machine: recreate it from requirements.txt.

Basics

name = "Alice"                               # no type declaration, no semicolon. Indentation (4 spaces) defines blocks
age, city = 30, "Paris"                      # multiple assignment       a, b = b, a    swap
x = y = 0                                    # same value to both
PI = 3.14159                                 # constants are just a naming habit (UPPER_CASE)
print("Hello", name, sep=", ", end="!\n")    # print to the screen
type(x)  isinstance(x, int)                  # what is it?
# a comment                                  """a docstring: documentation for a module, function or class"""
Types: int, float, str, bool (True, False), None, list, tuple, dict, set, bytes, complex
Truthiness: these are False: False, None, 0, 0.0, "", [], (), {}, set().  Everything else is True.
Operators:  + - * / (float divide)  // (floor divide)  % (remainder)  ** (power)      == != < > <= >=      and  or  not     in  not in     is  is not
10 / 4 = 2.5     10 // 4 = 2     10 % 4 = 2     2 ** 8 = 256      1_000_000 (underscores for readability)     0xFF  0o17  0b101
Compare with None using "is":  if x is None:     Chained comparisons work:  0 < x < 10
Conversion: int("42")  float("3.5")  str(42)  bool(0)  list("abc")  tuple([1,2])  set([1,1,2])  int("ff", 16)  round(3.14159, 2)  abs(-5)  divmod(17, 5)

Strings

s = "Hello, World"
len(s)   s[0]   s[-1]   s[0:5]   s[7:]   s[::-1]        # index, last, slice, from 7, reversed. Strings are immutable
f"{name} is {age} years old"                 # f-string: put expressions in braces
f"{price:.2f}  {n:05d}  {n:,}  {ratio:.1%}  {name:>10}  {name:<10}  {name:^10}"      # formatting: decimals, zero pad, thousands, percent, align
f"{x = }"                                    # debug form: prints  x = 5
s.upper()  s.lower()  s.title()  s.capitalize()  s.swapcase()
s.strip()  s.lstrip()  s.rstrip()  s.strip("-")               # trim whitespace or given characters
s.split(",")  s.split()  s.splitlines()  ",".join(["a", "b"]) # split and join
s.replace("l", "L")  s.startswith("He")  s.endswith("ld")  s.find("o")  s.index("o")  s.count("l")
"World" in s                                 # substring test
s.isdigit()  s.isalpha()  s.isalnum()  s.isspace()
s.zfill(5)  s.center(20, "*")  s.ljust(10)  s.rjust(10)
"a\tb\n"   r"raw \n string (backslashes stay)"   b"bytes"   """multi-line
string"""
text.encode("utf-8")  data.decode("utf-8")   # strings <-> bytes
"%s is %d" % (name, age)   "{} is {}".format(name, age)       # older styles (use f-strings)
Remove a prefix or suffix (3.9+):  s.removeprefix("Hello, ")   s.removesuffix(".txt")

Lists, tuples, sets and dicts

nums = [3, 1, 4, 1, 5]                       # list: ordered, changeable
nums.append(9)  nums.extend([2, 6])  nums.insert(0, 7)  nums.remove(1)  nums.pop()  nums.pop(0)  del nums[0]
nums.sort()  nums.sort(reverse=True)  nums.reverse()  nums.copy()  nums.clear()  nums.index(4)  nums.count(1)
sorted(nums)  reversed(nums)  len(nums)  min(nums)  max(nums)  sum(nums)  any(nums)  all(nums)
nums[1:3]  nums[::2]  nums[-1]  nums + [1]  nums * 2  4 in nums
t = (1, 2, 3)                                # tuple: ordered, NOT changeable.   a, b, c = t    unpacking     first, *rest = t
s = {1, 2, 3}                                # set: unique, unordered.  s.add(4)  s.discard(2)  a | b  a & b  a - b  a ^ b   (union, intersection, difference, symmetric)
d = {"name": "Alice", "age": 30}             # dict: key -> value (keeps insertion order)
d["name"]  d.get("email")  d.get("email", "n/a")           # get() avoids a KeyError
d["city"] = "Paris"   d.update({"a": 1})  d.pop("age")  del d["name"]  d.setdefault("k", [])
d.keys()  d.values()  d.items()  "name" in d  len(d)
for k, v in d.items(): print(k, v)
merged = d1 | d2                             # merge dicts (3.9+)       {**d1, **d2}    also works
list(d)  dict(zip(keys, values))  dict.fromkeys(["a", "b"], 0)
Copy: shallow  b = a.copy()  or  list(a);   deep  import copy; copy.deepcopy(a)      "b = a" does NOT copy (both names point to one object)

Comprehensions and generators

squares = [x**2 for x in range(10)]                         # list comprehension
evens = [x for x in nums if x % 2 == 0]                     # with a filter
pairs = [(a, b) for a in "ab" for b in (1, 2)]              # nested loops
names = {u["name"]: u for u in users}                       # dict comprehension
unique = {x.lower() for x in words}                         # set comprehension
total = sum(x * x for x in range(1000))                     # generator expression: lazy, uses little memory
def count_up(n):                                            # a generator function: yields one value at a time
    for i in range(n):
        yield i
Use a comprehension for simple transforms. Use a normal loop when it gets hard to read.

Control flow

if x > 10:
    print("big")
elif x > 5:
    print("medium")
else:
    print("small")
label = "adult" if age >= 18 else "minor"                   # conditional expression
for i in range(5):        # 0..4        range(2, 10)   range(0, 10, 2)
    print(i)
for i, item in enumerate(items, start=1): ...               # index and value
for a, b in zip(list1, list2): ...                          # walk two lists together
for key in d: ...                                           # dict keys
while count < 5:
    count += 1
    if count == 3: continue                                 # skip to the next round
    if count == 4: break                                    # leave the loop
else:
    print("loop finished without break")                    # runs only if no break happened
match command:                                              # structural pattern matching (3.10+)
    case "start": ...
    case "stop" | "quit": ...
    case ["go", direction]: ...
    case {"type": "error", "code": code}: ...
    case _: print("unknown")
if (n := len(items)) > 10: print(n)                         # walrus operator: assign inside an expression
pass                                                        # do nothing (placeholder)

Functions

def greet(name, greeting="Hello"):                          # default value
    """Return a greeting."""
    return f"{greeting}, {name}!"
greet("Alice")   greet("Bob", greeting="Hi")                # positional and keyword arguments
def total(*numbers, **options):                             # *args = tuple of extras, **kwargs = dict of named extras
    return sum(numbers)
total(1, 2, 3)   total(*[1, 2, 3])   func(**{"a": 1})       # unpack when calling
def area(w: float, h: float = 1.0) -> float:                # type hints (not enforced at run time, used by editors and mypy)
    return w * h
def only_keywords(*, a, b): ...                             # arguments after * must be named
def positional_only(a, b, /): ...                           # arguments before / must be positional
square = lambda x: x * x                                    # a small anonymous function
sorted(items, key=lambda i: i["age"])
Do NOT use a mutable default:  def f(x, items=[])  (the list is shared between calls).  Use  items=None  then  if items is None: items = []
Scope: variables assigned in a function are local. Use "global" or "nonlocal" only when you must.
Decorators wrap a function:
import functools, time
def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
        return result
    return wrapper
@timed
def work(): ...
@functools.lru_cache(maxsize=None)                          # cache results of a pure function
def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)

Classes and dataclasses

class Server:
    count = 0                                               # class attribute (shared)
    def __init__(self, name, port=22):                      # the constructor
        self.name = name                                    # instance attributes
        self.port = port
    def __repr__(self):                                     # how it prints in the REPL and logs
        return f"Server({self.name!r}, {self.port})"
    def url(self):
        return f"ssh://{self.name}:{self.port}"
    @property
    def is_default(self): return self.port == 22
    @classmethod
    def from_string(cls, text): host, port = text.split(":"); return cls(host, int(port))
    @staticmethod
    def valid_port(p): return 0 < p < 65536
class Web(Server):                                          # inheritance
    def __init__(self, name): super().__init__(name, 80)
from dataclasses import dataclass, field
@dataclass
class Host:                                                 # generates __init__, __repr__ and __eq__ for you
    name: str
    port: int = 22
    tags: list[str] = field(default_factory=list)
@dataclass(frozen=True)                                     # immutable
class Point: x: int; y: int
Other useful dunder methods: __str__, __len__, __eq__, __lt__, __iter__, __getitem__, __enter__ / __exit__, __call__.

Errors and exceptions

try:
    value = int(text)
except ValueError as e:                                     # catch specific errors
    print(f"bad number: {e}")
except (KeyError, IndexError):
    ...
else:
    print("no error happened")                              # runs only if nothing was raised
finally:
    cleanup()                                               # always runs
raise ValueError("port must be 1-65535")                    # raise your own
raise RuntimeError("failed") from original_error           # keep the original cause
class ConfigError(Exception): pass                          # your own exception type
with suppress(FileNotFoundError): os.remove(path)           # from contextlib import suppress
Never write a bare "except:" (it hides Ctrl+C and real bugs). Catch the specific errors you can handle.
Common built-in errors: ValueError, TypeError, KeyError, IndexError, AttributeError, NameError, FileNotFoundError, PermissionError, OSError, ImportError, ZeroDivisionError, TimeoutError
assert x > 0, "x must be positive"                          # for internal checks and tests. Do not use for input validation (python -O removes asserts)
import traceback; traceback.print_exc()                     # print the full error trace

Files and paths

with open("file.txt") as f:                                 # "with" closes the file for you, even on errors
    text = f.read()                                         # whole file       f.readline()   one line
    for line in f:                                          # line by line (memory friendly)
        print(line.rstrip())
open("out.txt", "w")  "a" (append)  "r+" (read and write)  "rb" / "wb" (binary)      open(p, encoding="utf-8")   # be explicit about encoding
with open("out.txt", "w", encoding="utf-8") as f:
    f.write("line\n"); f.writelines(lines)
from pathlib import Path                                    # the modern way to handle paths
p = Path("/var/log") / "syslog"                             # join with /
p.exists()  p.is_file()  p.is_dir()  p.name  p.stem  p.suffix  p.parent  p.resolve()  p.stat().st_size
p.read_text()  p.write_text("data")  p.read_bytes()
list(Path(".").glob("*.py"))  list(Path(".").rglob("*.py"))       # rglob = recursive
Path("a/b/c").mkdir(parents=True, exist_ok=True)   p.unlink()   p.rename("new")   Path.home()   Path.cwd()
import shutil
shutil.copy("a", "b")  shutil.copy2("a", "b")  shutil.copytree("src", "dst")  shutil.move("a", "b")  shutil.rmtree("dir")  shutil.which("git")
shutil.disk_usage("/")                                      # (total, used, free) in bytes
shutil.make_archive("backup", "gztar", "folder")            # create a tar.gz
import os
os.getcwd()  os.listdir(".")  os.walk("dir")  os.environ.get("HOME")  os.getenv("DEBUG", "0")  os.chmod(p, 0o644)  os.getpid()
import tempfile
with tempfile.TemporaryDirectory() as tmp: ...              # a temp folder that is deleted afterward
tempfile.NamedTemporaryFile("w", delete=False)
Write files safely: write to a temp file, then replace the real one:  Path("f.tmp").write_text(data); Path("f.tmp").replace("f")

JSON, CSV and config files

import json
data = json.loads('{"a": 1}')     text = json.dumps(data, indent=2, sort_keys=True)     # strings
with open("x.json") as f: data = json.load(f)               # files
with open("x.json", "w") as f: json.dump(data, f, indent=2)
import csv
with open("x.csv", newline="") as f:
    for row in csv.DictReader(f): print(row["name"])        # each row is a dict keyed by the header
with open("out.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=["name", "age"]); w.writeheader(); w.writerow({"name": "A", "age": 1})
import tomllib                                              # read TOML (3.11+)
with open("pyproject.toml", "rb") as f: cfg = tomllib.load(f)
import configparser                                         # INI files
cp = configparser.ConfigParser(); cp.read("app.ini"); cp["section"]["key"]
YAML needs a package:  pip install pyyaml    then    import yaml; yaml.safe_load(text)     # always safe_load, never yaml.load on untrusted input
Command line:  python3 -m json.tool file.json     (pretty-print and validate JSON)

Regular expressions

import re
re.search(r"\d+", text)                                     # first match anywhere (or None). Use raw strings r"..."
re.match(r"\d+", text)  re.fullmatch(r"\d+", text)          # match at the start / match the whole string
re.findall(r"\b\w+@\w+\.\w+\b", text)                       # list of all matches
re.finditer(pattern, text)                                  # iterate over match objects
re.sub(r"\s+", " ", text)                                   # replace
re.split(r"[,;]\s*", text)
m = re.search(r"(?P<user>\w+)@(?P<host>[\w.]+)", text)
if m: m.group(0)  m.group("user")  m.groups()  m.start()  m.end()
pat = re.compile(r"^ERROR (\d+)", re.MULTILINE | re.IGNORECASE)      # compile once and reuse; flags: I, M, S (dot matches newline), X (verbose)
Patterns:  .  \d \w \s (digit, word, space)  \D \W \S (not)  ^ $  * + ? {2,4}  (a|b)  [abc]  [^abc]  (?:non-capturing)  *? +? (lazy)  (?=ahead)  (?<=behind)

Dates and times

from datetime import datetime, date, timedelta, timezone
now = datetime.now()   utc = datetime.now(timezone.utc)   today = date.today()
now.strftime("%Y-%m-%d %H:%M:%S")                           # format:  %Y year %m month %d day %H hour %M minute %S second %A weekday %b month name %j day of year
datetime.strptime("2026-09-26", "%Y-%m-%d")                 # parse
datetime.fromisoformat("2026-09-26T10:30:00")   now.isoformat()
tomorrow = now + timedelta(days=1, hours=2)   (later - now).total_seconds()   (later - now).days
datetime.fromtimestamp(1700000000)   now.timestamp()        # to and from Unix time
from zoneinfo import ZoneInfo                               # time zones (3.9+)
datetime.now(ZoneInfo("Europe/Paris"))
import time
time.time()  time.sleep(2)  time.perf_counter()             # Unix time, pause, precise timer
Store and compare times in UTC. Convert to local time only when displaying.

Running commands: subprocess

import subprocess
r = subprocess.run(["ls", "-l", "/tmp"], capture_output=True, text=True, check=True)
print(r.stdout)   r.stderr   r.returncode
subprocess.run(["systemctl", "restart", "nginx"], check=True)               # check=True raises CalledProcessError on failure
subprocess.run(["grep", "-c", "error", "app.log"], capture_output=True, text=True, timeout=30)
subprocess.run("df -h | head -3", shell=True)                                # a shell string: ONLY with trusted, fixed text
out = subprocess.check_output(["hostname"], text=True).strip()
p = subprocess.Popen(["tail", "-f", "log"], stdout=subprocess.PIPE, text=True)       # for streaming or long-running processes
for line in p.stdout: print(line, end="")
Always pass a LIST of arguments, not one string, and avoid shell=True. Never build a command by pasting user input into a string (command injection).
import shlex; shlex.quote(user_text)  shlex.split("a 'b c'")    # if you must deal with shell-style text
Add timeout= to anything that could hang, and handle subprocess.TimeoutExpired and CalledProcessError.

Command line arguments: argparse

import argparse
parser = argparse.ArgumentParser(description="Clean old files")
parser.add_argument("path", help="folder to scan")                          # positional
parser.add_argument("-d", "--days", type=int, default=30, help="age in days")
parser.add_argument("-n", "--dry-run", action="store_true")                 # a flag
parser.add_argument("--mode", choices=["fast", "safe"], default="safe")
parser.add_argument("files", nargs="*")                                     # zero or more
args = parser.parse_args()
print(args.path, args.days, args.dry_run)
Run:  python3 clean.py /var/tmp -d 7 --dry-run          Help is automatic:  python3 clean.py -h
Subcommands:  sub = parser.add_subparsers(dest="cmd"); sub.add_parser("start")
Simple case: import sys; sys.argv (list of arguments; sys.argv[0] is the script). sys.exit(1) ends with a status. Write errors to sys.stderr.
Other options: click and typer (nicer command line tools, pip install).

Logging

import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger(__name__)
log.debug("detail")  log.info("started")  log.warning("careful")  log.error("failed")  log.exception("with traceback")   # exception() inside an except block
logging.basicConfig(filename="app.log", level=logging.DEBUG)                # log to a file
from logging.handlers import RotatingFileHandler                            # rotate files (or use logrotate)
Use logging instead of print in anything that runs unattended. Do not log passwords or tokens.

Environment variables and secrets

import os
db_url = os.environ["DATABASE_URL"]                          # KeyError if missing (good for required settings)
debug = os.getenv("DEBUG", "0") == "1"                       # optional, with a default
pip install python-dotenv    then    from dotenv import load_dotenv; load_dotenv()      # load a .env file in development (keep it out of git)
import secrets, getpass
secrets.token_hex(16)  secrets.token_urlsafe(32)  secrets.choice(chars)                 # secure random values (not the random module)
getpass.getpass("Password: ")                                # read a password without echoing it
Never hardcode passwords, tokens or keys in code. Read them from the environment or a secrets manager.

HTTP and APIs

pip install requests
import requests
r = requests.get("https://api.example.com/items", params={"q": "x"}, headers={"Authorization": f"Bearer {token}"}, timeout=10)
r.raise_for_status()                                         # raise an error for 4xx/5xx
data = r.json()   r.status_code   r.text   r.headers
requests.post(url, json={"a": 1}, timeout=10)                # send JSON       data={...} for a form
s = requests.Session(); s.headers.update({"User-Agent": "mytool/1.0"})       # reuse connections and settings
Always set timeout=. Without it a request can hang forever.
No install needed (standard library):
from urllib.request import urlopen
with urlopen("https://example.com", timeout=10) as resp: html = resp.read().decode()
Async and modern alternatives: httpx, aiohttp.   A quick web server for a folder:  python3 -m http.server 8000

Useful standard library modules

collections   Counter(words).most_common(3)   defaultdict(list)   deque(maxlen=10)   namedtuple   OrderedDict   ChainMap
itertools     chain, product, permutations, combinations, groupby, islice, cycle, count, batched (3.12)
functools     lru_cache, partial, reduce, wraps, cache
operator      itemgetter, attrgetter       sorted(rows, key=itemgetter("age"))
pathlib, shutil, os, glob, tempfile, fnmatch          files and paths
json, csv, tomllib, configparser, xml.etree.ElementTree, sqlite3    data formats and a built-in database
datetime, zoneinfo, time, calendar                    dates and times
re, string, textwrap, difflib                         text
subprocess, shlex, argparse, logging, sys, os         scripts and tools
hashlib       hashlib.sha256(data).hexdigest()        hmac, secrets, base64, uuid (uuid.uuid4())
socket, ssl, http.client, urllib, smtplib, email, ipaddress, selectors      networking (ipaddress.ip_network("10.0.0.0/24").hosts())
threading, multiprocessing, concurrent.futures, asyncio, queue, sched         concurrency
gzip, zipfile, tarfile, bz2, lzma, shutil.make_archive         compression
unittest, doctest, pdb, timeit, cProfile, tracemalloc          testing, debugging and profiling
platform      platform.platform(), platform.node()    getpass, pwd, grp, signal, resource   system information
sqlite3       a database in one file, no server:  conn = sqlite3.connect("app.db"); conn.execute("CREATE TABLE ..."); conn.commit()

Databases (parameterized queries only)

import sqlite3
conn = sqlite3.connect("app.db")
conn.execute("INSERT INTO users (name) VALUES (?)", (name,))            # the ? placeholder protects against SQL injection
rows = conn.execute("SELECT id, name FROM users WHERE age > ?", (18,)).fetchall()
conn.commit(); conn.close()
Never build SQL with f-strings or +. MySQL/PostgreSQL: pip install pymysql or psycopg (%s placeholders). ORMs: SQLAlchemy.

Concurrency (pick the right tool)

threading           Waiting on network or disk (I/O-bound). Threads share memory
multiprocessing     Heavy CPU work. Uses separate processes (the GIL limits threads to one CPU at a time in standard Python)
asyncio             Thousands of simultaneous network connections with async/await
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(fetch, urls))                                # run many at once, keep the order
    futures = [pool.submit(fetch, u) for u in urls]
    for fut in as_completed(futures): print(fut.result())
import asyncio
async def main():
    await asyncio.sleep(1)
    results = await asyncio.gather(fetch(1), fetch(2))                   # run coroutines together
asyncio.run(main())
Use a lock for shared data:  lock = threading.Lock(); with lock: ...

Sorting, counting and grouping

sorted(users, key=lambda u: u["age"], reverse=True)          sorted(words, key=str.lower)          sorted(rows, key=lambda r: (r["team"], -r["score"]))
from collections import Counter, defaultdict
Counter(words).most_common(5)                                # top 5 words
groups = defaultdict(list)
for u in users: groups[u["team"]].append(u)
from itertools import groupby                                # the data must be sorted by the key first
max(items, key=len)   min(d, key=d.get)   sum(1 for x in xs if x.ok)

Type hints and checking

def parse(line: str) -> dict[str, int]: ...                  # built-in generics (3.9+)
x: int | None = None                                         # union syntax (3.10+)      older: from typing import Optional, Union
from typing import Any, Callable, Iterable, Literal, TypedDict, Protocol
pip install mypy      mypy script.py          # static type check          pyright is a fast alternative
Type hints are optional and not enforced at run time, but they make big scripts far easier to maintain.

Testing

pip install pytest
# test_math.py
def add(a, b): return a + b
def test_add(): assert add(2, 3) == 5
import pytest
def test_error():
    with pytest.raises(ZeroDivisionError): 1 / 0
@pytest.mark.parametrize("a,b,expected", [(1, 2, 3), (0, 0, 0)])
def test_many(a, b, expected): assert add(a, b) == expected
pytest                    pytest -v      pytest -x (stop at first failure)      pytest -k "add"      pytest tests/test_math.py::test_add      pytest --cov=mypkg
Fixtures give tests set-up data:  @pytest.fixture  def tmp_config(tmp_path): ...       (tmp_path is a built-in temp directory)
Built in alternatives:  python3 -m unittest    python3 -m doctest file.py

Debugging and profiling

breakpoint()                                  # drop into the debugger at this line (pdb)
python3 -m pdb script.py                      # start under the debugger
pdb commands: n (next)  s (step into)  c (continue)  l (list code)  p expr (print)  pp expr  b 42 (breakpoint at line 42)  w (stack)  q (quit)
print(f"{var = }")                            # quick debugging          import pprint; pprint.pprint(data)
python3 -X dev script.py                      # extra checks           python3 -W error    warnings become errors
python3 -m timeit "sum(range(1000))"          # time a snippet
python3 -m cProfile -s cumtime script.py | head -20            # where does the time go?
import tracemalloc                            # find memory hogs
Read tracebacks from the BOTTOM: the last line is the error, and the frames above it show how it was reached.

Code quality tools

pip install ruff black mypy pytest
ruff check .   ruff check --fix .   ruff format .          # very fast linter and formatter (replaces flake8, isort and more)
black .                                                    # a popular auto-formatter
mypy .                                                     # type checker
pre-commit install                                         # run these automatically before each git commit
Follow PEP 8 (4-space indents, snake_case names, CapWords for classes, 79 to 100 character lines). A formatter settles the rest.

Packaging your own project (pyproject.toml)
my_tool/
  pyproject.toml
  src/my_tool/__init__.py
  src/my_tool/cli.py
  tests/test_cli.py
# pyproject.toml
[project]
name = "my-tool"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31"]
[project.scripts]
my-tool = "my_tool.cli:main"                                # creates a "my-tool" command
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
pip install -e .                                           # develop with live edits
python3 -m build                                           # build a wheel (pip install build)
Run a package's module:  python3 -m my_tool.cli        Include a main guard in every script (see the skeleton below).

A solid script skeleton

#!/usr/bin/env python3
"""Delete old files in a folder. Use --dry-run first."""
import argparse
import logging
import sys
import time
from pathlib import Path

log = logging.getLogger(__name__)

def parse_args(argv=None):
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("folder", type=Path)
    p.add_argument("-d", "--days", type=int, default=30)
    p.add_argument("-n", "--dry-run", action="store_true")
    p.add_argument("-v", "--verbose", action="store_true")
    return p.parse_args(argv)

def old_files(folder: Path, days: int):
    cutoff = time.time() - days * 86400
    for p in folder.iterdir():
        if p.is_file() and p.stat().st_mtime < cutoff:
            yield p

def main(argv=None) -> int:
    args = parse_args(argv)
    logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
                        format="%(asctime)s %(levelname)s %(message)s")
    if not args.folder.is_dir():
        log.error("Not a folder: %s", args.folder)
        return 2
    for path in old_files(args.folder, args.days):
        if args.dry_run:
            log.info("Would delete %s", path)
        else:
            log.info("Deleting %s", path)
            path.unlink()
    return 0

if __name__ == "__main__":                                  # runs only when executed, not when imported
    sys.exit(main())
Make it runnable:  chmod +x clean.py   then   ./clean.py /var/tmp -n       The shebang #!/usr/bin/env python3 finds python3 on the PATH.

Sysadmin recipes

# Disk usage
import shutil; t, u, f = shutil.disk_usage("/"); print(f"{u / t:.0%} used")

# Find the 10 biggest files under a folder
from pathlib import Path
files = sorted((p for p in Path("/var").rglob("*") if p.is_file()), key=lambda p: p.stat().st_size, reverse=True)[:10]

# Count the most common IP addresses in a log
import re; from collections import Counter
ips = re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", Path("access.log").read_text())
print(Counter(ips).most_common(10))

# Check if a port is open
import socket
with socket.socket() as s:
    s.settimeout(2); print(s.connect_ex(("example.com", 443)) == 0)

# Send a simple email through an SMTP relay (see the Azure SMTP relay guide for the settings)
import smtplib; from email.message import EmailMessage
msg = EmailMessage(); msg["From"] = "noreply@example.com"; msg["To"] = "you@example.com"; msg["Subject"] = "Alert"; msg.set_content("Disk is 90% full")
with smtplib.SMTP("smtp.example.com", 587) as s: s.starttls(); s.login(user, password); s.send_message(msg)

# System stats (pip install psutil)
import psutil; psutil.cpu_percent(1); psutil.virtual_memory().percent; psutil.disk_usage("/").percent; [p.info for p in psutil.process_iter(["pid", "name"])]

# Run a command on many servers over SSH (pip install paramiko or fabric)
import paramiko
c = paramiko.SSHClient(); c.load_system_host_keys(); c.connect("host", username="me", key_filename="/home/me/.ssh/id_ed25519")
stdin, stdout, stderr = c.exec_command("uptime"); print(stdout.read().decode()); c.close()

# Work with IP networks
import ipaddress
net = ipaddress.ip_network("192.168.1.0/24"); list(net.hosts())[:3]; ipaddress.ip_address("192.168.1.5") in net

# Parse JSON from a command
import json, subprocess
data = json.loads(subprocess.run(["ip", "-j", "addr"], capture_output=True, text=True, check=True).stdout)

# Retry with backoff
import time
for attempt in range(5):
    try: do_it(); break
    except OSError: time.sleep(2 ** attempt)

# Watch a file for new lines (like tail -f)
with open("app.log") as f:
    f.seek(0, 2)
    while True:
        line = f.readline()
        print(line, end="") if line else time.sleep(0.5)

Scheduling your scripts

crontab -e     */15 * * * * /usr/bin/python3 /opt/scripts/check.py >> /var/log/check.log 2>&1
Use full paths (cron has a tiny PATH), the virtualenv's interpreter (/opt/app/.venv/bin/python), and log the output.
A systemd timer gives better logging and control (see the systemctl sheet). Use flock to avoid overlapping runs.

Handy one-liners

python3 -m http.server 8000                            # share the current folder on port 8000 (local testing only)
python3 -m json.tool data.json                         # pretty-print and validate JSON       cat x.json | python3 -m json.tool
python3 -c "import sys, json; print(json.load(sys.stdin)['key'])" < x.json        # pull one value out of JSON
python3 -m venv .venv && . .venv/bin/activate          # new environment
python3 -c "import uuid; print(uuid.uuid4())"          # a random ID
python3 -c "import secrets; print(secrets.token_urlsafe(32))"      # a strong random token
python3 -c "import base64; print(base64.b64encode(b'hello').decode())"
python3 -c "import hashlib; print(hashlib.sha256(b'hello').hexdigest())"
python3 -c "import time; print(time.time())"           # current Unix time
python3 -c "import platform; print(platform.platform())"
python3 -c "print(sum(range(1, 101)))"
python3 -m timeit "'-'.join(str(n) for n in range(100))"
python3 -m calendar 2026                               # print a calendar
python3 -m zipfile -l archive.zip                      # list a zip       python3 -m tarfile -l archive.tar.gz
python3 -m pip list --outdated
python3 -m compileall .                                # byte-compile everything (a quick syntax check)
python3 -m pyflakes script.py                          # a light linter (pip install pyflakes)

Common errors

ModuleNotFoundError: No module named 'x'          Not installed in THIS environment. Activate your venv and pip install x. Check which python3 and pip
error: externally-managed-environment             Debian/Ubuntu protect the system Python. Use a venv, pipx, or apt install python3-NAME
IndentationError / TabError                       Mixed tabs and spaces, or wrong indentation. Use 4 spaces everywhere. Configure your editor
SyntaxError: invalid syntax                       A missing colon, bracket or quote. The error line is often just AFTER the real mistake. Also check for Python 2 print "x"
NameError: name 'x' is not defined                A typo, or used before assignment, or forgot an import
TypeError: 'NoneType' object is not subscriptable   A function returned None (often it forgot to "return"), or you assigned the result of list.sort() (which returns None)
TypeError: can only concatenate str (not "int")   Convert first: str(n), or use an f-string
KeyError: 'name'                                  The dict has no such key. Use d.get("name") or check "name" in d
IndexError: list index out of range               The index is past the end. Check len(), and remember indexes start at 0
AttributeError: 'X' object has no attribute 'y'   Wrong type, or a typo. Check with type(x) and dir(x)
ValueError: invalid literal for int()             The text is not a number. Validate first or catch ValueError
UnicodeDecodeError                                Wrong encoding. Open with encoding="utf-8" (or errors="replace"), or read in binary mode
FileNotFoundError / PermissionError               Wrong path or working directory (print Path.cwd()), or missing permissions
RecursionError                                    A function calls itself forever. Add a base case, or use a loop
ImportError: cannot import name / circular import   Two modules import each other. Move shared code to a third module
"python: command not found"                       Use python3 (or install the python-is-python3 package)
Script edits are ignored                          You are running an old copy, another environment, or a stale .pyc. Check python3 -c "import mod; print(mod.__file__)"
Changes to a list inside a function surprise you  Lists and dicts are passed by reference. Copy first if you must keep the original
The same list appears in every object             A mutable default argument, or a class-level list. Create it in __init__ or use default_factory
Slow loops                                        Use built-ins, comprehensions, sets/dicts for lookups, generators for big data, or a profiler to find the hot spot

Security habits

- Never use eval() or exec() on data you did not write, and never pickle.load() untrusted files (both can run arbitrary code)
- Pass argument LISTS to subprocess, avoid shell=True, and never build SQL, shell or file paths by pasting user input
- Use the secrets module (not random) for tokens and passwords, and hashlib.scrypt or a library like argon2/bcrypt to hash passwords
- Load secrets from the environment or a secrets manager, never from source code or git
- Use yaml.safe_load, defusedxml for untrusted XML, and validate input (types, ranges, allowed characters)
- Keep dependencies pinned and updated. Check them with pip-audit. Install only packages you trust and review names carefully (typosquatting is real)
- Run scripts with the least privilege they need, and set timeouts on network calls and subprocesses
- Do not log secrets. Do not commit .env files

Tips

    Use python3 and a virtual environment for every project. Never run sudo pip install.

    Use f-strings, pathlib, with-statements and dataclasses. They make code shorter and safer.

    Always pass timeout= to network calls and subprocess.run, and check=True (or handle the return code) for commands.

    Catch specific exceptions, log with logging.exception, and let unexpected errors fail loudly instead of hiding them.

    Put a main() function and an "if __name__ == '__main__':" guard in every script, so it can also be imported and tested.

    Use argparse (or click and typer) for options, and add --dry-run to anything that changes or deletes things.

    Read tracebacks from the bottom up, and use breakpoint() when print statements are not enough.

    Format with ruff or black, check types with mypy, and write a few pytest tests for anything you rely on.

    Reach for Python when a bash script needs real data structures, JSON, APIs or error handling. Keep bash for gluing simple commands together.

    Never trust user input, and never eval it. Treat files, environment variables and network data as untrusted until you validate them.