DevOps Nimbus · Python for DevOps

Python errors and fixes for DevOps

76 errors that automation scripts meet, each with the exact message, what it means, each cause with how to check it and its fix, and how to prevent it. Then learn it for good: break and fix each one yourself in the browser playground. Generated from the site's troubleshooting reference.

ModuleNotFoundError: No module named 'requests'

ModuleNotFoundError: No module named 'requests'

What it means

Python looked for a module called requests in every folder on its search path (sys.path) and found none. The search path belongs to the interpreter that runs the script, so the usual surprise is that it works in your terminal and fails under cron, systemd, sudo or CI, or right after a new venv is created.

Causes, most likely first

  1. A different interpreter runs the script than the one you installed into, for example the system python3 instead of the venv's. Each interpreter has its own site-packages, so the package is simply not on this one's path.

    Check

    python3 -c 'import sys; print(sys.executable)'   # run it the way the job runs: a path outside your venv is this cause
    head -1 script.py   # which interpreter the shebang names

    Fix

    Run the script with the environment's own interpreter (/opt/app/.venv/bin/python script.py) in the crontab, unit file or CI step.

  2. The package was never installed in this environment, so no interpreter here can import it.

    Check

    python3 -m pip show requests   # 'Package(s) not found': it is not installed for this interpreter

    Fix

    Install it into the interpreter that runs the script: python3 -m pip install requests (inside the venv), and add it to requirements.txt.

  3. The import name differs from the package name, so installing the name you import installs nothing useful: import yaml comes from PyYAML.

    Check

    python3 -m pip show PyYAML   # installed under its distribution name?

    Fix

    Install the distribution that provides the module (pip install PyYAML for yaml, opencv-python for cv2).

Prevent

One venv per application, a pinned requirements.txt, and absolute interpreter paths in crontabs and unit files.

Now learn it: break and fix it yourself in Cron uses the wrong Python, No matching distribution for yaml, then take Level 10 · Packaging and environments. Interactive version: #fix/modulenotfound.

ImportError: cannot import name 'x' from 'y'

ImportError: cannot import name 'Retry' from 'requests.packages.urllib3' (unknown location)

What it means

The module was found and imported, but the name you asked for is not in it. (unknown location) means Python could not even tell where the module came from, a sign that it is a namespace or a vendored alias rather than the file you expected.

Causes, most likely first

  1. The name moved or was removed in a new major version of the library, so the module exists but no longer has it.

    Check

    python3 -c 'import y; print(y.__file__, getattr(y, "__version__", "?"))'   # a newer version than your code was written for
    pip index versions y

    Fix

    Import from the library's current location, or pin the version your code was written for until you update the code.

  2. A local file shadows the library (a json.py or requests.py next to your script): Python imports your file, which does not have the name.

    Check

    ls *.py   # anything named like the library?
    python3 -c 'import y; print(y.__file__)'   # your folder, not site-packages: this cause

    Fix

    Rename the local file and delete its __pycache__.

  3. A circular import: two of your modules import each other, so one asks for a name before the other has finished defining it.

    Check

    Read the traceback: the same two modules of yours appear in the import chain

    Fix

    Move the shared code into a third module that both import.

Prevent

Pin dependencies, read changelogs before major upgrades, and never name your files after modules.

Now learn it: break and fix it yourself in An unpinned dependency breaks the deploy, then take Level 10 · Packaging and environments. Interactive version: #fix/importerror.

AttributeError: module 'x' has no attribute 'y'

AttributeError: partially initialized module 'requests' has no attribute 'get' (most likely due to a circular import)

What it means

The module was imported, but it does not have the attribute you used. partially initialized means the module was still being loaded when the name was looked up, usually because of a circular import or a file of yours with the library's name.

Causes, most likely first

  1. A file of yours has the library's name (requests.py, json.py, email.py), so import requests imports your file, which imports itself.

    Check

    python3 -c 'import requests; print(requests.__file__)'   # your folder, not site-packages: this cause
    ls *.py

    Fix

    Rename the file that shadows the library, and delete its __pycache__.

  2. The module and a class in it share a name: after import datetime, now() is datetime.datetime.now().

    Check

    python3 -c 'import datetime; print(dir(datetime))'

    Fix

    Call the name where it lives (from datetime import datetime).

  3. Two of your modules import each other, so one is used before it has finished loading.

    Check

    Read the traceback: the same two modules of yours appear twice

    Fix

    Move shared code into a third module to break the cycle.

Prevent

Never name your files after modules you use, and keep imports one-directional.

Now learn it: take Level 02 · Functions, modules and scripts. Interactive version: #fix/moduleattr.

error: externally-managed-environment

error: externally-managed-environment

× This environment is externally managed

What it means

pip refused to install into the system Python because the operating system marks it as managed by its own package manager (PEP 668), so pip could overwrite files apt or dnf installed. This happens on Debian 12 or later, Ubuntu 23.04 or later and other distributions that ship the marker; --user and sudo are refused too.

Cause

  1. You are running the system pip, so the install would go into the OS's Python.

    Check

    python3 -m pip --version   # a path under /usr/lib: the system Python
    ls /usr/lib/python3*/EXTERNALLY-MANAGED   # the marker file pip obeys

    Fix

    Create a venv and use its pip and python: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt. For command-line tools, pipx install <tool>. Use apt for OS-wide Python packages. Avoid --break-system-packages: a later apt upgrade can break the OS's tools or your packages.

Prevent

Never install into the system Python; avoid --break-system-packages.

Now learn it: break and fix it yourself in externally-managed-environment, then take Level 10 · Packaging and environments. Interactive version: #fix/pep668.

ERROR: No matching distribution found

ERROR: Could not find a version that satisfies the requirement yaml==6.0.2 (from versions: none)
ERROR: No matching distribution found for yaml==6.0.2

What it means

pip asked the package index for that name and version and got nothing it can install. (from versions: none) means no version at all matched for this Python and platform, which usually points at the name rather than the version.

Causes, most likely first

  1. The distribution has another name on PyPI, so the name you asked for does not exist: yaml is PyYAML, cv2 is opencv-python.

    Check

    pip index versions PyYAML   # versions listed under the real name: this cause

    Fix

    Use the PyPI distribution name in requirements.txt.

  2. The pinned version does not exist, or has no wheel for this Python version or platform, so pip filters it out.

    Check

    pip index versions PyYAML   # is your pinned version in the list?
    python3 -V; pip debug --verbose | head   # the Python version and platform tags pip accepts

    Fix

    Pin a version that exists for your Python, or use a Python version the package supports.

  3. A private index or proxy is not configured, so pip searches an index that does not have the package.

    Check

    pip config list   # index-url and extra-index-url in effect

    Fix

    Configure the index in pip.conf or with PIP_INDEX_URL where the job runs.

Prevent

Generate requirements with pip freeze from a working venv.

Now learn it: break and fix it yourself in No matching distribution for yaml, then take Level 10 · Packaging and environments. Interactive version: #fix/nomatch.

source: not found (activating a venv in cron or sh)

/bin/sh: 1: source: not found

What it means

The shell running the line has no command called source. That is /bin/sh: on Debian and Ubuntu it is dash, which only knows the POSIX spelling .. Cron and many CI steps run lines with /bin/sh, not bash.

Causes, most likely first

  1. cron or the CI step runs /bin/sh, and dash has no source.

    Check

    ls -l /bin/sh   # -> dash: this cause

    Fix

    Do not activate at all in automation (see the next cause); in sh scripts, . (dot) is the POSIX spelling of source.

  2. Activation was never needed: it only puts the venv's bin first on PATH for the current shell. Calling the venv's interpreter directly does the same job.

    Check

    /opt/app/.venv/bin/python -c 'import sys; print(sys.prefix)'   # prints the venv: the interpreter alone uses it

    Fix

    Call /opt/app/.venv/bin/python script.py directly.

Prevent

Use absolute venv interpreter paths in crontabs, systemd units and CI steps.

Now learn it: break and fix it yourself in Cron uses the wrong Python, then take Level 00 · Run Python the right way. Interactive version: #fix/venvactivate.

Activate.ps1 cannot be loaded because running scripts is disabled

.venv\Scripts\Activate.ps1 cannot be loaded because running scripts is disabled on this system.

What it means

PowerShell refused to run the venv's activation script because its execution policy forbids running scripts. Restricted, the default on Windows client editions, runs none at all.

Cause

  1. The effective execution policy is Restricted (or AllSigned), so Activate.ps1 is blocked before it starts.

    Check

    Get-ExecutionPolicy -List   # the first scope that is not Undefined wins

    Fix

    Allow local scripts for your user: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, or skip activation and run .venv\Scripts\python.exe script.py.

Prevent

Document the venv's python.exe path for Windows build agents.

Now learn it: take Level 00 · Run Python the right way. Interactive version: #fix/psscripts.

python: command not found

bash: python: command not found

What it means

The shell searched every folder in PATH for a program called python and found none. Current Linux distributions ship python3, and the bare python name is optional.

