breakpoint() looks like a shortcut for import pdb; pdb.set_trace(), and in its default configuration that is all it does. The difference is that it is a hook. Since Python 3.7, the built-in calls sys.breakpointhook(), and the default hook consults the PYTHONBREAKPOINT environment variable to decide what to run. That one level of indirection means the same line of code can open pdb on your laptop, ipdb for a colleague who prefers it, a remote debugger inside a container, or nothing at all in CI — without editing the source.
Most teams use a fraction of this. Knowing the rest pays off in three situations: when you want a better debugger than pdb without changing every call site, when a forgotten breakpoint() hangs a pipeline waiting for input that never comes, and when you debug code inside a container or service where there is no terminal attached.
Prerequisites
- Python 3.11 or later,
pytest >= 8.0. - Optionally
ipdb,pudb, ordebugpy. - Basics from Interactive debugging with pdb and ipdb.
Solution
# Pick the debugger per shell, per run, or per project (.envrc, IDE run config).
export PYTHONBREAKPOINT=ipdb.set_trace
pytest tests/test_checkout.py -k discount
# pudb's full-screen UI for one run:
PYTHONBREAKPOINT=pudb.set_trace python -m app.cli reprice
# Turn every breakpoint() into a no-op — CI, containers, production images.
export PYTHONBREAKPOINT=0
# A custom hook for behaviour an environment variable cannot express:
# attach a remote debugger when running in a container, fall back to pdb locally.
import os
import sys
def _hook(*args, **kwargs):
if os.environ.get("IN_CONTAINER"):
import debugpy
debugpy.listen(("0.0.0.0", 5678))
print("waiting for debugger on :5678", file=sys.stderr)
debugpy.wait_for_client()
debugpy.breakpoint()
else:
import pdb
pdb.Pdb().set_trace(sys._getframe(1))
sys.breakpointhook = _hook
# pyproject.toml — catch leftovers before they merge.
[tool.ruff.lint]
extend-select = ["T10"] # T100: breakpoint(), pdb.set_trace and friends
Why this works
The built-in breakpoint(*args, **kwargs) does nothing except call sys.breakpointhook(*args, **kwargs). The default hook, sys.__breakpointhook__, reads PYTHONBREAKPOINT each time it runs. An empty or missing value means pdb.set_trace. The value 0 means return immediately. Any other value is treated as a dotted import path; the hook imports the module, looks up the attribute and calls it with the same arguments. If the import fails, it issues a RuntimeWarning and continues without stopping, rather than crashing the program.
Because the variable is read on every call rather than at startup, you can change it with os.environ inside a running process, and the next breakpoint follows. Because the hook is an ordinary attribute of sys, a project can replace it entirely — as in the container example — for behaviour that depends on more than a single dotted name.
Breakpoints inside pytest
pytest captures stdout and stderr, which would normally make an interactive prompt unusable. It handles breakpoint() specially: its debugging plugin wraps sys.breakpointhook, and when the hook fires it suspends capture, starts the debugger, and resumes capture when you type continue. That is why breakpoint() inside a test or the code it calls just works, even without -s.
Two options interact with this. --pdbcls=IPython.terminal.debugger:TerminalPdb sets the debugger class pytest uses for both --pdb post-mortems and breakpoint() calls, so ipdb-style behaviour is available without setting PYTHONBREAKPOINT. And under pytest-xdist, workers have no terminal, so a breakpoint in a worker cannot be answered; run the failing test without -n to debug it interactively.
-s flag is needed to use breakpoint() in a test.Stopping only when it matters
A breakpoint inside a loop that runs ten thousand times is useless if the interesting iteration is the 9,876th. The simplest fix is ordinary Python around the call: if order.total < 0: breakpoint(). That is often clearer than any debugger feature, and it costs nothing when the condition is false.
Because breakpoint() forwards its arguments to the hook, a custom hook can accept its own parameters. Python 3.13's pdb.set_trace(commands=[...]) lets a breakpoint run debugger commands on arrival, and a thin hook can pass them through, so breakpoint(commands=["p order", "where"]) prints the object and the stack before handing over the prompt. For older versions, the same effect comes from a .pdbrc file in the project directory, whose commands run at every stop.
A counting hook helps with the "nth time" problem when there is no natural condition:
import collections, pdb, sys
_hits = collections.Counter()
def nth_hook(*, nth=1, key=None, **kw):
caller = sys._getframe(1)
k = key or (caller.f_code.co_filename, caller.f_lineno)
_hits[k] += 1
if _hits[k] == nth:
pdb.Pdb().set_trace(caller)
sys.breakpointhook = nth_hook
# breakpoint(nth=250) → stops on the 250th pass through this line
Keep such hooks in a development-only module that a conftest.py or a sitecustomize.py in the virtualenv imports, never in application code. The point of the hook mechanism is that production code contains only plain breakpoint() calls — ideally none at all by the time it merges — and everything clever lives in the developer's environment.
A team convention that keeps breakpoints safe
The hook mechanism is most valuable when a whole team shares a small convention. Everyone writes plain breakpoint(), never import pdb; pdb.set_trace() or import ipdb; ipdb.set_trace(), so the choice of debugger is personal and lives in each developer's environment. CI and every container image set PYTHONBREAKPOINT=0, so a forgotten call is inert rather than a hung job. The linter flags any breakpoint() in committed code, so forgotten calls are caught in review rather than in production logs. And the pre-commit configuration runs that lint rule locally, so most are never pushed at all.
With those four pieces in place, breakpoints stop being a source of incidents and become what they should be: a zero-cost, zero-setup way to stop and look.
Debugging a running service with the hook
The container hook in the solution deserves a closer look, because it solves a problem that otherwise needs code changes: stopping inside a service that has no terminal. A web worker running under gunicorn in a container has stdin closed, so plain pdb cannot take input. With the custom hook installed at startup and IN_CONTAINER=1 set, the same breakpoint() instead opens a debug adapter on port 5678 and waits.
The workflow is then: publish the port from the container, add a breakpoint() in the handler under investigation, send the request that reaches it, and attach from VS Code or PyCharm with a "remote attach" configuration pointing at the published port. The request pauses at the breakpoint with full variable inspection, stepping and an interactive console, exactly as if it were running locally.
Two precautions matter. Run a single worker process while debugging, or several workers will race to listen on the same port. And never ship an image with the hook enabled by default: a debug adapter listening on all interfaces is remote code execution for anyone who can reach the port. Gate it behind an environment variable that only development compose files set, and let PYTHONBREAKPOINT=0 in production images act as a second line of defence.
Edge cases and failure modes
- Breakpoint hangs CI. A stray call waits forever for input on a closed stdin — or fails with
BdbQuitin some runners. SetPYTHONBREAKPOINT=0in CI and lint for leftovers. - Misspelled debugger.
PYTHONBREAKPOINT=ipbd.set_tracefails to import, warns, and skips the breakpoint entirely. If a breakpoint seems ignored, look for theRuntimeWarning. -Eand-Iflags. Python started with-Eor in isolated mode ignoresPYTHON*environment variables, including this one. Set the hook in code if you must use those flags.- Debugger not installed in the environment.
ipdbin your global Python does not help a virtualenv. Install it as a dev dependency. - Remote debuggers blocking startup. A hook that waits for a client blocks the process until one attaches. Use it only behind an explicit flag.
Frequently Asked Questions
How do I make breakpoint() open ipdb instead of pdb?
Set the environment variable PYTHONBREAKPOINT=ipdb.set_trace. breakpoint() imports the named callable when it is called and runs it, so any importable function works, including pudb.set_trace or a remote debugger's entry point.
How do I stop a forgotten breakpoint() from hanging CI?
Set PYTHONBREAKPOINT=0 in the CI environment, which turns every breakpoint() call into a no-op. Also add a lint rule such as Ruff's T100 so breakpoints are caught before they are merged.
Why does breakpoint() work inside pytest even though output is captured?
pytest wraps sys.breakpointhook and suspends output capture before the debugger starts, then resumes it when you continue. That is why you can type at the pdb prompt inside a captured test.
Related
- Interactive Debugging with pdb and ipdb — commands and workflow.
- Dropping into pdb on Test Failure — --pdb and --trace.
- Setting Conditional Breakpoints in pdb — stopping only when it matters.
- Attaching debugpy to a Container — the remote hook in practice.
← Back to Interactive Debugging with pdb and ipdb