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
-
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 namesFix
Run the script with the environment's own interpreter (
/opt/app/.venv/bin/python script.py) in the crontab, unit file or CI step. -
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 interpreterFix
Install it into the interpreter that runs the script:
python3 -m pip install requests(inside the venv), and add it to requirements.txt. -
The import name differs from the package name, so installing the name you import installs nothing useful:
import yamlcomes from PyYAML.Check
python3 -m pip show PyYAML # installed under its distribution name?Fix
Install the distribution that provides the module (
pip install PyYAMLfor yaml,opencv-pythonfor 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
-
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 yFix
Import from the library's current location, or pin the version your code was written for until you update the code.
-
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 causeFix
Rename the local file and delete its __pycache__.
-
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 chainFix
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
-
A file of yours has the library's name (requests.py, json.py, email.py), so
import requestsimports your file, which imports itself.Check
python3 -c 'import requests; print(requests.__file__)' # your folder, not site-packages: this cause ls *.pyFix
Rename the file that shadows the library, and delete its __pycache__.
-
The module and a class in it share a name: after
import datetime, now() isdatetime.datetime.now().Check
python3 -c 'import datetime; print(dir(datetime))'Fix
Call the name where it lives (
from datetime import datetime). -
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 twiceFix
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
-
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 obeysFix
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
-
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 causeFix
Use the PyPI distribution name in requirements.txt.
-
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 acceptsFix
Pin a version that exists for your Python, or use a Python version the package supports.
-
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 effectFix
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
-
cron or the CI step runs /bin/sh, and dash has no
source.Check
ls -l /bin/sh # -> dash: this causeFix
Do not activate at all in automation (see the next cause); in sh scripts,
.(dot) is the POSIX spelling of source. -
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 itFix
Call
/opt/app/.venv/bin/python script.pydirectly.
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
-
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 winsFix
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
-
Only python3 is installed, so the name
pythondoes not exist.Check
command -v python3 python # only python3 printed: this causeFix
Call python3 (or the venv's bin/python). On Debian and Ubuntu,
apt install python-is-python3adds thepythonname if you need it. -
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
-
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 RequiresFix
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
-
The body of the block is not indented further than its header.
Check
python3 -m py_compile script.py # names the lineFix
Indent the block by four spaces more than its header line.
-
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 ^IFix
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
-
A line is indented more than the statements around it.
Check
python3 -m py_compile script.py # names the lineFix
Align the line with the other statements of its block.
-
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 codeFix
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
-
Tabs and spaces are mixed in the same block.
Check
grep -nP '^\t' script.py # every line that starts with a tabFix
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
-
A closing bracket or quote is missing in a long call or literal.
Check
python3 -m py_compile script.py # names the opening bracketFix
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
-
A missing import.
Check
grep -n 'json' script.py # used, but never imported?Fix
Import the name at the top of the module.
-
A typo in the name, or a different case (
Pathandpath).Check
grep -n -i 'path' script.pyFix
Use the name exactly as it is defined.
-
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 runsFix
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
-
Assigning to a name anywhere in a function makes it local in the whole function, so
total += 1cannot read the module-level total.Check
Find every assignment to the name inside the function: each one makes it localFix
Pass the value in and return the new one. Use
globalornonlocalonly when you really mean to share it. -
The variable is assigned only in a branch or in a try block that failed, and read afterwards (often
connin 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, thenif 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
-
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 nowFix
Call it with the parameters it has now; give a new parameter a default so old callers keep working.
-
A library changed its signature in a new version.
Check
pip show <library> # which version is installedFix
Read the signature of the installed version, or pin the version your code was written for.
-
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 callFix
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
-
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 somethingFix
Check for None where you call the function (
if host is None:), report it, and continue or exit. -
A dict .get() returned None for a missing key.
Check
print(repr(d.get(key))) # None for some recordsFix
Give .get() a default, or handle the missing key explicitly.
-
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.pyFix
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
-
A default of None that is never replaced with a real value.
Check
print(repr(x)) # None at that pointFix
Create the value when it is None (
if tags is None: tags = []). -
re.match(), re.search() or .get() found nothing and returned None.
Check
print(repr(m)) # None for the input that failedFix
Check the match before using it (
if m is None: ...). -
An assignment from a method that returns None (
items = items.append(x)).Check
Read the line that sets the variableFix
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
-
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 keyFix
Use .get(key, default) for optional keys.
-
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 valuesFix
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
-
sys.argv[1] when the script was run without an argument.
Check
print(sys.argv)Fix
Use argparse, which reports a missing argument clearly.
-
line.split()[n] on an empty or truncated line.
Check
print(len(fields), repr(line)) # fewer fields than expectedFix
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
-
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 numberFix
Validate each value where you read it; skip and count bad records instead of stopping.
-
A unit or a separator in the value ("80%", "1,024").
Check
print(repr(value))Fix
Strip the unit or separator explicitly before converting.
-
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
-
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+. -
An argparse option without
type=int.Check
grep -n 'add_argument' script.pyFix
Add
type=intto 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
-
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)). -
A blank, short or comment line.
Check
grep -n -v -E '^[^,]+,[^,]+$' hosts.csv | headFix
Skip and count lines with the wrong number of fields.
-
A dict iterated without .items(), so each item is a key string.
Check
Read the for lineFix
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
-
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: naiveFix
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
-
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). -
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
-
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 namesFix
Convert first:
.isoformat()for datetimes, list() for sets, str() for paths; orjson.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
-
An unquoted value that contains
:(a URL with a port is fine,note: run at: 02:00is not).Check
python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" deploy.yaml # line and columnFix
Quote the value (
note: "run at: 02:00"). -
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 ^IFix
Indent with spaces only, aligned with the sibling keys.
-
A value that starts with a character YAML reserves (
*,&,@, a backtick,%) and is not quoted.Check
sed -n '7p' deploy.yamlFix
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
-
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.
-
Following a cycle: symlinks, parent links, a property that calls itself.
Check
find /path -type l # symlinks that point back upFix
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
-
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 messageFix
Open with the encoding the file was written in:
open(path, encoding='cp1252'). -
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
-
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 folderFix
Anchor files that ship with the script to it:
Path(__file__).resolve().parent / 'config.json'. -
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
-
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 writeFix
Build paths with pathlib (
Path('/srv/archive') / name). -
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 thereFix
Write where the job's user owns the directory, or give that user its own directory. Not chmod 777.
-
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 pathFix
Give the directory the right label (
semanage fcontextandrestorecon), neversetenforce 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
-
Files or responses are opened and never closed.
Check
ls /proc/<pid>/fd | wc -l # rising over time: a leakFix
Open files and responses in with blocks.
-
A new connection per request without a session or pool.
Check
ls -l /proc/<pid>/fd | grep -c socketFix
Reuse a requests.Session.
-
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
-
The file system is full.
Check
df -h /backups # 100% used du -xh --max-depth=1 /backups | sort -h | tail # what fills itFix
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.
-
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.
-
Deleted files are still held open by a running process, so their space is not freed.
Check
lsof +L1 # deleted files still openFix
Restart or signal the process that holds them (for a logger, reopen its log).
-
/tmp is a small tmpfs in memory.
Check
df -h /tmp # tmpfsFix
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
-
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 tracebackFix
Catch it at the top level:
except BrokenPipeError:thenos.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())andsys.exit(1), as the Python documentation for the signal module shows. -
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
-
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 processFix
Stream input line by line, process in batches, and bound caches.
-
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}' # OOMKilledFix
Reduce memory use as above, or raise the container's limit to what you measured.
-
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 sizeFix
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
-
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 listensFix
Start the service, or use the right host and port.
-
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 onlyFix
Make it listen on the right interface, and allow only the hosts that need it in the firewall.
-
A firewall rejects the traffic with a reset rather than dropping it.
Check
sudo iptables -S | grep -i reject # or nft list rulesetFix
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
-
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 443Fix
Allow the traffic from the client's address in the security group or firewall.
-
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 anyFix
Use an address that is reachable from where the script runs, or add the route or NAT gateway.
-
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 setFix
Set the proxy where the job runs.
-
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
-
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 chainFix
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. -
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 sentFix
Fix the server's chain: configure the full chain file.
-
The certificate expired or does not name the host.
Check
python3 scripts/cert_checker.py host:443 # expiry and namesFix
Renew the certificate, or connect with the name it was issued for.
-
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 CAFix
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
-
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.pyFix
Keep the timeout, catch requests.exceptions.RequestException, report the service as down, and exit non-zero; retry with backoff if it makes sense.
-
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
-
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/htmlFix
Call
resp.raise_for_status()(or check status_code) beforeresp.json(). -
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 URLFix
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
-
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 hereFix
Use a name that resolves where the script runs.
-
DNS is not configured in this container or CI runner.
Check
cat /etc/resolv.conf # nameserver linesFix
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
-
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.
-
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.
-
No pagination or caching: the script re-fetches everything each time.
Check
Count calls per runFix
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
-
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 :9100Fix
Stop the old process or choose another port.
-
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 LISTENFix
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
-
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 setFix
Give the process the current password from the environment or a secret store. In a URL, percent-encode an @, : or / in the password.
-
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 accountFix
Remove the anonymous accounts (
DROP USER ''@'localhost';, as mysql_secure_installation does) or the stale, more specific row, then check CURRENT_USER() again. -
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 messageFix
As an administrator, create an account for the application's network, for example
CREATE USER 'app'@'10.0.3.%' IDENTIFIED BY ...andGRANT 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. -
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 refusedFix
Connect the way the account was set up, or create the account for the host the server sees.
-
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 AWSAuthenticationPluginFix
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-tokenover 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
-
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): inactiveFix
Start the server, or use the right host and port.
-
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 onlyFix
Set bind-address to the private interface, restart, and allow only the application's hosts in the firewall or security group.
-
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 refusedFix
Allow port 3306 from the application's hosts only.
-
The host name does not resolve from where the script runs.
Check
getent hosts db.example.internal # no output: DNS, not MySQLFix
Use a name that resolves where the script runs.
-
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 usesFix
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
-
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 newlinesFix
Use the right name for this environment.
-
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 nameFix
Create it:
CREATE DATABASE shop CHARACTER SET utf8mb4;and grant the application user access to it. -
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 seeFix
Fix the host and port in the configuration for this environment.
-
Database names are case-sensitive on Linux servers by default, so Shop is not shop.
Check
SHOW DATABASES; # the exact caseFix
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
-
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 leakFix
Close every connection in a finally block (or
contextlib.closing). -
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 clientsFix
Use one small pool per process and size it so processes × pool size stays well under max_connections.
-
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 lockFix
Fix the slow query or the lock that holds everyone up. Raising max_connections only buys time: each connection costs server memory.
-
Idle connections held for hours, kept until
wait_timeout(8 hours by default).Check
The processlist above: many Sleep with a large timeFix
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
-
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 timeoutsFix
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; itsreconnect=Trueis deprecated in recent versions; in SQLAlchemy,pool_pre_ping=Trueandpool_recyclebelow the shortest idle timeout on the path). -
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 sendFix
Send large data in batches, or raise max_allowed_packet on the server.
-
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 killerFix
Find why it restarted; retry work that is safe to repeat.
-
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
-
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 matchedFix
Give the job the current password from the environment or a secret store.
-
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 hereFix
Connect to the right server, or create the role, set its password, or move VALID UNTIL, as the log says.
-
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 userFix
Unset the stale PGPASSWORD;
chmod 600 ~/.pgpassfor the job's user. -
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. -
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: falseFix
Set the role's password again (
\password appin 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
-
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 responseFix
Start the server, or use the right host and port.
-
The server listens on localhost only:
listen_addressesdefaults 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 onlyFix
Set
listen_addressesto the private interface, add a pg_hba.conf line for the application's network, restart, and allow only those hosts in the firewall. -
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 outFix
Allow port 5432 from the application's hosts only.
-
The host name does not resolve from where the script runs.
Check
getent hosts db.example.internal # no output: DNSFix
Use a name that resolves there.
-
No host given, so the client uses the local Unix socket; or
localhostinside a container, which is the container itself.Check
env | grep -E '^PGHOST=' # empty: the socket is usedFix
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
-
A typo, or the name for another environment.
Check
env | grep -E '^PG(DATABASE|HOST|PORT)='Fix
Name the right database (
dbname=shop). -
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.
-
Case: the name in connection settings is used exactly, but SQL folds unquoted names to lower case, so
CREATE DATABASE Shopmade shop and dbname=Shop does not find it.Check
psql -h db.example.internal -U app -d postgres -c '\l' # the exact namesFix
Use the name exactly as it is listed.
-
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 reachedFix
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
-
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). -
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()). -
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 sawFix
Allow the network the client really comes from, no wider.
-
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: offFix
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
-
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 themFix
A small pool per process (psycopg_pool or SQLAlchemy) shared by its threads, and PgBouncer in front when many processes connect.
-
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 openFix
Close connections with
with; set idle_in_transaction_session_timeout so forgotten transactions end. -
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.
-
Slow queries or lock waits: each request holds its connection longer, so connections pile up.
Check
The state query above: many active, waiting on LockFix
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
-
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 itFix
Commit promptly (
with conn:commits or rolls back the transaction; it does not close the connection); allow a longer wait withsqlite3.connect(path, timeout=30). -
Overlapping cron runs, or several threads or processes writing at once.
Check
pgrep -af job.py # more than one copyFix
Stop runs from overlapping (
flock -n /run/lock/job.lock python3 job.py) and keep one writer at a time. -
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. -
A database browser left open on the file holds a lock.
Check
fuser -v jobs.db # a GUI tool or sqlite3 shellFix
Close it; switch to WAL (
PRAGMA journal_mode=WAL) so readers do not block the writer. -
The file is on a network file system (NFS, SMB), where locking is unreliable.
Check
findmnt -T jobs.db # FSTYPE nfs or cifsFix
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
-
Connections or sessions are checked out and never returned: a Session not closed,
engine.connect()withoutwith.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 returnFix
Return connections:
with Session(engine) as session:andwith engine.connect() as conn:. -
More threads or tasks than pool_size + max_overflow need a connection at once.
Check
Count worker threads against 15Fix
Size pool_size and max_overflow to the real concurrency, within the database's connection limit.
-
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 returnFix
Keep sessions out of slow non-database work, and fix slow queries.
-
A new engine created per request, each with its own pool.
Check
grep -n 'create_engine' *.py # called inside a function that runs per requestFix
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
-
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 missingFix
Install the driver into the environment that runs the script (
.venv/bin/pip install PyMySQLor"psycopg[binary]"). -
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 printedFix
Name the driver in the URL:
mysql+pymysql://...orpostgresql+psycopg://.... -
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 messageFix
Install the package that provides that module.
-
pip install psycopg2builds from source and needs pg_config ("pg_config executable not found"); mysqlclient needs the MySQL client headers and a compiler.Check
The pip install outputFix
Use a binary wheel (psycopg2-binary,
psycopg[binary]) or PyMySQL, or install the build dependencies. -
A
postgres://URL: SQLAlchemy 1.4 and later only knowpostgresql://("Can't load plugin: sqlalchemy.dialects:postgres").Check
The URL's schemeFix
Rewrite
postgres://topostgresql://.
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
-
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 .stderrFix
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
-
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 itFix
Use the absolute path (
/usr/local/bin/kubectl), or set PATH where the job is defined. -
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 listFix
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
-
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.
-
The command is waiting for input or a password prompt that nobody will answer.
Check
ssh -o BatchMode=yes host true # fails instead of promptingFix
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
-
The call is missing await.
Check
python3 -X dev script.py # shows where the coroutine was createdFix
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
-
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 laterFix
Replace the module: version comparison with tuples of ints or packaging.version; imp with importlib.
-
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 breakFix
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
-
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 valuesFix
Give the process an identity the standard way: a role on the instance, task or pod (best),
--profileor AWS_PROFILE, oraws sso login. Never paste keys into the script. -
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 userFix
Configure the profile for the job's user, or set AWS_PROFILE and HOME in the job's definition.
-
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 1Fix
Raise the IMDSv2 hop limit to 2, or better give the container its own role.
-
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 roleFix
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
-
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 causeFix
Pass the region explicitly (
boto3.client("ec2", region_name=args.region)), or set AWS_REGION where the job is defined. -
The region was exported in ~/.bashrc, which cron does not read.
Check
grep -n AWS_ ~/.bashrc ~/.profileFix
Set the region in the crontab or unit file, or in the profile's config.
-
The profile in ~/.aws/config has no region line, or a different profile is used.
Check
aws configure get region # empty for this profileFix
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
-
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 errorFix
Refresh:
aws sso login --profile <name>, or assume the role again. Profiles that assume a role (role_arnwithsource_profile) are refreshed by botocore automatically. -
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 keysFix
Unset the stale AWS_* variables so the profile is used.
-
The job runs longer than the role's maximum session duration.
Check
aws iam get-role --role-name <role> --query Role.MaxSessionDurationFix
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
-
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.
-
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 keyFix
Set the secret key again from its source, without whitespace.
-
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.
-
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 timeFix
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
-
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 itFix
Grant exactly the missing action on the resources it needs (each script's header lists its minimum policy).
-
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 typeFix
Change the SCP or boundary with its owner, or run the job in an account where it is allowed.
-
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 errorMessageFix
Fix the bucket or key policy, or grant the kms: actions the operation needs.
-
A different identity is used than you think.
Check
aws sts get-caller-identity # another role or account than expectedFix
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
-
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' | headFix
Fewer and bigger calls (paginators, filters, batch APIs), and jobs spread out in time.
-
The default (legacy) retry mode gives up after four retries.
Check
grep -c 'reached max retries' job.logFix
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. -
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
-
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 regionFix
Correct the region.
-
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 hostFix
Add a NAT gateway or interface VPC endpoints for the services the job calls.
-
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.
-
The service is not offered in that region.
Check
The AWS regional services list for that serviceFix
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
-
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=intin argparse), and pass lists where the API takes lists. -
A required parameter is missing:
Missing required parameter in input: "Bucket".Check
aws ec2 stop-instances help # required parametersFix
Pass it.
-
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 namesFix
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
-
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 messageFix
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
-
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[]"). Notepage.get("Contents", []): an empty page has no Contents key. -
Asking for a bigger page only works up to the API's cap.
Check
The API reference's maximum for MaxKeys or MaxResultsFix
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
-
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 wayFix
Pass
WaiterConfig={"Delay": 15, "MaxAttempts": 80}. -
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-instancesFix
Look at the resource before retrying; catch WaiterError and report which resource did not get there instead of crashing the whole run.
-
It reached a failure state: the instance was terminated while you waited for running.
Check
The State.Name above: terminatedFix
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
-
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 wordFix
Log identifiers, not records (
customer["id"]); mask at the source; redact what is already written (log_redactor.py --applyon rotated files). -
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 toFix
Purge the copies in every log store. Treat a leak to an outside system as a possible breach and follow the incident runbook.
-
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
-
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-defaultFix
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. -
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 filesFix
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
-
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 periodFix
Agree a retention period per kind of data with its owner, then enforce it: database deletion jobs, and
pii_retention.py --applyfor file shares, with the dry-run report reviewed first. -
Copies (exports, backups, test environments) are outside every retention rule.
Check
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET" # NoSuchLifecycleConfiguration: no ruleFix
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
-
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).
-
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 tooFix
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.