Causes, most likely first

  1. Only python3 is installed, so the name python does not exist.

    Check

    command -v python3 python   # only python3 printed: this cause

    Fix

    Call python3 (or the venv's bin/python). On Debian and Ubuntu, apt install python-is-python3 adds the python name if you need it.

  2. PATH under cron or sudo is shorter than your shell's, so a Python in /usr/local/bin or your home is not found there.

    Check

    echo $PATH   # compare with the job's PATH (cron: /usr/bin:/bin)

    Fix

    Use absolute interpreter paths in automation, or set PATH where the job is defined.

Prevent

Use #!/usr/bin/env python3 shebangs and absolute paths in automation.

Now learn it: break and fix it yourself in No such file: 'kubectl', then take Level 00 · Run Python the right way. Interactive version: #fix/commandnotfound.

TypeError: Retry.__init__() got an unexpected keyword argument

TypeError: Retry.__init__() got an unexpected keyword argument 'method_whitelist'

What it means

Code (often your own retry setup) passed an argument that the installed urllib3 no longer accepts. method_whitelist was renamed allowed_methods and removed in urllib3 2.0, so a fresh install that pulled the new major version breaks code that did not change.

Cause

  1. An unpinned transitive dependency (urllib3, pulled in by requests) moved to a new major version that removed the argument.

    Check

    pip freeze | grep -i urllib3   # 2.x: this cause
    pip show requests | grep Requires

    Fix

    Pin every package to the versions you tested (pip freeze), then update the code to the new API (allowed_methods) in its own change.

Prevent

Lock all dependencies, including transitive ones, and upgrade on purpose with tests.

Now learn it: break and fix it yourself in An unpinned dependency breaks the deploy, then take Level 10 · Packaging and environments. Interactive version: #fix/urllib3ssl.

IndentationError: expected an indented block

IndentationError: expected an indented block after 'if' statement on line 11

What it means

Python reached a line ending in a colon (if, for, def, with, try) and the next line was not indented further, so the block has no body. The file is rejected before anything runs; the message names the header line.

Causes, most likely first

  1. The body of the block is not indented further than its header.

    Check

    python3 -m py_compile script.py   # names the line

    Fix

    Indent the block by four spaces more than its header line.

  2. An editor mixed in tabs, so a line that looks indented is not indented the way Python counts it.

    Check

    cat -A script.py | sed -n '9,14p'   # tabs show as ^I

    Fix

    Replace tabs with spaces and set the editor to four spaces.

Prevent

Configure your editor for four spaces, and run py_compile or a linter in CI.

Now learn it: break and fix it yourself in IndentationError, then take Level 01 · Values, collections and control flow. Interactive version: #fix/indentexpected.

IndentationError: unexpected indent

IndentationError: unexpected indent

What it means

A line is indented more than the block it belongs to, so Python cannot tell which block it is in. unindent does not match any outer indentation level is the same mistake the other way round.

Causes, most likely first

  1. A line is indented more than the statements around it.

    Check

    python3 -m py_compile script.py   # names the line

    Fix

    Align the line with the other statements of its block.

  2. Code pasted from a chat or web page brought extra leading spaces or non-breaking spaces.

    Check

    cat -A script.py | sed -n '1,20p'   # unexpected characters before the code

    Fix

    Re-indent the pasted lines, or run a formatter.

Prevent

Format with a formatter and check syntax in CI.

Now learn it: break and fix it yourself in IndentationError, then take Level 01 · Values, collections and control flow. Interactive version: #fix/unexpectedindent.

TabError: inconsistent use of tabs and spaces

TabError: inconsistent use of tabs and spaces in indentation

What it means

Some lines in the block are indented with tabs and others with spaces. Python 3 refuses to guess how wide a tab is, so lines that look aligned are rejected.

Cause

  1. Tabs and spaces are mixed in the same block.

    Check

    grep -nP '^\t' script.py   # every line that starts with a tab

    Fix

    Replace tabs with four spaces throughout (expand -t 4), or let your editor convert them.

Prevent

An .editorconfig with indent_style = space for *.py.

Now learn it: break and fix it yourself in IndentationError, then take Level 01 · Values, collections and control flow. Interactive version: #fix/taberror.

SyntaxError: '(' was never closed

SyntaxError: '(' was never closed

What it means

Python reached the end of the file with an opening bracket still open. It points at the opening bracket, which is often lines above the real mistake: the missing closing one is somewhere after it.

Cause

  1. A closing bracket or quote is missing in a long call or literal.

    Check

    python3 -m py_compile script.py   # names the opening bracket

    Fix

    Close the bracket Python points at; check the lines after it for the missing one.

Prevent

Keep calls short, one argument per line for long ones, and an editor that matches brackets.

Now learn it: take Level 01 · Values, collections and control flow. Interactive version: #fix/neverclosed.

NameError: name 'x' is not defined

NameError: name 'json' is not defined. Did you forget to import 'json'?

What it means

The line uses a name that does not exist at that point. Python only finds this when the line runs, so it can appear on one path through the code only.

Causes, most likely first

  1. A missing import.

    Check

    grep -n 'json' script.py   # used, but never imported?

    Fix

    Import the name at the top of the module.

  2. A typo in the name, or a different case (Path and path).

    Check

    grep -n -i 'path' script.py

    Fix

    Use the name exactly as it is defined.

  3. The name is set only inside an if branch, a loop that did not run, or another function.

    Check

    python3 -m py_compile script.py   # passes: a NameError only appears when the line runs

    Fix

    Set the variable before the branches that may skip it.

Prevent

A linter in CI (pyflakes or ruff) reports undefined names before the script runs.

Now learn it: take Level 02 · Functions, modules and scripts. Interactive version: #fix/nameerror.

UnboundLocalError: cannot access local variable

UnboundLocalError: cannot access local variable 'total' where it is not associated with a value

What it means

The function reads a local variable that has not been given a value yet. Before Python 3.11 the message was local variable 'total' referenced before assignment.

Causes, most likely first

  1. Assigning to a name anywhere in a function makes it local in the whole function, so total += 1 cannot read the module-level total.

    Check

    Find every assignment to the name inside the function: each one makes it local

    Fix

    Pass the value in and return the new one. Use global or nonlocal only when you really mean to share it.

  2. The variable is assigned only in a branch or in a try block that failed, and read afterwards (often conn in a finally block).

    Check

    Read the branch or try block that sets it: can it be skipped?

    Fix

    Initialise it before the branch or the try (conn = None, then if conn is not None: conn.close() in finally).

Prevent

Keep state in function arguments and return values rather than module globals.

Now learn it: take Level 02 · Functions, modules and scripts. Interactive version: #fix/unboundlocal.

TypeError: f() missing 1 required positional argument

TypeError: send_alert() missing 1 required positional argument: 'channel'

What it means

The call does not match the function's parameters. The message names the function and what is wrong: a missing argument, an unexpected keyword, or too many positional arguments.

Causes, most likely first

  1. A parameter was added, renamed or removed and not every caller was updated.

    Check

    python3 -c 'import inspect, alerts; print(inspect.signature(alerts.send_alert))'   # what it takes now

    Fix

    Call it with the parameters it has now; give a new parameter a default so old callers keep working.

  2. A library changed its signature in a new version.

    Check

    pip show <library>   # which version is installed

    Fix

    Read the signature of the installed version, or pin the version your code was written for.

  3. A method defined without self, or called on the class instead of an instance, so the arguments shift by one.

    Check

    Read the def line and the call

    Fix

    Add self, or call it on an instance.

Prevent

Keyword arguments for anything beyond the obvious first one, pinned dependencies, and tests that call each function.

Now learn it: take Level 02 · Functions, modules and scripts. Interactive version: #fix/funcargs.

TypeError: 'NoneType' object is not subscriptable

TypeError: 'NoneType' object is not subscriptable

What it means

The code did x[...] and x was None. None is what a function gives back when it finds nothing or ends without return, so the crash is on the indexing line but the real cause is wherever x was set.

Causes, most likely first

  1. A function returned None: it found nothing, or reached its end without return.

    Check

    print(repr(x)) just before the line
    Read the function: every path should return something

    Fix

    Check for None where you call the function (if host is None:), report it, and continue or exit.

  2. A dict .get() returned None for a missing key.

    Check

    print(repr(d.get(key)))   # None for some records

    Fix

    Give .get() a default, or handle the missing key explicitly.

  3. The result of an in-place method was assigned: list.sort() and dict.update() change the object and return None.

    Check

    grep -n '= .*\.sort()' script.py

    Fix

    Call the method on its own line, or use sorted() which returns a new list.

Prevent

Return explicit values, or raise a clear exception when nothing is found.

Now learn it: break and fix it yourself in 'NoneType' object is not subscriptable, then take Level 01 · Values, collections and control flow. Interactive version: #fix/nonesub.

AttributeError: 'NoneType' object has no attribute

AttributeError: 'NoneType' object has no attribute 'append'

What it means

The code called a method or read an attribute on a variable that holds None. The line in the traceback is where it was used; the cause is where it was set.

Causes, most likely first

  1. A default of None that is never replaced with a real value.

    Check

    print(repr(x))   # None at that point

    Fix

    Create the value when it is None (if tags is None: tags = []).

  2. re.match(), re.search() or .get() found nothing and returned None.

    Check

    print(repr(m))   # None for the input that failed

    Fix

    Check the match before using it (if m is None: ...).

  3. An assignment from a method that returns None (items = items.append(x)).

    Check

    Read the line that sets the variable

    Fix

    Call the method without assigning its result.

Prevent

Use None as the default for mutable arguments, and handle the no-match case of every search.

Now learn it: break and fix it yourself in Tags leak between hosts, then take Level 02 · Functions, modules and scripts. Interactive version: #fix/noneattr.

KeyError

KeyError: 'email'

What it means

A dict (or os.environ) was asked for a key it does not have. The message shows the missing key.

Causes, most likely first

  1. An optional field is absent from some records, and the code indexes it with [].

    Check

    python3 -c "import json; print([u for u in json.load(open('users.json')) if 'email' not in u][:3])"   # records without the key

    Fix

    Use .get(key, default) for optional keys.

  2. os.environ['X'] for a variable that is not set; cron and CI start with few variables.

    Check

    env | cut -d= -f1 | sort   # the variable names this process has, without their values

    Fix

    Set the variable where the job is defined; for required settings, fail early with a clear message.

Prevent

Validate input at the edge; document which keys and variables are required.

Now learn it: break and fix it yourself in KeyError on optional data, then take Level 01 · Values, collections and control flow. Interactive version: #fix/keyerror.

IndexError: list index out of range

IndexError: list index out of range

What it means

The code asked for position n of a list, tuple or string that has fewer items. It usually comes from input that is shorter than expected.

Causes, most likely first

  1. sys.argv[1] when the script was run without an argument.

    Check

    print(sys.argv)

    Fix

    Use argparse, which reports a missing argument clearly.

  2. line.split()[n] on an empty or truncated line.

    Check

    print(len(fields), repr(line))   # fewer fields than expected

    Fix

    Catch (ValueError, IndexError) per line and count the skipped ones.

Prevent

Treat external input as untrusted; test with empty and truncated lines.

Now learn it: break and fix it yourself in One bad line stops the report, then take Level 08 · Errors and logging. Interactive version: #fix/indexerror.

ValueError: invalid literal for int() / could not convert string to float

ValueError: could not convert string to float: '/api/ca'

What it means

int() or float() was given text that is not a number. The message shows the exact value, which tells you which field or line was wrong.

Causes, most likely first

  1. A truncated or malformed line, so another field ends up where the number should be.

    Check

    grep -n -v -E '[0-9.]+$' access.log | head   # lines that do not end in a number

    Fix

    Validate each value where you read it; skip and count bad records instead of stopping.

  2. A unit or a separator in the value ("80%", "1,024").

    Check

    print(repr(value))

    Fix

    Strip the unit or separator explicitly before converting.

  3. The line is split in the wrong place, for example a comma inside a quoted CSV field.

    Check

    print(line.split(','))

    Fix

    Parse structured formats with their modules (csv, json).

Prevent

Parse structured formats with their modules (csv, json), and log what was skipped.

Now learn it: break and fix it yourself in One bad line stops the report, too many values to unpack, then take Level 08 · Errors and logging. Interactive version: #fix/valueint.

TypeError: '>' not supported between instances of 'str' and 'int'

TypeError: '>' not supported between instances of 'int' and 'str'

What it means

The code compared (or added) a str and an int. Python does not convert between them; values from outside the program are text until you convert them.

Causes, most likely first

  1. os.environ values, input(), sys.argv and CSV fields are always strings.

    Check

    print(repr(limit), type(limit))   # '90' <class 'str'>

    Fix

    Convert once where the value is read: limit = int(os.environ.get("DISK_ALERT_PERCENT", "90")); for text, f"{count} hosts" instead of +.

  2. An argparse option without type=int.

    Check

    grep -n 'add_argument' script.py

    Fix

    Add type=int to the option.

Prevent

Validate settings at start-up and fail with a clear message if they are not numbers.

Now learn it: break and fix it yourself in '>' not supported for str, then take Level 05 · Command-line tools. Interactive version: #fix/strintcompare.

ValueError: too many values to unpack

ValueError: too many values to unpack (expected 2)

What it means

The code unpacked a sequence into a fixed number of names (a, b = ...) and the sequence had a different number of items. The message says how many were expected (and, for too few, how many it got).

Causes, most likely first

  1. A field contains the separator: a comma inside a quoted CSV value, a colon in an IPv6 address.

    Check

    print(repr(line), line.split(','))

    Fix

    Use the csv module for CSV; split with a limit (line.rsplit(':', 1)).

  2. A blank, short or comment line.

    Check

    grep -n -v -E '^[^,]+,[^,]+$' hosts.csv | head

    Fix

    Skip and count lines with the wrong number of fields.

  3. A dict iterated without .items(), so each item is a key string.

    Check

    Read the for line

    Fix

    for k, v in d.items().

Prevent

Parse structured formats with their modules and test with awkward lines.

Now learn it: break and fix it yourself in too many values to unpack, then take Level 06 · Data formats and time. Interactive version: #fix/unpack.

TypeError: can't compare offset-naive and offset-aware datetimes

TypeError: can't subtract offset-naive and offset-aware datetimes

What it means

The code compared or subtracted a datetime that has a time zone (aware) and one that has none (naive). Python refuses because it cannot know which zone the naive one is in.

Cause

  1. datetime.now() and utcnow() are naive, while ISO times with +00:00 from an API are aware.

    Check

    python3 -c "from datetime import datetime; print(datetime.now().tzinfo)"   # None: naive

    Fix

    Use aware times throughout: datetime.now(timezone.utc); attach a zone to naive inputs with .replace(tzinfo=...) only when you know it.

Prevent

Store and compare in UTC; convert to local time only for display.

Now learn it: break and fix it yourself in Naive and aware datetimes, then take Level 06 · Data formats and time. Interactive version: #fix/naiveaware.

TypeError: a bytes-like object is required, not 'str'

TypeError: a bytes-like object is required, not 'str'

What it means

An operation got text (str) where it needs bytes, or the other way round. Python 3 never converts between them silently, and comparing the two is always False, which can hide the bug.

Causes, most likely first

  1. subprocess.run without text=True returns bytes.

    Check

    print(type(out), repr(out[:40]))   # <class 'bytes'>, b'...'

    Fix

    Ask for text: subprocess.run(..., capture_output=True, text=True).

  2. A file or socket opened in binary mode ('rb', or a raw socket) is being mixed with str.

    Check

    print(f.mode)   # 'rb'

    Fix

    Open in text mode with an encoding, or decode once with .decode('utf-8').

Prevent

Decide at the edge whether data is bytes or text; never compare the two.

Now learn it: break and fix it yourself in The alert that never fires, then take Level 04 · Processes and subprocess. Interactive version: #fix/bytesstr.

TypeError: Object of type datetime is not JSON serializable

TypeError: Object of type datetime is not JSON serializable

What it means

json.dump met a value that JSON has no type for. JSON knows strings, numbers, booleans, null, lists and objects; not datetime, set, Decimal or Path.

Cause

  1. A datetime, set, Decimal or Path is in the data being dumped.

    Check

    print({k: type(v).__name__ for k, v in report.items()})   # the type the message names

    Fix

    Convert first: .isoformat() for datetimes, list() for sets, str() for paths; or json.dumps(obj, default=str).

Prevent

Build report dicts from JSON-ready values; test the dump in CI.

Now learn it: break and fix it yourself in Not JSON serializable, then take Level 06 · Data formats and time. Interactive version: #fix/notserializable.

yaml.scanner.ScannerError: mapping values are not allowed here

yaml.scanner.ScannerError: mapping values are not allowed here
  in "deploy.yaml", line 7, column 17

What it means

The YAML parser found text it cannot read as YAML at that line and column. mapping values are not allowed here means a : appeared where YAML did not expect a new key; the real mistake is often on the line before.

Causes, most likely first

  1. An unquoted value that contains : (a URL with a port is fine, note: run at: 02:00 is not).

    Check

    python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" deploy.yaml   # line and column

    Fix

    Quote the value (note: "run at: 02:00").

  2. Indentation that does not line up, or a tab: found character '\t' that cannot start any token.

    Check

    sed -n '5,8p' deploy.yaml | cat -A   # tabs show as ^I

    Fix

    Indent with spaces only, aligned with the sibling keys.

  3. A value that starts with a character YAML reserves (*, &, @, a backtick, %) and is not quoted.

    Check

    sed -n '7p' deploy.yaml

    Fix

    Quote the value.

Prevent

Load configuration with yaml.safe_load in a CI check, and an editor that shows YAML indentation.

Now learn it: break and fix it yourself in yaml.load runs code, then take Level 06 · Data formats and time. Interactive version: #fix/yamlerror.

RecursionError: maximum recursion depth exceeded

RecursionError: maximum recursion depth exceeded

What it means

A function called itself (directly or through others) more than about a thousand levels deep, Python's default limit. The traceback shows the repeating frame.

Causes, most likely first

  1. No base case, or one that is never reached.

    Check

    Read the repeated frame in the traceback: "[Previous line repeated 996 more times]"

    Fix

    Fix the base case, or turn the recursion into a loop with an explicit stack.

  2. Following a cycle: symlinks, parent links, a property that calls itself.

    Check

    find /path -type l   # symlinks that point back up

    Fix

    Track what you have visited; for directory trees use os.walk, which does not follow symlinks by default. Do not raise sys.setrecursionlimit to hide it: it moves the crash and can crash the interpreter.

Prevent

Do not raise sys.setrecursionlimit to hide it; it moves the crash and can crash the interpreter.

Now learn it: break and fix it yourself in Too many levels of symbolic links, then take Level 03 · Files and paths. Interactive version: #fix/recursion.

UnicodeDecodeError: 'utf-8' codec can't decode byte

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 1832: invalid continuation byte

What it means

Python read the file as UTF-8 and met a byte sequence that is not valid UTF-8. The byte and position in the message point at the first such character, often an accented letter written in another encoding.

Causes, most likely first

  1. The file is not UTF-8, often Windows-1252 or Latin-1 from another system.

    Check

    file -i customers_export.csv   # charset=iso-8859-1 or unknown-8bit
    python3 -c "print(open('customers_export.csv','rb').read()[1820:1840])"   # the bytes around the position in the message

    Fix

    Open with the encoding the file was written in: open(path, encoding='cp1252').

  2. open() without encoding= uses the machine's locale, so the same script reads differently on another machine.

    Check

    python3 -c 'import locale; print(locale.getpreferredencoding())'

    Fix

    Always pass encoding=. errors='ignore' only hides corrupted data.

Prevent

Always pass encoding=; agree on UTF-8 for exchange files. errors='ignore' only hides corrupted data.

Now learn it: break and fix it yourself in UnicodeDecodeError, then take Level 03 · Files and paths. Interactive version: #fix/unicodedecode.

FileNotFoundError: [Errno 2] No such file or directory

FileNotFoundError: [Errno 2] No such file or directory: 'config.json'

What it means

The operating system has no file at that path. A relative path like 'config.json' is looked up from the current working directory, which is the folder you start the job in, not the script's folder.

Causes, most likely first

  1. A relative path resolves against the working directory, which differs under cron, systemd or CI.

    Check

    python3 -c 'import os; print(os.getcwd())'   # run the way the job runs: not the script's folder

    Fix

    Anchor files that ship with the script to it: Path(__file__).resolve().parent / 'config.json'.

  2. A parent directory is missing, or the path is not the one you meant.

    Check

    ls -l "$(dirname script.py)"
    print(repr(path))

    Fix

    Create the directory first (path.parent.mkdir(parents=True, exist_ok=True)) and build paths with pathlib.

Prevent

Never rely on the working directory in scheduled jobs; pass paths as arguments.

Now learn it: break and fix it yourself in Works by hand, fails in cron, Path glued together, then take Level 03 · Files and paths. Interactive version: #fix/filenotfound.

PermissionError: [Errno 13] Permission denied

PermissionError: [Errno 13] Permission denied: '/srv/archive2026-10-30.zip'

What it means

The operating system refused the read or write for the user the process runs as. Check the exact path in the message first: here a missing separator put the file in /srv instead of /srv/archive.

Causes, most likely first

  1. The path is not what you meant (a missing separator puts the file in the parent directory), and that directory is not writable.

    Check

    print(repr(path)) before the write

    Fix

    Build paths with pathlib (Path('/srv/archive') / name).

  2. The job runs as a different user than in your test, or the directory is owned by root.

    Check

    id; ls -ld /srv /srv/archive   # who you are, and who may write there

    Fix

    Write where the job's user owns the directory, or give that user its own directory. Not chmod 777.

  3. SELinux or AppArmor denies it although the mode bits allow it (common for services on RHEL and Fedora).

    Check

    ls -Z /srv/archive; ausearch -m avc -ts recent   # as root: an AVC denial names the process and the path

    Fix

    Give the directory the right label (semanage fcontext and restorecon), never setenforce 0.

Prevent

Run jobs as a dedicated user with its own directories; avoid sudo in scripts.

Now learn it: break and fix it yourself in Path glued together, then take Level 03 · Files and paths. Interactive version: #fix/permission.

OSError: [Errno 24] Too many open files

OSError: [Errno 24] Too many open files

What it means

The process hit its limit on open file descriptors. Files, sockets and pipes all count, so a long run that never closes them fails after hours.

Causes, most likely first

  1. Files or responses are opened and never closed.

    Check

    ls /proc/<pid>/fd | wc -l   # rising over time: a leak

    Fix

    Open files and responses in with blocks.

  2. A new connection per request without a session or pool.

    Check

    ls -l /proc/<pid>/fd | grep -c socket

    Fix

    Reuse a requests.Session.

  3. The limit itself is low for this service: the soft limit is often 1024 (systemd: LimitNOFILE=).

    Check

    grep 'open files' /proc/<pid>/limits   # the running process's limit, not your shell's (ulimit -n)

    Fix

    Raise the limit (LimitNOFILE= in the unit) only for a service that really needs many files, after any leak is fixed.

Prevent

with for every resource; watch the open-file count in monitoring.

Now learn it: break and fix it yourself in The empty report, then take Level 03 · Files and paths. Interactive version: #fix/toomanyfiles.

OSError: [Errno 28] No space left on device

OSError: [Errno 28] No space left on device

What it means

A write failed because the file system had no room left. The file being written is left truncated.

Causes, most likely first

  1. The file system is full.

    Check

    df -h /backups   # 100% used
    du -xh --max-depth=1 /backups | sort -h | tail   # what fills it

    Fix

    Free space or write elsewhere; write to a temporary name and rename only when the write succeeded, so no truncated file is left as if complete.

  2. There is free space but no free inodes (millions of small files).

    Check

    df -i /backups   # IUse% 100%

    Fix

    Delete or archive the small files.

  3. Deleted files are still held open by a running process, so their space is not freed.

    Check

    lsof +L1   # deleted files still open

    Fix

    Restart or signal the process that holds them (for a logger, reopen its log).

  4. /tmp is a small tmpfs in memory.

    Check

    df -h /tmp   # tmpfs

    Fix

    Write large temporary files to a disk-backed directory (tempfile.mkdtemp(dir=...)).

Prevent

Check free space before large writes (shutil.disk_usage), alert on disk usage with disk_report.py, and rotate and expire old files.

Now learn it: break and fix it yourself in Readers see a half-written file, then take Level 03 · Files and paths. Interactive version: #fix/nospace.

BrokenPipeError: [Errno 32] Broken pipe

BrokenPipeError: [Errno 32] Broken pipe

What it means

The script wrote to a pipe or socket whose reader had already closed it. With | head, head stops reading after its lines, and Python raises on the next write.

Causes, most likely first

  1. The reader closed the pipe early (head, grep -q), and Python raises on the next write to stdout.

    Check

    python3 list_hosts.py | head -1; echo "${PIPESTATUS[0]}"   # non-zero with a traceback

    Fix

    Catch it at the top level: except BrokenPipeError: then os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) and sys.exit(1), as the Python documentation for the signal module shows.

  2. For sockets: the other side closed the connection.

    Check

    Did the server restart or time out the connection at that moment?

    Fix

    Treat it as a closed connection and reconnect if that makes sense.

