packages/shared-skills/skills/debugging/references/runtimes/python.md
Covers CPython 3.9+, pytest, asyncio, Django, FastAPI. Setup commands, attach mechanisms, state-query patterns, gotchas, silent-failure signatures.
# Which Python will actually run the code?
which python; which python3
python --version
# Is there a project env manager in play?
ls poetry.lock uv.lock Pipfile.lock requirements*.txt .python-version 2>/dev/null
# Installed debuggers / profilers in this env?
python -c 'import pdb, sys; print("pdb", "built-in"); print("python", sys.executable)'
pip list 2>/dev/null | grep -iE '^(ipdb|pudb|debugpy|py-spy|memray|rich)\s'
# asyncio debug mode available?
python -c 'import asyncio; print(asyncio.__version__)'
Wrapper gotchas (these change how flags propagate):
poetry run python ... — args after python are fine; args before poetry run go to poetry, not pythonuv run python ... — similar; prefer uv run -- python -X dev if flags collidepipenv run — same story./manage.py <cmd> (Django) — shebang resolution; make sure it points to the right venvpytest — loads conftest.py at collection; breakpoints inside collection need pytest --pdb-trace not --pdb| Method | When to use | Command |
|---|---|---|
breakpoint() inline (Python 3.7+) | You can edit the source and restart. Most reliable. | Add breakpoint() to source. Run normally. It invokes pdb by default. |
python -m pdb <script> | No source edit desired. Breaks on entry. | python -m pdb script.py arg1 |
post-mortem pdb.pm() | Exception already happened, you want to inspect state | In an exception-caught REPL: import pdb; pdb.pm() after the exception propagates |
| debugpy (remote / IDE) | IDE attach, remote host, containerized process | python -m debugpy --listen 5678 --wait-for-client script.py then attach from VS Code / PyCharm |
ipdb or pudb over plain pdb when availablepip install ipdb, then PYTHONBREAKPOINT=ipdb.set_trace or use import ipdb; ipdb.set_trace().pip install pudb, then PYTHONBREAKPOINT=pudb.set_trace.breakpoint() globally# Use ipdb instead of pdb
export PYTHONBREAKPOINT=ipdb.set_trace
# Disable all breakpoint() calls (useful to ship without removing them)
export PYTHONBREAKPOINT=0
Journal this env var — unset at Phase 9.
At a (Pdb) or ipdb> prompt:
l list source around current line
ll list whole function
s step into
n step over (next)
r step out (return)
c continue
b list breakpoints
b <line> breakpoint at line
b <func> breakpoint at function
cl <n> clear breakpoint n
w where (backtrace)
u / d move up/down the stack
a args of current frame
p <expr> print expression
pp <expr> pretty-print
!<stmt> execute Python statement (e.g. !x = 5)
interact drop into a full Python REPL with current frame's locals
q quit (aborts the program)
interact is underused — it gives you a full IPython-esque REPL with all locals available. Faster than typing p for 20 things.
# Enter pdb on first failure
pytest --pdb
# Enter pdb at the START of each test (not on failure)
pytest --trace
# Run only the failing test, with -s to show print output
pytest --pdb -x -s path/to/test.py::test_name
# Collect-time debugging (for problems in conftest.py / fixture setup)
pytest --pdb-trace
# Disable capture for this test (so breakpoint prompt is visible)
pytest -s
Common failure: breakpoint() hangs inside a pytest test — that's because pytest captures stdout/stderr by default. Always add -s when debugging with breakpoints inside pytest.
Async is where most Python debug sessions go sideways. Know these before attaching.
breakpoint() works inside an async function, but stepping into another coroutine from pdb is awkward. Two techniques:
async def handler():
result = await some_async_fn() # add breakpoint ABOVE, not inside, when possible
breakpoint()
return result
Inside the breakpoint, to inspect a coroutine without actually advancing time:
!import asyncio
!loop = asyncio.get_event_loop()
!task = asyncio.ensure_future(some_async_fn())
# Now inspect task state, don't await it
p task
Enable before running the process:
PYTHONASYNCIODEBUG=1 python script.py
Surfaces: coroutines that were never awaited, slow callbacks, unhandled task exceptions. Always turn this on if the bug is timing- or async-related.
asyncio.gather swallows the first exceptionBy default, asyncio.gather(t1, t2) raises the first exception and cancels the rest. If you need all exceptions, use gather(..., return_exceptions=True).
async def main():
task = asyncio.create_task(broken_coroutine())
# If task raises and we never await it, the exception is eaten at gc time
await asyncio.sleep(10)
To catch these, set loop.set_exception_handler(...) or upgrade to Python 3.12+ which warns louder by default.
Listen and wait for attach:
python -m debugpy --listen 0.0.0.0:5678 --wait-for-client script.py
Attach from VS Code:
// .vscode/launch.json
{
"name": "attach",
"type": "python",
"request": "attach",
"connect": { "host": "localhost", "port": 5678 }
}
Inside the code, programmatic attach point:
import debugpy
debugpy.listen(5678)
debugpy.wait_for_client() # blocks until attached
debugpy.breakpoint() # programmatic breakpoint
Journal: the port (5678) and the listener file — unset at Phase 9.
When the problem is performance or a hang (not a crash), don't attach pdb — it alters timing. Use a sampling profiler that attaches to the running process:
# py-spy — production-safe, zero code change, works on running process
py-spy top --pid <pid> # live top-like view
py-spy record -o profile.svg --pid <pid> # flamegraph
py-spy dump --pid <pid> # stack traces of all threads right now
# memray — memory allocation tracking
memray run script.py
memray flamegraph output.bin
memray stats output.bin
py-spy dump on a stuck process is often enough to find the hung call — no breakpoints needed.
Add these to Phase 8's silent-failure check:
| Pattern | Why it's silent |
|---|---|
except Exception: pass or except: pass | Catches and discards every error including KeyboardInterrupt |
logging.exception(...) in a logger with no handlers | "Logs" but actually writes nowhere |
asyncio.create_task(coro) without storing the task | Task GC'd before completion, exception swallowed |
return x.get("key") where key is missing | Returns None silently, caller often doesn't check |
subprocess.run(..., check=False) with ignored returncode | Non-zero exit treated as success |
Django transaction.atomic() inside a broader except | Rolls back silently |
contextlib.suppress(Exception) | Explicit silencer; easy to leave wider than intended |
queue.get(block=False) with except queue.Empty: pass | Polling that silently drops the work |
# Remove breakpoint() / ipdb / pudb lines from source
git diff | grep -E '(breakpoint\(\)|import ipdb|import pudb|import pdb; pdb\.set_trace)'
# If the above has output, revert those files:
git checkout <file>
# Unset the global breakpoint override
unset PYTHONBREAKPOINT
# Kill any leftover debugpy listeners
pkill -f 'debugpy' || true
lsof -iTCP:5678 -sTCP:LISTEN -nP 2>/dev/null # confirm free