Prevent

Command-line tools that handle a closed stdout quietly; network clients that expect connections to close.

Now learn it: take Level 05 · Command-line tools. Interactive version: #fix/brokenpipe.

Killed (exit status 137)

Killed

What it means

The process received SIGKILL, so it stopped without a traceback; 137 is 128 + 9, the signal number. Almost always the kernel's out-of-memory killer.

Causes, most likely first

  1. The kernel's out-of-memory killer: the machine ran out of memory and chose this process.

    Check

    dmesg -T | grep -i -E 'killed process|out of memory'   # names the process

    Fix

    Stream input line by line, process in batches, and bound caches.

  2. A container or cgroup memory limit, lower than the host's free memory (Kubernetes reports OOMKilled).

    Check

    dmesg -T | grep -i 'memory cgroup out of memory'
    kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'   # OOMKilled

    Fix

    Reduce memory use as above, or raise the container's limit to what you measured.

  3. Reading a whole large file, or an unbounded cache, makes memory grow with the input.

    Check

    /usr/bin/time -v python3 script.py   # Maximum resident set size

    Fix

    Iterate over the file instead of read(); give caches a size limit.

Prevent

Measure peak memory on realistic data before it reaches production.

Now learn it: break and fix it yourself in Killed: the 10 GB log, Memory grows until the OOM killer, then take Level 03 · Files and paths. Interactive version: #fix/killed.

ConnectionRefusedError: [Errno 111] Connection refused

requests.exceptions.ConnectionError: HTTPConnectionPool(host='127.0.0.1', port=8080): Max retries exceeded with url: / (Caused by NewConnectionError("HTTPConnection(host='127.0.0.1', port=8080): Failed to establish a new connection: [Errno 111] Connection refused"))

What it means

The client reached the host, and the host answered at once that nothing accepts connections on that port (a TCP reset). Because the answer is immediate, the network path works; the problem is the service or the address.

Causes, most likely first

  1. Nothing listens on that host and port: the service is down, or the port is wrong.

    Check

    python3 scripts/port_check.py host:8080   # refused from here
    ss -ltnp | grep 8080   # on the server: no line means nothing listens

    Fix

    Start the service, or use the right host and port.

  2. The service listens on 127.0.0.1 only, so connections from other hosts are refused.

    Check

    ss -ltnp | grep 8080   # on the server: 127.0.0.1:8080 means local only

    Fix

    Make it listen on the right interface, and allow only the hosts that need it in the firewall.

  3. A firewall rejects the traffic with a reset rather than dropping it.

    Check

    sudo iptables -S | grep -i reject   # or nft list ruleset

    Fix

    Allow the traffic from the client's address.

Prevent

Health checks with port_check.py or http_monitor.py after every deploy.

Now learn it: break and fix it yourself in Hangs for ever, then take Level 07 · HTTP and APIs. Interactive version: #fix/connrefused.

ConnectTimeout: connection timed out

requests.exceptions.ConnectTimeout: HTTPSConnectionPool(host='api.example.internal', port=443): Max retries exceeded with url: /v1/status (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7f2c>, 'Connection to api.example.internal timed out. (connect timeout=5)'))

What it means

The client sent the connection request and nothing came back at all before the connect timeout (5 seconds here): no answer, no refusal. Something on the way drops the packets, or the address leads nowhere. Without requests it is TimeoutError: [Errno 110] Connection timed out.

Causes, most likely first

  1. A firewall, security group or network ACL silently drops the traffic.

    Check

    python3 scripts/port_check.py api.example.internal:443   # timed out, not refused
    nc -vz -w 5 api.example.internal 443

    Fix

    Allow the traffic from the client's address in the security group or firewall.

  2. The address is not reachable from here: a private IP from outside the VPC or VPN, no route, no NAT gateway.

    Check

    ip route get <address>   # which route, if any

    Fix

    Use an address that is reachable from where the script runs, or add the route or NAT gateway.

  3. A proxy is required (HTTPS_PROXY) and not set, or is set and not reachable.

    Check

    env | grep -i -o '^[a-z_]*proxy'   # which proxy variables are set

    Fix

    Set the proxy where the job runs.

  4. The host is down and nothing answers for it.

    Check

    Does it time out from other hosts too?

    Fix

    Bring the host back; keep a short connect timeout, separate from the read timeout: timeout=(5, 30).

Prevent

Test connectivity from new hosts and subnets before the first job runs, and report unreachable services clearly instead of retrying for ever.

Now learn it: break and fix it yourself in Hangs for ever, then take Level 07 · HTTP and APIs. Interactive version: #fix/connecttimeout.

SSLError: CERTIFICATE_VERIFY_FAILED

requests.exceptions.SSLError: ... [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1032)

What it means

The TLS connection was made, but Python could not build a chain of trust from the server's certificate to a certificate authority it trusts. unable to get local issuer certificate means the issuer is missing from the trust store or from the chain the server sent.

Causes, most likely first

  1. The server uses a private CA that requests does not trust: requests uses the certifi bundle, not the system store.

    Check

    openssl s_client -connect host:443 -servername host -showcerts </dev/null   # the issuer at the top of the chain

    Fix

    Trust the right CA: verify='/etc/ssl/certs/corp-root-ca.pem' or REQUESTS_CA_BUNDLE. Never verify=False: it accepts any certificate, including an attacker's.

  2. The server does not send its intermediate certificate, so clients that do not cache it cannot complete the chain.

    Check

    openssl s_client -connect host:443 -servername host -showcerts </dev/null | grep -c 'BEGIN CERTIFICATE'   # 1: only the leaf was sent

    Fix

    Fix the server's chain: configure the full chain file.

  3. The certificate expired or does not name the host.

    Check

    python3 scripts/cert_checker.py host:443   # expiry and names

    Fix

    Renew the certificate, or connect with the name it was issued for.

  4. A TLS-inspecting proxy replaces the certificate with one signed by its own CA.

    Check

    openssl s_client -connect host:443 -servername host </dev/null | grep issuer   # the proxy's CA

    Fix

    Trust the proxy's CA explicitly where the job runs.

Prevent

Distribute the company CA bundle to hosts and CI; monitor expiry with cert_checker.py.

Now learn it: break and fix it yourself in CERTIFICATE_VERIFY_FAILED, then take Level 07 · HTTP and APIs. Interactive version: #fix/sslverify.

ReadTimeout / TimeoutError: timed out

requests.exceptions.ReadTimeout: HTTPConnectionPool(host='status.internal', port=8080): Read timed out. (read timeout=10)

What it means

The connection was made and the request sent, but no answer arrived within the read timeout (10 seconds here). The server is reachable; it is slow, stuck, or the connection died silently.

Causes, most likely first

  1. The server accepted the connection but answers slowly or not at all.

    Check

    curl -m 10 -sv http://status.internal:8080/api/health   # connects, then waits
    time python3 status_check.py

    Fix

    Keep the timeout, catch requests.exceptions.RequestException, report the service as down, and exit non-zero; retry with backoff if it makes sense.

  2. A dropped connection with no reset (a NAT or firewall forgot it), so only a timeout notices.

    Check

    Does it fail after an idle period and work on retry?

    Fix

    Retry idempotent calls once with a new connection, and keep the timeout.

Prevent

A timeout on every network call: requests, urlopen, sockets and subprocess.

Now learn it: break and fix it yourself in Hangs for ever, One host hangs the whole run, then take Level 07 · HTTP and APIs. Interactive version: #fix/readtimeout.

JSONDecodeError: Expecting value: line 1 column 1 (char 0)

requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

What it means

resp.json() was called on a body that is not JSON. line 1 column 1 (char 0) means the very first character was wrong: the body is empty or HTML.

Causes, most likely first

  1. The body is an HTML error page (502, 503) or empty, because the call failed.

    Check

    curl -s -o /dev/null -w '%{http_code} %{content_type}\n' URL   # not 200, or text/html

    Fix

    Call resp.raise_for_status() (or check status_code) before resp.json().

  2. A login redirect returned an HTML page instead of the API's answer.

    Check

    print(resp.status_code, resp.url, resp.headers.get('Content-Type'))   # a login URL

    Fix

    Send the credentials the API expects, and treat a redirect to a login page as an authentication failure.

Prevent

Never treat a failed call as an empty result; alert on the status.

Now learn it: break and fix it yourself in JSONDecodeError on an error page, then take Level 07 · HTTP and APIs. Interactive version: #fix/jsondecode.

socket.gaierror: [Errno -2] Name or service not known

socket.gaierror: [Errno -2] Name or service not known

What it means

The name could not be turned into an address: getaddrinfo failed before any connection was tried. Name or service not known means DNS said the name does not exist; Temporary failure in name resolution means no DNS server answered.

Causes, most likely first

  1. A typo, or an internal name used outside the network or VPN where it exists.

    Check

    getent hosts db.internal   # no output: the name does not resolve here

    Fix

    Use a name that resolves where the script runs.

  2. DNS is not configured in this container or CI runner.

    Check

    cat /etc/resolv.conf   # nameserver lines

    Fix

    Fix DNS for that environment (the container's network or the runner's resolver).

Prevent

Read host names from configuration per environment.

Now learn it: take Level 07 · HTTP and APIs. Interactive version: #fix/gaierror.

429 Too Many Requests

requests.exceptions.HTTPError: 429 Client Error: Too Many Requests for url: https://config.internal/v1/hosts/self

What it means

The API refused the request because this client (or every client sharing the key or address) sent too many requests in its time window. The server may say when to try again in a Retry-After header.

Causes, most likely first

  1. Retries in a tight loop multiply the request rate exactly when the API is struggling.

    Check

    Count requests per minute in the API's logs
    resp.headers.get('Retry-After')

    Fix

    Limit attempts, back off exponentially with random jitter, and honour Retry-After.

  2. Many hosts retry in step after an outage, all at the same moment.

    Check

    Do the 429s start right after a restart or outage, from many hosts?

    Fix

    Add random jitter to retries and schedules.

  3. No pagination or caching: the script re-fetches everything each time.

    Check

    Count calls per run

    Fix

    Page through results once, and cache what does not change.

Prevent

One shared retry helper with backoff and jitter for every API client.

Now learn it: break and fix it yourself in Retries take the API down, Only the first 100, then take Level 07 · HTTP and APIs. Interactive version: #fix/http429.

OSError: [Errno 98] Address already in use

OSError: [Errno 98] Address already in use

What it means

The program tried to listen on a port that another socket already holds. macOS says [Errno 48].

Causes, most likely first

  1. Another process, often an older copy of the same script, already listens on the port.

    Check

    ss -ltnp 'sport = :9100'   # the process that holds it
    lsof -i :9100

    Fix

    Stop the old process or choose another port.

  2. The previous run's socket is in TIME_WAIT and the server does not set SO_REUSEADDR.

    Check

    ss -tan 'sport = :9100'   # TIME-WAIT lines, no LISTEN

    Fix

    Set sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) before bind (socketserver: allow_reuse_address = True).

Prevent

Run services under systemd so only one copy runs, and make the port a setting.

Now learn it: take Level 07 · HTTP and APIs. Interactive version: #fix/addrinuse.

MySQL 1045: Access denied for user

pymysql.err.OperationalError: (1045, "Access denied for user 'app'@'10.0.3.15' (using password: YES)")

What it means

The server received your login but no account accepted it. 'app'@'10.0.3.15' is the user name your client sent and the address the server saw you connect from (after any NAT or proxy). using password: YES means a password was sent; NO means none was. The same message covers a wrong password, no matching account and another account matching first, so it does not by itself mean the password is wrong. Code 1698 with the same text is root on a server that signs root in through the Unix socket.

Causes, most likely first

  1. The password is wrong or was rotated, or the variable that holds it is empty or not set where the job runs (cron, CI, the container). The server finds the account, the password does not match, and it answers 1045.

    Check

    mysql -h db.example.internal -P 3306 -u app -p -e 'SELECT USER(), CURRENT_USER();'   # with the password from the secret store: works here, so the script gets another password
    python3 -c "import os; print(repr(os.environ.get('DB_USER')), bool(os.environ.get('DB_PASSWORD')))"   # run it the way the job runs: shows whether the credentials reach the process, without printing the password; False: empty or not set

    Fix

    Give the process the current password from the environment or a secret store. In a URL, percent-encode an @, : or / in the password.

  2. Another account matched first. MySQL sorts accounts by host, most specific first ('10.0.3.15', then '10.0.3.%', then '%'), and uses the first that matches, so 'app'@'%' with the password you know can lose to a more specific 'app' row with another password, or to an anonymous ''@'localhost' when you connect from the server itself.

    Check

    mysql -h db.example.internal -P 3306 -u app -p -e 'SELECT USER(), CURRENT_USER();'   # USER() is the identity the client supplied (user@client-host as the server sees it); CURRENT_USER() is the account actually used to authenticate. ''@'localhost' or another host than you expect: another account matched
    SELECT user, host, plugin FROM mysql.user WHERE user IN ('app', '') ORDER BY host;   # as an administrator: every account that could match; user '' is an anonymous account

    Fix

    Remove the anonymous accounts (DROP USER ''@'localhost';, as mysql_secure_installation does) or the stale, more specific row, then check CURRENT_USER() again.

  3. No account exists for this user from this host: 'app'@'localhost' does not let app in from 10.0.3.15. The client's address may also not be the one you expect (NAT, a proxy, a new subnet). With no row to match, the server answers 1045.

    Check

    SELECT user, host FROM mysql.user WHERE user = 'app';   # as an administrator: no host pattern covers the address in the message

    Fix

    As an administrator, create an account for the application's network, for example CREATE USER 'app'@'10.0.3.%' IDENTIFIED BY ... and GRANT SELECT, INSERT ON shop.* TO 'app'@'10.0.3.%'. The server still picks the account by its host-matching rules and the accounts that exist, so run the CURRENT_USER() check afterwards to see which one it chose.

  4. Socket and TCP are different paths: the mysql client and mysqlclient reach localhost through the Unix socket and 127.0.0.1 through TCP, so the two can match different accounts.

    Check

    mysql -u app -p -e 'SELECT CURRENT_USER()'; mysql -h 127.0.0.1 -u app -p -e 'SELECT CURRENT_USER()'   # different accounts, or one refused

    Fix

    Connect the way the account was set up, or create the account for the host the server sees.

  5. The account signs in another way. Root on Debian, Ubuntu and MariaDB uses the unix_socket or auth_socket plugin, so a password login as root is refused (1698). An Amazon RDS account with IAM authentication needs a fresh token (valid for 15 minutes) over TLS, not a password.

    Check

    SELECT user, host, plugin FROM mysql.user WHERE user IN ('app', 'root');   # unix_socket, auth_socket or AWSAuthenticationPlugin

    Fix

    For root, create an application user instead of changing root's plugin; with RDS IAM authentication, connect with a token from aws rds generate-db-auth-token over TLS.

Prevent

One database user per application with only the privileges it needs, no anonymous accounts, credentials from the environment or a secret store, and a start-up check that fails with one clear line when they are missing.

Now learn it: take Level 05 · Command-line tools. Interactive version: #fix/mysqldenied.

MySQL 2003: Can't connect to MySQL server

pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on 'db.example.internal' ([Errno 111] Connection refused)")

What it means

The client could not open a network connection to the MySQL server, so no login was tried. The reason in brackets tells the causes apart: Connection refused comes back at once, timed out after a pause, Name or service not known is DNS. 2002 Can't connect to local MySQL server through socket is the local socket.

Causes, most likely first

  1. The server is not running, or the host or port (3306) is wrong, so the host answers with a refusal.

    Check

    python3 scripts/port_check.py db.example.internal:3306   # from where the script runs: refused
    systemctl status mysql   # on the server (or mysqld, mariadb): inactive

    Fix

    Start the server, or use the right host and port.

  2. The server listens on 127.0.0.1 only (bind-address = 127.0.0.1, which the Debian and Ubuntu packages set) or has skip_networking on, so other hosts are refused.

    Check

    mysql -e "SHOW VARIABLES WHERE Variable_name IN ('bind_address', 'port', 'skip_networking', 'socket')"   # on the server: what it actually uses
    ss -ltnp | grep 3306   # on the server: 127.0.0.1:3306 means local connections only

    Fix

    Set bind-address to the private interface, restart, and allow only the application's hosts in the firewall or security group.

  3. A firewall or security group drops the traffic, so nothing answers and the client times out.

    Check

    python3 scripts/port_check.py db.example.internal:3306   # timed out, not refused

    Fix

    Allow port 3306 from the application's hosts only.

  4. The host name does not resolve from where the script runs.

    Check

    getent hosts db.example.internal   # no output: DNS, not MySQL

    Fix

    Use a name that resolves where the script runs.

  5. For localhost (2002), the mysql client and mysqlclient use the Unix socket, and its path differs from the server's or the server is down. PyMySQL uses TCP unless you pass unix_socket=.

    Check

    mysql -e 'SHOW VARIABLES LIKE "socket"'   # on the server: the path it uses

    Fix

    Connect to 127.0.0.1, or pass the server's socket path (unix_socket=).

Prevent

A connect timeout on every connection (connect_timeout=5), a port check after each deploy, and the database host and port in configuration per environment.

Now learn it: take Level 07 · HTTP and APIs. Interactive version: #fix/mysqlconnect.

MySQL 1049: Unknown database

pymysql.err.OperationalError: (1049, "Unknown database 'shop_prod'")

What it means

The login worked, but the database named in the connection settings does not exist on this server. The server says 1049 only when the account has a privilege that covers that name; otherwise it answers 1044 Access denied for user 'app'@'%' to database 'shop_prod', whether or not the database exists.

Causes, most likely first

  1. A typo, the name for another environment (shop_prod and shop), or a space or newline left in the variable.

    Check

    python3 -c "import os; print(repr(os.environ.get('DB_NAME')))"   # the exact name the script gets: repr shows case, spaces and newlines

    Fix

    Use the right name for this environment.

  2. The database was never created on this server: provisioning or the first migration has not run.

    Check

    SHOW DATABASES LIKE 'shop%';   # as an administrator: not there, or there under another name

    Fix

    Create it: CREATE DATABASE shop CHARACTER SET utf8mb4; and grant the application user access to it.

  3. The host or port points at another server than you think, such as another environment's.

    Check

    mysql -h db.example.internal -u app -p -e 'SELECT @@hostname, @@port; SHOW DATABASES'   # which server you reached, and the databases this user may see

    Fix

    Fix the host and port in the configuration for this environment.

  4. Database names are case-sensitive on Linux servers by default, so Shop is not shop.

    Check

    SHOW DATABASES;   # the exact case

    Fix

    Use the name exactly as it is listed.

Prevent

Create databases in provisioning code, and keep the database name in configuration per environment.

Now learn it: take Level 05 · Command-line tools. Interactive version: #fix/mysqlnodb.

MySQL 1040: Too many connections

pymysql.err.OperationalError: (1040, 'Too many connections')

What it means

The server already has max_connections client connections open and refuses new ones, usually for every application sharing it. The server keeps one extra connection for an account with SUPER or CONNECTION_ADMIN, so an administrator can still get in to look.

Causes, most likely first

  1. Connections opened per request, thread or loop and never closed, so they pile up until the limit.

    Check

    SELECT user, SUBSTRING_INDEX(host, ':', 1) AS client, command, COUNT(*) FROM information_schema.processlist GROUP BY 1, 2, 3 ORDER BY 4 DESC;   # many Sleep from one client: a leak

    Fix

    Close every connection in a finally block (or contextlib.closing).

  2. Pools in many processes and workers add up to more than max_connections (default 151).

    Check

    SHOW VARIABLES LIKE 'max_connections';
    SHOW GLOBAL STATUS LIKE 'Threads_connected';   # at the limit, spread over many clients

    Fix

    Use one small pool per process and size it so processes × pool size stays well under max_connections.

  3. Slow queries or a lock wait: every request holds its connection longer, so connections pile up although nothing leaks.

    Check

    SELECT id, user, time, state FROM information_schema.processlist WHERE command = 'Query' ORDER BY time DESC LIMIT 10;   # many long-running queries, or waiting for a lock

    Fix

    Fix the slow query or the lock that holds everyone up. Raising max_connections only buys time: each connection costs server memory.

  4. Idle connections held for hours, kept until wait_timeout (8 hours by default).

    Check

    The processlist above: many Sleep with a large time

    Fix

    Return connections to the pool promptly, and recycle idle ones.

Prevent

Alert on Threads_connected against max_connections, and size concurrency from the scarcest shared resource.

Now learn it: break and fix it yourself in Too many clients already, then take Level 09 · Concurrency. Interactive version: #fix/mysqltoomany.

MySQL 2006 / 2013: server has gone away, lost connection during query

pymysql.err.OperationalError: (2013, 'Lost connection to MySQL server during query')

What it means

The connection to the server was closed underneath the client: 2006 when it found out before sending, 2013 when the connection dropped during a query. It typically hits the first query after a quiet period, or one very large query.

Causes, most likely first

  1. The connection sat idle longer than the server's wait_timeout (8 hours by default) or something on the path: a proxy, a load balancer or NAT (an AWS NAT gateway drops idle connections after 350 seconds). The server or the middlebox closed it, and the next query finds it gone.

    Check

    SELECT @@global.wait_timeout;   # what a script's connection gets; in the mysql shell, the session's wait_timeout comes from interactive_timeout instead
    Compare the idle time before the failing query with these timeouts

    Fix

    Do not keep connections idle across long waits: connect per run, or check before use (in PyMySQL, conn.ping() and a new connection if it raises; its reconnect=True is deprecated in recent versions; in SQLAlchemy, pool_pre_ping=True and pool_recycle below the shortest idle timeout on the path).

  2. A query or row larger than max_allowed_packet (64 MB by default in MySQL 8.0, 16 MB in MariaDB): the server drops the connection instead of reading it.

    Check

    SELECT @@global.max_allowed_packet;   # smaller than the statement or row you send

    Fix

    Send large data in batches, or raise max_allowed_packet on the server.

  3. The server restarted, failed over, or was killed (the OOM killer), which closes every connection.

    Check

    SHOW GLOBAL STATUS LIKE 'Uptime';   # seconds since the server started: shorter than the job has run means it restarted
    journalctl -u mysql --since '1 hour ago'   # on the server (or mysqld, mariadb): a restart, a crash, the OOM killer

    Fix

    Find why it restarted; retry work that is safe to repeat.

  4. The client reads a large result more slowly than net_write_timeout (60 seconds) allows, so the server gives up on it.

    Check

    SELECT @@global.net_write_timeout;

    Fix

    Read results faster (stream them with an unbuffered cursor and process in batches), or fetch less at once.

Prevent

Pools that test connections before use, and a retry for work that is safe to repeat.

Now learn it: take Level 07 · HTTP and APIs. Interactive version: #fix/mysqlgone.

PostgreSQL: password authentication failed for user

psycopg2.OperationalError: connection to server at "db.example.internal" (10.0.3.20), port 5432 failed: FATAL:  password authentication failed for user "app"

What it means

The server reached the password check and refused the login. PostgreSQL sends this same message for a wrong password, a role that does not exist, a role with no password and an expired password, so the client cannot tell them apart; the server's log can. fe_sendauth: no password supplied means the client sent no password at all.

Causes, most likely first

  1. The password is wrong or was rotated, or its variable is empty in this environment.

    Check

    psql "host=db.example.internal port=5432 dbname=shop user=app" -c 'SELECT current_user'   # with the password from the secret store: works, so the job gets a different one
    grep -A1 'password authentication failed' /var/log/postgresql/postgresql-*.log | tail -4   # on the server (Debian, Ubuntu; managed services have their own log): for a wrong password the DETAIL line names the pg_hba.conf line it matched

    Fix

    Give the job the current password from the environment or a secret store.

  2. The role does not exist on this server (another environment's server, or never created), has no password, or its VALID UNTIL date has passed.

    Check

    grep -A1 'password authentication failed' /var/log/postgresql/postgresql-*.log | tail -4   # DETAIL: Role "app" does not exist, has no password assigned, or has an expired password
    SELECT rolname, rolvaliduntil, rolpassword IS NULL AS no_password FROM pg_authid WHERE rolname = 'app';   # as a superuser; no row: the role does not exist here

    Fix

    Connect to the right server, or create the role, set its password, or move VALID UNTIL, as the log says.

  3. A stale PGPASSWORD in the environment wins over ~/.pgpass. ~/.pgpass is ignored when its group or others can read it (it needs mode 0600), or the job runs as another user with another HOME.

    Check

    env | grep -o '^PG[A-Z]*'   # PGPASSWORD set? Names only, not values
    ls -l ~/.pgpass   # must be -rw------- and owned by the job's user

    Fix

    Unset the stale PGPASSWORD; chmod 600 ~/.pgpass for the job's user.

  4. A password with @, : or / inside a postgresql:// URL that is not percent-encoded, so the URL is cut in the wrong place and the server gets part of it.

    Check

    Does the password contain @, :, / or %?

    Fix

    Pass connection settings as keyword arguments, or encode the password with urllib.parse.quote(password, safe="") in the URL.

  5. The server requires scram-sha-256 but the role's password was stored as MD5 before an upgrade, so it can never match.

    Check

    SELECT rolname, rolpassword LIKE 'SCRAM%' AS scram FROM pg_authid WHERE rolname = 'app';   # as a superuser: false

    Fix

    Set the role's password again (\password app in psql) with password_encryption = scram-sha-256.

Prevent

Rotate passwords through the secret store that the jobs read, and check the database login at start-up so a rotation fails loudly.

Now learn it: break and fix it yourself in Backup complete (it was not), then take Level 05 · Command-line tools. Interactive version: #fix/pgauth.

PostgreSQL: could not connect to server: Connection refused

psycopg2.OperationalError: connection to server at "db.example.internal" (10.0.3.20), port 5432 failed: Connection refused
	Is the server running on that host and accepting TCP/IP connections?

What it means

The client could not open a connection to the PostgreSQL server, so no login was tried. Connection refused comes back at once; timeout expired means nothing answered; could not translate host name is DNS; connection to server on socket ... No such file or directory is the local Unix socket. Older clients say could not connect to server: Connection refused.

Causes, most likely first

  1. The server is not running, or the host or port (5432) is wrong.

    Check

    pg_isready -h db.example.internal -p 5432   # accepting connections, rejecting connections, or no response

    Fix

    Start the server, or use the right host and port.

  2. The server listens on localhost only: listen_addresses defaults to localhost, so connections from other hosts are refused.

    Check

    psql -c 'SHOW listen_addresses'   # on the server: the value it actually uses
    ss -ltnp | grep 5432   # on the server: 127.0.0.1:5432 means local only

    Fix

    Set listen_addresses to the private interface, add a pg_hba.conf line for the application's network, restart, and allow only those hosts in the firewall.

  3. A firewall or security group drops the traffic, so nothing answers (a time-out, not a refusal).

    Check

    python3 scripts/port_check.py db.example.internal:5432   # timed out

    Fix

    Allow port 5432 from the application's hosts only.

  4. The host name does not resolve from where the script runs.

    Check

    getent hosts db.example.internal   # no output: DNS

    Fix

    Use a name that resolves there.

  5. No host given, so the client uses the local Unix socket; or localhost inside a container, which is the container itself.

    Check

    env | grep -E '^PGHOST='   # empty: the socket is used

    Fix

    Give the host explicitly; in containers, connect to the database's service name, not localhost.

Prevent

connect_timeout=5 on every connection, pg_isready in health checks, and host and port in configuration per environment.

Now learn it: take Level 07 · HTTP and APIs. Interactive version: #fix/pgconnect.

PostgreSQL: FATAL: database does not exist

psycopg2.OperationalError: connection to server at "db.example.internal" (10.0.3.20), port 5432 failed: FATAL:  database "shop_prod" does not exist

What it means

The server answered and accepted the login, but no database has the name you asked for on this server.

Causes, most likely first

  1. A typo, or the name for another environment.

    Check

    env | grep -E '^PG(DATABASE|HOST|PORT)='

    Fix

    Name the right database (dbname=shop).

  2. No database given, so the client uses one named after the user (database "app" does not exist).

    Check

    Is dbname set in the connection settings?

    Fix

    Set dbname explicitly.

  3. Case: the name in connection settings is used exactly, but SQL folds unquoted names to lower case, so CREATE DATABASE Shop made shop and dbname=Shop does not find it.

    Check

    psql -h db.example.internal -U app -d postgres -c '\l'   # the exact names

    Fix

    Use the name exactly as it is listed.

  4. It exists on another server, cluster or port.

    Check

    psql -h db.example.internal -U app -d postgres -c 'SELECT inet_server_addr(), inet_server_port()'   # which server you reached

    Fix

    Fix the host and port, or create it here: createdb -h db.example.internal -O app shop.

Prevent

Create databases in provisioning code and keep the name in configuration per environment.

Now learn it: take Level 05 · Command-line tools. Interactive version: #fix/pgnodb.

PostgreSQL: no pg_hba.conf entry for host ... no encryption (SSL required)

psycopg2.OperationalError: connection to server at "db.example.internal" (10.0.3.20), port 5432 failed: FATAL:  no pg_hba.conf entry for host "10.0.3.15", user "app", database "shop", no encryption

What it means

The server checked pg_hba.conf, its list of who may connect from where and how, and no line allows this address, user and database over this kind of connection. The end of the message says how the client connected: no encryption (older servers: SSL off) or SSL encryption. Some managed services answer SSL connection is required instead.

Causes, most likely first

  1. The server accepts this client only over TLS (hostssl lines; on Amazon RDS, the rds.force_ssl parameter), and the client connected without it (sslmode=disable, or a driver default).

    Check

    psql "host=db.example.internal dbname=shop user=app sslmode=require" -c 'SELECT ssl, version FROM pg_stat_ssl WHERE pid = pg_backend_pid()'   # works: the server needed TLS
    env | grep -E '^PGSSL'

    Fix

    Connect with TLS and check the server's certificate: psycopg2.connect(..., sslmode="verify-full", sslrootcert="/etc/ssl/certs/db-ca.pem") (sslmode=require encrypts but does not verify).

  2. There is no pg_hba.conf line for this client's address, user and database at all, or only one for another database or user.

    Check

    psql -c 'SELECT line_number, type, database, user_name, address, error FROM pg_hba_file_rules'   # on the server, as a superuser: the file as it is now (reload after a change)

    Fix

    On a server you run, add a hostssl line for the application's network and reload (SELECT pg_reload_conf()).

  3. The client's address is not what you expect (a new subnet, NAT, a container network), so a line that exists does not match.

    Check

    The host in the message: the address the server saw

    Fix

    Allow the network the client really comes from, no wider.

  4. The other way round: the client demands TLS and the server has ssl off: server does not support SSL, but SSL was required.

    Check

    psql -c 'SHOW ssl'   # on the server: off

    Fix

    Turn on TLS on the server with a certificate; do not lower the client's sslmode.

Prevent

sslmode=verify-full in every connection setting, the provider's CA bundle shipped with the application, and pg_hba.conf managed as code.

Now learn it: take Level 12 · Security for automation. Interactive version: #fix/pgssl.

PostgreSQL: sorry, too many clients already

psycopg.OperationalError: connection failed: connection to server at "10.0.3.20", port 5432 failed: FATAL:  sorry, too many clients already

What it means

The server already has max_connections connections and refuses new ones for everyone; remaining connection slots are reserved means only the slots kept for administrators are left.

Causes, most likely first

  1. Thread or process pools in which each worker opens its own connection.

    Check

    psql -c 'SELECT usename, application_name, count(*) FROM pg_stat_activity GROUP BY 1, 2 ORDER BY 3 DESC'   # as a superuser (the reserved slots let you in): which application holds them

    Fix

    A small pool per process (psycopg_pool or SQLAlchemy) shared by its threads, and PgBouncer in front when many processes connect.

  2. Connections that are never closed, or left idle in a transaction.

    Check

    psql -c 'SELECT state, wait_event_type, count(*) FROM pg_stat_activity GROUP BY 1, 2 ORDER BY 3 DESC'   # many idle: leaks or oversized pools; idle in transaction: transactions left open

    Fix

    Close connections with with; set idle_in_transaction_session_timeout so forgotten transactions end.

  3. More processes × pool size than max_connections (default 100).

    Check

    psql -c 'SHOW max_connections'

    Fix

    Size pools from the limit, leaving room for other applications and administrators. Raising max_connections needs a restart and costs memory per connection.

  4. Slow queries or lock waits: each request holds its connection longer, so connections pile up.

    Check

    The state query above: many active, waiting on Lock

    Fix

    Fix the query or lock that holds everyone up.

Prevent

Alert on connections against max_connections, and leave room for the other applications and for administrators.

Now learn it: break and fix it yourself in Too many clients already, then take Level 09 · Concurrency. Interactive version: #fix/pgtoomany.

sqlite3.OperationalError: database is locked

sqlite3.OperationalError: database is locked

What it means

SQLite allows one writer at a time. This connection needed the write lock, another connection held it, and the wait (5 seconds by default) ran out. Failing at once, without the wait, points at a transaction that read first and then tried to write.

Causes, most likely first

  1. Another connection holds a write transaction longer than this one's timeout: a transaction never committed, or a long batch.

    Check

    fuser -v jobs.db   # or: lsof jobs.db: which processes have it open
    ls -l jobs.db*   # a -journal or -wal file next to it

    Fix

    Commit promptly (with conn: commits or rolls back the transaction; it does not close the connection); allow a longer wait with sqlite3.connect(path, timeout=30).

  2. Overlapping cron runs, or several threads or processes writing at once.

    Check

    pgrep -af job.py   # more than one copy

    Fix

    Stop runs from overlapping (flock -n /run/lock/job.lock python3 job.py) and keep one writer at a time.

  3. A transaction that began by reading and then writes cannot wait for the lock (in WAL mode, its snapshot is out of date), so it fails at once.

    Check

    sqlite3 jobs.db 'PRAGMA journal_mode;'   # wal
    Does it fail immediately, not after the timeout?

    Fix

    Start write transactions with BEGIN IMMEDIATE (isolation_level="IMMEDIATE") so they wait for the lock up front.

  4. A database browser left open on the file holds a lock.

    Check

    fuser -v jobs.db   # a GUI tool or sqlite3 shell

    Fix

    Close it; switch to WAL (PRAGMA journal_mode=WAL) so readers do not block the writer.

  5. The file is on a network file system (NFS, SMB), where locking is unreliable.

    Check

    findmnt -T jobs.db   # FSTYPE nfs or cifs

    Fix

    Keep SQLite on local disk, or move to a database server.

Prevent

Keep SQLite on local disk, stop cron runs from overlapping (flock -n /run/lock/job.lock python3 job.py), and move to a database server when many processes write.

Now learn it: take Level 09 · Concurrency. Interactive version: #fix/sqlitelocked.

SQLAlchemy: QueuePool limit of size 5 overflow 10 reached, connection timed out

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00 (Background on this error at: https://sqlalche.me/e/20/3o7r)

What it means

Every connection in SQLAlchemy's pool (pool_size 5 plus max_overflow 10) was checked out, and none came back within pool_timeout (30 seconds). The database itself is usually fine; the application holds on to connections.

Causes, most likely first

  1. Connections or sessions are checked out and never returned: a Session not closed, engine.connect() without with.

    Check

    print(engine.pool.status())   # checked out at the limit while the database is idle: connections are not returned
    create_engine(url, echo_pool="debug")   # logs every checkout and return

    Fix

    Return connections: with Session(engine) as session: and with engine.connect() as conn:.

  2. More threads or tasks than pool_size + max_overflow need a connection at once.

    Check

    Count worker threads against 15

    Fix

    Size pool_size and max_overflow to the real concurrency, within the database's connection limit.

  3. Slow queries or long transactions hold connections, or a session stays open across other slow work (an HTTP call, a sleep).

    Check

    echo_pool="debug" above: long gaps between checkout and return

    Fix

    Keep sessions out of slow non-database work, and fix slow queries.

  4. A new engine created per request, each with its own pool.

    Check

    grep -n 'create_engine' *.py   # called inside a function that runs per request

    Fix

    Create one engine per process.

Prevent

One engine per process, sessions scoped to a unit of work, and pool usage in your metrics.

Now learn it: break and fix it yourself in Too many clients already, then take Level 09 · Concurrency. Interactive version: #fix/sqlapool.

No module named 'MySQLdb' or 'psycopg2': database driver not installed

ModuleNotFoundError: No module named 'MySQLdb'

What it means

The database driver module is not installed for the Python that runs the script. It is often raised from SQLAlchemy's create_engine: mysql:// uses mysqlclient (imported as MySQLdb), and postgresql:// uses psycopg2 up to SQLAlchemy 2.0 and psycopg (version 3) from 2.1, unless the URL names a driver.

Causes, most likely first

  1. The driver is not installed in the interpreter that runs the script.

    Check

    python3 -c 'import sys; print(sys.executable)'
    python3 -m pip show sqlalchemy mysqlclient PyMySQL psycopg2-binary psycopg   # which are missing

    Fix

    Install the driver into the environment that runs the script (.venv/bin/pip install PyMySQL or "psycopg[binary]").

  2. The URL selects another driver than the one you installed: no driver in the URL means the default, and an upgrade to SQLAlchemy 2.1 changed the PostgreSQL default to psycopg.

    Check

    python3 -c "import os; from sqlalchemy.engine import make_url; u = make_url(os.environ['DATABASE_URL']); print(u.drivername, u.get_dialect().driver)"   # the driver the URL selects; the password is not printed

    Fix

    Name the driver in the URL: mysql+pymysql://... or postgresql+psycopg://....

  3. The import name differs from the package: MySQLdb is mysqlclient, mysql.connector is mysql-connector-python, psycopg is psycopg (version 3).

    Check

    The module name in the message

    Fix

    Install the package that provides that module.

  4. pip install psycopg2 builds from source and needs pg_config ("pg_config executable not found"); mysqlclient needs the MySQL client headers and a compiler.

    Check

    The pip install output

    Fix

    Use a binary wheel (psycopg2-binary, psycopg[binary]) or PyMySQL, or install the build dependencies.

  5. A postgres:// URL: SQLAlchemy 1.4 and later only know postgresql:// ("Can't load plugin: sqlalchemy.dialects:postgres").

    Check

    The URL's scheme

    Fix

    Rewrite postgres:// to postgresql://.

Prevent

The driver pinned in requirements.txt next to SQLAlchemy, a driver named in every URL, and binary wheels where you cannot build.

Now learn it: break and fix it yourself in No matching distribution for yaml, then take Level 10 · Packaging and environments. Interactive version: #fix/dbdriver.

CalledProcessError: returned non-zero exit status

subprocess.CalledProcessError: Command '['pg_dump', '--format=custom', '--file', '/backups/shop-2026-10-30.dump', 'shop']' returned non-zero exit status 1.

What it means

A command the script ran exited with a non-zero status, and check=True turned that into an exception. This is the good outcome: the failure was not ignored. The real reason is in the command's own error output.

Cause

  1. The command itself failed; its stderr, printed above the traceback or captured, says why.

    Check

    Run the exact command from the error in a shell
    subprocess.run(..., capture_output=True, text=True) and print .stderr

    Fix

    Fix the command's cause; catch CalledProcessError only where you can do something useful, and exit non-zero otherwise.

Prevent

check=True on every subprocess.run, so failures are never silent.

Now learn it: break and fix it yourself in Backup complete (it was not), Lost exceptions in futures, then take Level 04 · Processes and subprocess. Interactive version: #fix/calledprocess.

FileNotFoundError: No such file or directory: 'kubectl'

FileNotFoundError: [Errno 2] No such file or directory: 'kubectl'

What it means

subprocess tried to start a program called kubectl and the operating system found no such file. Without a shell, the program is looked up in PATH of this process, which under cron is just /usr/bin:/bin.

Causes, most likely first

  1. The program is not on PATH for this process (cron: /usr/bin:/bin).

    Check

    env -i PATH=/usr/bin:/bin sh -c 'command -v kubectl'   # no output: cron cannot find it

    Fix

    Use the absolute path (/usr/local/bin/kubectl), or set PATH where the job is defined.

  2. A whole command line was passed as one string without a shell, so Python looks for a program named 'kubectl get pods'.

    Check

    print(args) before the call   # one string instead of a list

    Fix

    Pass a list of arguments: ['kubectl', 'get', 'pods'].

Prevent

Absolute paths to tools in scheduled scripts.

Now learn it: break and fix it yourself in No such file: 'kubectl', Shell injection, then take Level 04 · Processes and subprocess. Interactive version: #fix/subprocessnotfound.

subprocess.TimeoutExpired

subprocess.TimeoutExpired: Command '['ssh', 'nfs-01', 'df', '-h', '/']' timed out after 30 seconds

What it means

The command ran longer than the timeout you gave it, so Python killed it and raised. The command never finished, so its result is unknown.

Causes, most likely first

  1. A stuck remote host or mount (an NFS server that does not answer).

    Check

    Run the command by hand with `timeout 30`

    Fix

    Catch subprocess.TimeoutExpired per host, report it, and carry on with the others.

  2. The command is waiting for input or a password prompt that nobody will answer.

    Check

    ssh -o BatchMode=yes host true   # fails instead of prompting

    Fix

    Use BatchMode for ssh and pass input explicitly (stdin=subprocess.DEVNULL).

Prevent

A timeout on every command that touches the network; BatchMode for ssh.

Now learn it: break and fix it yourself in One host hangs the whole run, then take Level 04 · Processes and subprocess. Interactive version: #fix/timeoutexpired.

RuntimeWarning: coroutine was never awaited

RuntimeWarning: coroutine 'sleep' was never awaited

What it means

A coroutine was created and then thrown away without being run. Calling an async function only builds the coroutine; nothing happens until it is awaited or scheduled.

Cause

  1. The call is missing await.

    Check

    python3 -X dev script.py   # shows where the coroutine was created

    Fix

    Add await (await asyncio.sleep(1)), or schedule it with asyncio.create_task and keep a reference to the task.

Prevent

Run asyncio code with -X dev in tests; it reports blocking calls and unawaited coroutines.

Now learn it: break and fix it yourself in time.sleep inside async code, then take Level 09 · Concurrency. Interactive version: #fix/neverawaited.

ModuleNotFoundError: No module named 'distutils'

ModuleNotFoundError: No module named 'distutils'

What it means

The script imports a standard-library module that no longer exists in this Python. distutils was removed in 3.12 (PEP 632); many old modules were removed in 3.12 and 3.13 (PEP 594).

Causes, most likely first

  1. The OS or the image moved to Python 3.12 or later, which no longer has distutils, imp, asynchat, asyncore or smtpd.

    Check

    python3 -V   # 3.12 or later

    Fix

    Replace the module: version comparison with tuples of ints or packaging.version; imp with importlib.

  2. On 3.13 also cgi, crypt, telnetlib, pipes and others were removed.

    Check

    python3 -W error::DeprecationWarning script.py   # on the old version: what will break

    Fix

    pipes.quote becomes shlex.quote; cgi parsing moves to email.message or a framework.

Prevent

Run tests with deprecation warnings as errors before upgrading.

Now learn it: break and fix it yourself in No module named 'distutils', then take Level 13 · Boss level: the night shift. Interactive version: #fix/distutilsgone.

NoCredentialsError: Unable to locate credentials

botocore.exceptions.NoCredentialsError: Unable to locate credentials

What it means

boto3 went through its whole credential chain (environment variables, ~/.aws files, SSO, container and instance roles) and found nothing, so it did not send any request. Typically it works on your laptop and fails in cron, a container, CI or on a new server.

Causes, most likely first

  1. Nothing in the credential chain is set for this process: no AWS_* variables, no profile in ~/.aws, no SSO session, no role.

    Check

    aws configure list   # where each setting comes from; keys shown masked; <not set> everywhere: this cause
    env | grep -o '^AWS_[A-Z_]*'   # which AWS variables are set, without their values

    Fix

    Give the process an identity the standard way: a role on the instance, task or pod (best), --profile or AWS_PROFILE, or aws sso login. Never paste keys into the script.

  2. cron, systemd and sudo run with another HOME or a clean environment, so ~/.aws and your exported variables are not there.

    Check

    sudo -u <job-user> env HOME=<job-home> aws sts get-caller-identity   # fails as the job's user

    Fix

    Configure the profile for the job's user, or set AWS_PROFILE and HOME in the job's definition.

  3. A container on EC2 cannot reach the instance metadata service: IMDSv2 with a hop limit of 1 stops one network hop short of the container.

    Check

    aws ec2 describe-instances --instance-ids "$INSTANCE_ID" --query 'Reservations[].Instances[].MetadataOptions'   # HttpPutResponseHopLimit 1

    Fix

    Raise the IMDSv2 hop limit to 2, or better give the container its own role.

  4. The task or pod has no role attached (ECS task role, EKS IRSA or Pod Identity).

    Check

    aws ecs describe-task-definition --task-definition <name> --query 'taskDefinition.taskRoleArn'   # null: no task role

    Fix

    Attach a role with the minimum policy the script needs.

Prevent

Run automation with roles, not long-lived keys; test a new job with the same user and environment cron will use; make credential errors exit 2 with one clear line (awskit.run).

Now learn it: take Level 14 · boto3: credentials, regions and errors. Interactive version: #fix/awsnocreds.

NoRegionError: You must specify a region.

botocore.exceptions.NoRegionError: You must specify a region.

What it means

boto3 could not work out which AWS region to talk to, so it could not even build the client. Regions come from the code, AWS_REGION or AWS_DEFAULT_REGION, or the profile's config.

Causes, most likely first

  1. No region_name in the code, and no AWS_REGION or AWS_DEFAULT_REGION in this environment.

    Check

    env | grep -E '^AWS_(DEFAULT_)?REGION='
    python3 -c 'import boto3; print(boto3.Session().region_name)'   # None: this cause

    Fix

    Pass the region explicitly (boto3.client("ec2", region_name=args.region)), or set AWS_REGION where the job is defined.

  2. The region was exported in ~/.bashrc, which cron does not read.

    Check

    grep -n AWS_ ~/.bashrc ~/.profile

    Fix

    Set the region in the crontab or unit file, or in the profile's config.

  3. The profile in ~/.aws/config has no region line, or a different profile is used.

    Check

    aws configure get region   # empty for this profile

    Fix

    Add region = ... to the profile, or select the right profile.

Prevent

Give every script a --region option (and --all-regions), and fail with exit 2 and a clear message when none is set.

Now learn it: break and fix it yourself in NoRegionError under cron, then take Level 14 · boto3: credentials, regions and errors. Interactive version: #fix/awsnoregion.

ExpiredToken: The security token included in the request is expired

botocore.exceptions.ClientError: An error occurred (ExpiredToken) when calling the GetCallerIdentity operation: The security token included in the request is expired

What it means

The request was signed with temporary credentials whose session has ended. The identity is fine; its time ran out. SSO users may see Error when retrieving token from sso: Token has expired and refresh failed.

Causes, most likely first

  1. Temporary credentials (SSO, assume-role, MFA sessions) expire at the end of their session, often after an hour.

    Check

    aws sts get-caller-identity   # fails with the same error

    Fix

    Refresh: aws sso login --profile <name>, or assume the role again. Profiles that assume a role (role_arn with source_profile) are refreshed by botocore automatically.

  2. Old AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN values left in the environment win over the refreshed profile.

    Check

    env | grep -o '^AWS_[A-Z_]*'   # AWS_SESSION_TOKEN set
    aws configure list   # Type env for the keys

    Fix

    Unset the stale AWS_* variables so the profile is used.

  3. The job runs longer than the role's maximum session duration.

    Check

    aws iam get-role --role-name <role> --query Role.MaxSessionDuration

    Fix

    Use a profile or instance role that botocore refreshes, split the job, or raise the role's maximum session duration.

Prevent

Use profiles or instance roles instead of exported keys, so botocore refreshes them; split very long jobs or raise the role's maximum session duration.

Now learn it: take Level 14 · boto3: credentials, regions and errors. Interactive version: #fix/awsexpired.

InvalidClientTokenId or SignatureDoesNotMatch

botocore.exceptions.ClientError: An error occurred (InvalidClientTokenId) when calling the GetCallerIdentity operation: The security token included in the request is invalid.

What it means

AWS does not recognise the access key (InvalidClientTokenId), or the signature made with the secret key does not match (SignatureDoesNotMatch). Every call fails at once, often right after a key rotation, a copy and paste, or switching accounts.

Causes, most likely first

  1. InvalidClientTokenId: the access key was deleted or deactivated, belongs to another partition, or is a temporary (ASIA...) key used without its session token.

    Check

    aws configure list   # which key (masked) and where it comes from
    aws iam list-access-keys --user-name "$USER_NAME"   # from a working identity: is the key Active?

    Fix

    Replace a deleted key; pass the session token with temporary keys.

  2. SignatureDoesNotMatch: the secret key is wrong, or was copied with a space or a line break.

    Check

    aws configure list   # the source of the secret key

    Fix

    Set the secret key again from its source, without whitespace.

  3. Stale AWS_* variables in the environment override the profile you meant.

    Check

    env | grep -o '^AWS_[A-Z_]*'

    Fix

    Unset the AWS_* variables or fix the profile: use one source of credentials.

  4. The region is not enabled for the account (EC2 answers AuthFailure); a skewed clock gives "Signature expired" instead.

    Check

    aws account list-regions --region-opt-status-contains ENABLED ENABLED_BY_DEFAULT --query 'Regions[].RegionName'
    date -u   # compare with the real time

    Fix

    Enable the region or use another; sync the clock (chrony or systemd-timesyncd).

Prevent

Prefer roles and SSO to static keys; when a key must exist, rotate it with a check (get-caller-identity) before deleting the old one.

Now learn it: take Level 14 · boto3: credentials, regions and errors. Interactive version: #fix/awsbadkey.

AccessDenied or UnauthorizedOperation

botocore.exceptions.ClientError: An error occurred (UnauthorizedOperation) when calling the StopInstances operation: You are not authorized to perform this operation. User: arn:aws:sts::111122223333:assumed-role/ops-scheduler/cron is not authorized to perform: ec2:StopInstances on resource: arn:aws:ec2:eu-west-1:111122223333:instance/i-0abc123 because no identity-based policy allows the ec2:StopInstances action. Encoded authorization failure message: ...

What it means

AWS knows who you are (the credentials work), but a policy does not allow this action on this resource. The message names the identity, the action and the resource, and often which kind of policy refused it.

Causes, most likely first

  1. The identity's policies do not allow this action, or not on this resource or under this condition (a tag, a region).

    Check

    aws sts get-caller-identity   # the identity actually used
    aws iam simulate-principal-policy --policy-source-arn "$ROLE_ARN" --action-names ec2:StopInstances   # implicitDeny: no policy allows it

    Fix

    Grant exactly the missing action on the resources it needs (each script's header lists its minimum policy).

  2. A service control policy, a permission boundary or a session policy denies it, even though the identity's policy allows it.

    Check

    aws sts decode-authorization-message --encoded-message "$MSG" --query DecodedMessage --output text   # EC2's encoded message names the denying policy type

    Fix

    Change the SCP or boundary with its owner, or run the job in an account where it is allowed.

  3. A resource policy denies it: an S3 bucket policy, a KMS key policy (encrypted volumes and buckets need key permissions too).

    Check

    CloudTrail: the event's errorCode and errorMessage

    Fix

    Fix the bucket or key policy, or grant the kms: actions the operation needs.

  4. A different identity is used than you think.

    Check

    aws sts get-caller-identity   # another role or account than expected

    Fix

    Select the right profile or role.

Prevent

Start from the documented least-privilege policy, dry-run (DryRun=True) before the run that matters, and handle one denied region without stopping the rest.

Now learn it: break and fix it yourself in A clean-up with no dry run, then take Level 14 · boto3: credentials, regions and errors. Interactive version: #fix/awsdenied.

Throttling: Rate exceeded (reached max retries)

botocore.exceptions.ClientError: An error occurred (Throttling) when calling the GetMetricStatistics operation (reached max retries: 4): Rate exceeded

What it means

AWS rate-limited the calls, and the client gave up after its retries (4 here). Limits are per account, region and API, shared by every job using them. EC2 calls it RequestLimitExceeded, DynamoDB ProvisionedThroughputExceededException, S3 SlowDown.

Causes, most likely first

  1. Too many requests per second for the account, region and API.

    Check

    CloudTrail: events with errorCode Throttling or RequestLimitExceeded at that time   # other jobs too?
    aws service-quotas list-service-quotas --service-code ec2 --query 'Quotas[].QuotaName' | head

    Fix

    Fewer and bigger calls (paginators, filters, batch APIs), and jobs spread out in time.

  2. The default (legacy) retry mode gives up after four retries.

    Check

    grep -c 'reached max retries' job.log

    Fix

    Create the client with Config(retries={"mode": "adaptive", "max_attempts": 10}): exponential backoff with jitter, and adaptive mode also slows the client down when throttled.

  3. Many threads each retrying on their own make the burst worse.

    Check

    How many threads call the API at once?

    Fix

    Share one client between threads and keep the pool small.

Prevent

Retries configured on every client, fewer and bigger calls (paginators, filters, batch APIs), and jobs spread out in time instead of all at minute 0.

Now learn it: break and fix it yourself in Throttling: reached max retries, then take Level 15 · Inventory and audits at scale. Interactive version: #fix/awsthrottle.

EndpointConnectionError: Could not connect to the endpoint URL

botocore.exceptions.EndpointConnectionError: Could not connect to the endpoint URL: "https://ec2.eu-centrl-1.amazonaws.com/"

What it means

botocore could not open a connection to the AWS endpoint at all, so there is no AWS error code. The URL in the message shows the host it tried, which includes the region.

Causes, most likely first

  1. A misspelt or non-existent region name, so the host name does not resolve.

    Check

    python3 -c "import socket; print(socket.getaddrinfo('ec2.eu-west-1.amazonaws.com', 443)[0][4])"   # works for the right region

    Fix

    Correct the region.

  2. The server has no route to AWS: a private subnet with no NAT gateway and no VPC endpoint for that service.

    Check

    curl -sS -o /dev/null -w '%{http_code}\n' https://sts.eu-west-1.amazonaws.com/   # times out from this host

    Fix

    Add a NAT gateway or interface VPC endpoints for the services the job calls.

  3. A proxy is required (HTTPS_PROXY) or is set but unreachable; a firewall blocks port 443.

    Check

    env | grep -i -o '^[a-z_]*proxy'

    Fix

    Set HTTPS_PROXY (and NO_PROXY for 169.254.169.254) where a proxy is required.

  4. The service is not offered in that region.

    Check

    The AWS regional services list for that service

    Fix

    Use a region that has the service.

Prevent

Validate --region against describe_regions, set connect and read timeouts in Config, and test new subnets with a one-line aws sts get-caller-identity.

Now learn it: take Level 14 · boto3: credentials, regions and errors. Interactive version: #fix/awsendpoint.

ParamValidationError: Parameter validation failed

botocore.exceptions.ParamValidationError: Parameter validation failed:
Invalid type for parameter MaxResults, value: 50, type: <class 'str'>, valid types: <class 'int'>

What it means

botocore checked the request against the API's model before sending it, and a parameter is the wrong type, missing, or unknown. Nothing reached AWS, so nothing is in CloudTrail.

Causes, most likely first

  1. A value from argparse or the environment is still a string where the API wants a number or a list.

    Check

    The message: `Invalid type for parameter MaxResults, value: 50, type: <class 'str'>`

    Fix

    Convert values once where you read them (type=int in argparse), and pass lists where the API takes lists.

  2. A required parameter is missing: Missing required parameter in input: "Bucket".

    Check

    aws ec2 stop-instances help   # required parameters

    Fix

    Pass it.

  3. A parameter name in Python style instead of the API's: Unknown parameter in input: "instance_ids", must be one of: InstanceIds, ....

    Check

    python3 -c "import boto3; m = boto3.client('ec2', region_name='eu-west-1').meta.service_model; print(list(m.operation_model('StopInstances').input_shape.members))"   # the exact names

    Fix

    Use the API's exact parameter names (InstanceIds, not instance_ids).

Prevent

Unit tests with botocore's Stubber: it validates every request against the API model, without the network.

Now learn it: take Level 15 · Inventory and audits at scale. Interactive version: #fix/awsparam.

DryRunOperation: Request would have succeeded, but DryRun flag is set.

botocore.exceptions.ClientError: An error occurred (DryRunOperation) when calling the StopInstances operation: Request would have succeeded, but DryRun flag is set.

What it means

EC2 checked the call with DryRun=True and says it would have succeeded. EC2 reports every dry run as an error code, so boto3 raises ClientError even for the good answer.

Cause

  1. The script calls with DryRun=True but does not catch ClientError; EC2 answers DryRunOperation when the call would succeed and UnauthorizedOperation when it would be refused.

    Check

    aws ec2 stop-instances --instance-ids "$ID" --dry-run   # the CLI prints the same message

    Fix

    Catch ClientError and look at e.response["Error"]["Code"]: DryRunOperation means it would succeed (report "would stop"); re-raise or report anything else.

Prevent

Keep one helper for it (awskit.dry_run), make the dry run the default and real changes need --apply.

Now learn it: break and fix it yourself in A clean-up with no dry run, then take Level 16 · Changing AWS safely. Interactive version: #fix/dryrunop.

Only the first page: exactly 1,000 S3 objects, or one page of a describe call

checked 1000 objects, 12.7 GB   # the bucket holds 48,213

What it means

No error is raised: the list or describe call returned its first page and the script stopped there. A suspiciously round count (1,000 objects, 50 or 100 items) is the sign.

Causes, most likely first

  1. List and describe calls return one page at a time (list_objects_v2 at most 1,000 keys), and the script never follows NextToken, NextContinuationToken or Marker.

    Check

    aws s3api list-objects-v2 --bucket "$BUCKET" --prefix orders/ --query 'length(Contents)'   # the CLI pages for you: a bigger number
    python3 -c "import boto3; print(boto3.client('s3').can_paginate('list_objects_v2'))"

    Fix

    Use a paginator for every list call: for page in client.get_paginator("list_objects_v2").paginate(Bucket=b): ..., or .search("Contents[]"). Note page.get("Contents", []): an empty page has no Contents key.

  2. Asking for a bigger page only works up to the API's cap.

    Check

    The API reference's maximum for MaxKeys or MaxResults

    Fix

    Paginate instead of raising the page size.

Prevent

Paginate by default, even where today's data fits in one page; test with Stubber responses that carry a NextToken.

Now learn it: break and fix it yourself in Exactly 1,000 objects, then take Level 15 · Inventory and audits at scale. Interactive version: #fix/awspaging.

WaiterError: Waiter InstanceStopped failed: Max attempts exceeded

botocore.exceptions.WaiterError: Waiter InstanceStopped failed: Max attempts exceeded

What it means

The waiter polled the resource its maximum number of times and it never reached the state you waited for. Waiter encountered a terminal failure state means the resource went somewhere it will never come back from.

Causes, most likely first

  1. The resource takes longer than the waiter's default (EC2 instance waiters poll every 15 seconds, 40 times: 10 minutes).

    Check

    aws ec2 describe-instances --instance-ids "$ID" --query 'Reservations[].Instances[].[State.Name,StateTransitionReason]'   # still on its way

    Fix

    Pass WaiterConfig={"Delay": 15, "MaxAttempts": 80}.

  2. The resource is stuck (an instance stuck in stopping, a shutdown script that hangs).

    Check

    aws ec2 describe-instance-status --instance-ids "$ID" --include-all-instances

    Fix

    Look at the resource before retrying; catch WaiterError and report which resource did not get there instead of crashing the whole run.

  3. It reached a failure state: the instance was terminated while you waited for running.

    Check

    The State.Name above: terminated

    Fix

    Report it as failed; do not retry the wait.

Prevent

Wait only when the next step needs it, set WaiterConfig deliberately, and treat a timeout as a finding to report.

Now learn it: take Level 16 · Changing AWS safely. Interactive version: #fix/awswaiter.

Personal data in logs: Aadhaar, phone numbers or emails in application output

INFO verifying customer {'id': 'c-1042', 'name': 'Asha Rao', 'phone': '+91 9XXXXXXXXX', 'aadhaar': '...'}

What it means

Personal data such as Aadhaar or PAN numbers, phone numbers or emails was written to logs, traces or error reports. Logs are copied widely and kept long, so each line is a copy of the data outside its intended store.

Causes, most likely first

  1. Whole request bodies, records or exception locals are logged "for debugging".

    Check

    python3 scripts/pii_scanner.py /var/log/app/   # which files and kinds of data
    python3 scripts/pii_scanner.py --loose /var/log/app/ | tail -1   # also numbers without a context word

    Fix

    Log identifiers, not records (customer["id"]); mask at the source; redact what is already written (log_redactor.py --apply on rotated files).

  2. Logs are copied to more systems (shipping, SIEM, support tickets) and kept longer than the data itself.

    Check

    List every log store the application ships to

    Fix

    Purge the copies in every log store. Treat a leak to an outside system as a possible breach and follow the incident runbook.

  3. Nobody owns a rule for what may be logged.

    Check

    Is there a logging rule in the code review checklist?

    Fix

    Write the rule down and check it in review.

Prevent

A logging rule in code review, the scanner in CI over test output and sample logs, short log retention, and redaction in the log pipeline.

Now learn it: break and fix it yourself in Aadhaar numbers in the log, then take Level 17 · Data hygiene: personal data in logs, files and storage. Interactive version: #fix/piiinlogs.

Unencrypted backups or snapshots of personal data

FIX  arn:aws:rds:ap-south-1:111122223333:db:customers  - storage not encrypted

What it means

A database, volume, snapshot or bucket that holds personal data is not encrypted at rest, or a dump of it sits on a plain server or laptop.

Causes, most likely first

  1. The resource was created before encryption was the default, or restored from an unencrypted snapshot.

    Check

    python3 scripts/aws/pii_storage_audit.py --all-regions
    aws rds describe-db-instances --query 'DBInstances[].[DBInstanceIdentifier,StorageEncrypted]'
    aws ec2 get-ebs-encryption-by-default

    Fix

    Encrypt by copying: an encrypted copy of the snapshot (aws rds copy-db-snapshot --kms-key-id ...), restore from it, switch over, then delete the unencrypted originals. Turn on EBS encryption by default in every region.

  2. Dumps and exports are made outside the managed backup and land wherever there is space.

    Check

    python3 scripts/pii_scanner.py /srv/dumps   # personal data in plain dump files

    Fix

    Take backups only through the managed, encrypted path, and delete the stray dumps.

Prevent

Encryption by default, the storage audit in a scheduled job, and backups only through the managed, encrypted path with access logging.

Now learn it: take Level 17 · Data hygiene: personal data in logs, files and storage. Interactive version: #fix/unencryptedbackup.

No retention limit: personal data kept for ever

delete   exports/orders-2025-01.csv  (640 days old; Aadhaar number 212, Indian mobile number 4310)

What it means

Files or records with personal data are kept with no end date: nothing erases them once their purpose is served.

Causes, most likely first

  1. Data is collected for a purpose, but no job erases it when the purpose is served.

    Check

    python3 scripts/pii_retention.py /srv/exports --days 180   # what is past the period

    Fix

    Agree a retention period per kind of data with its owner, then enforce it: database deletion jobs, and pii_retention.py --apply for file shares, with the dry-run report reviewed first.

  2. Copies (exports, backups, test environments) are outside every retention rule.

    Check

    aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET"   # NoSuchLifecycleConfiguration: no rule

    Fix

    Add S3 lifecycle rules and retention to backups and every copy.

Prevent

A retention period recorded for every data store when it is created, enforced by a scheduled job whose report someone reads; erasure requests handled by the same code path.

Now learn it: take Level 17 · Data hygiene: personal data in logs, files and storage. Interactive version: #fix/noretention.

Production personal data copied into test or staging

staging-db: 1,204,331 rows with real phone numbers and PAN, readable by every developer

What it means

Real customers' data is in a test or staging environment, usually from a "quick" restore of a production backup. Test environments are less protected, so the data is exposed to more people.

Causes, most likely first

  1. Restoring production is the easiest way to get realistic data.

    Check

    python3 scripts/pii_scanner.py tests/fixtures/ seed/

    Fix

    Replace it with synthetic data or an irreversibly masked copy, then delete the copied data everywhere it went (snapshots, dumps, laptops).

  2. Test environments have weaker access control, logging and retention than production.

    Check

    python3 scripts/aws/pii_storage_audit.py --all-regions   # with test resources tagged too

    Fix

    Make production backups restorable only into production accounts, and add a masking step as the only path to test.

Prevent

A masking step in the only allowed path from production to test, the scanner in CI over fixtures, and production backups restorable only into production accounts.

Now learn it: take Level 17 · Data hygiene: personal data in logs, files and storage. Interactive version: #fix/prodtestdata.