What happens when a stray breakpoint() call ships to a production worker?
answer
- A development affordance shipped by accident
- Nothing strips it from the build
- It waits for input nobody will type
- sys.breakpointhook decides what it does
- PYTHONBREAKPOINT=0 makes it a no-op
basics
~20 sbreakpoint() calls sys.breakpointhook(), which by default starts pdb on stdin. In a deployed worker that either hangs the job holding all its memory and handles, or, if stdin is wired up, hands whoever reaches it an arbitrary-code prompt inside the process.
solid answer
~50 s`breakpoint()` is a builtin that defers to `sys.breakpointhook()`, whose default implementation calls `pdb.set_trace()`. Nothing strips it in a shipped build — `-O` removes asserts, not breakpoints — so it runs exactly as it did on the developer's laptop. With no terminal attached the debugger waits on a standard input that never delivers a line, so the job stalls mid-transaction still holding its locks, its open handles and its whole working set; when it does see end-of-file it can tear the process down at an arbitrary point. If stdin *is* connected to anything reachable, the prompt is an interactive Python evaluator inside your process with your credentials. Defend in depth: a lint rule and a CI grep for `breakpoint(`, `pdb.set_trace` and `import pdb`, plus `PYTHONBREAKPOINT=0` in the production environment, plus replacing `sys.breakpointhook` at startup with something that logs and refuses.
code
python · 9 linesimport os, subprocess, sys
env = dict(os.environ, PYTHONBREAKPOINT="0")
done = subprocess.run(
[sys.executable, "-c", "breakpoint(); print('worker kept running')"],
capture_output=True, text=True, env=env,
)
print("stdout:", done.stdout.strip())
print("exit status:", done.returncode)go deeper
Know that breakpoint() starts the debugger and must never be committed, and that pdb.set_trace is the same thing spelled the older way. Remove it before you push; a linter can check for you.
Explain the chain: breakpoint() calls sys.breakpointhook(), which defaults to pdb.set_trace(), and PYTHONBREAKPOINT=0 disables it. State clearly that -O does not remove it the way it removes asserts.
Describe the deployed consequence — a stalled worker holding memory, locks and an open transaction, or an interactive evaluator if stdin is attached — and give layered defences: CI enforcement, PYTHONBREAKPOINT=0, and a breakpointhook that logs and refuses.
Own the boundary between development affordances and shipped artifacts as a policy: what CI must reject, which runtime defaults are pinned in every environment, and how a build is proved free of debug entry points rather than assumed to be.
## The mechanism `breakpoint()` has been a builtin since 3.7 (PEP 553). It takes any arguments, passes them straight through to `sys.breakpointhook()`, and the default `sys.breakpointhook` calls `pdb.set_trace()`, which stops the current frame and reads debugger commands from standard input. Two things control it from outside the source: * **`PYTHONBREAKPOINT=0`** disables `breakpoint()` entirely — the call becomes a no-op and execution continues. * **`PYTHONBREAKPOINT=some.module.function`** routes every `breakpoint()` in the process to a callable of your choosing, imported by that dotted path. That environment variable is read when `sys` is initialized, and `sys.breakpointhook` can also be reassigned at runtime. Both are process-wide, and both are the reason this failure is defensible without editing the source. Note what does **not** remove it: `-O` and `-OO` strip `assert` statements and docstrings, and people routinely assume "optimized build" also means "debug statements gone". It does not. A `breakpoint()` compiles to an ordinary call and ships. ## What actually goes wrong in a deployment Take a museum-catalogue importer running as a batch worker with a 2.4 GB working set — the whole accession index held in memory for the merge pass. A `breakpoint()` left in the row-normalization branch fires on the first record that reaches it. * **No terminal attached.** The debugger writes its prompt to stdout and blocks reading stdin, which will never produce a line. The job does not fail, so nothing alerts: it hangs. It holds 2.4 GB resident, an open transaction, whatever advisory locks it took, and its slot in the scheduler. Downstream, the ordering assumption of the next stage quietly breaks because the batch never lands. * **Stdin at end-of-file.** The debugger reads EOF and starts asking whether to quit, and the process can end up terminated at an arbitrary point mid-transaction rather than at a checkpoint you chose. * **Stdin actually connected.** If the process was started by something that gives it an interactive terminal, that prompt is a full Python evaluator running as the service account, inside a process that already holds the database session and the decrypted secrets. It is the most complete privilege handover a stray line of code can offer. Any output the debugger writes is also the same class of disclosure as a traceback: it echoes source lines and, on request, locals. ## `http.server` is the same mistake at a different layer The standard library's `http.server` exists to serve files during development, and its documentation says plainly that it is not recommended for production because it implements only basic security checks. Left in a shipped path, `http.server.SimpleHTTPRequestHandler` serves the process's working directory, which is usually the source tree — directory listings, `.py` files, configuration next to them, a version-control directory. That is the whole codebase handed over by a convenience import someone added to check a fixture. The general rule this leaf teaches: **a development affordance in a production build is an information-disclosure bug**, whether it is a debugger prompt, a directory-serving handler, or a traceback in a response body. ## Defending in depth Three independent layers, because each one alone fails eventually: 1. **Keep it out of the artifact.** A linter rule that forbids `breakpoint`, `pdb.set_trace` and bare `import pdb` outside test directories, enforced in CI so the build fails rather than a reviewer having to spot it. A grep in the packaging step catches what the linter's exclusions miss. 2. **Make it inert if it ships.** Set `PYTHONBREAKPOINT=0` in the production environment. This costs nothing and turns a hang into a continue. 3. **Make it loud.** In the production entry point, assign `sys.breakpointhook` to a function that logs the calling location at error level and raises, so a stray breakpoint becomes an ordinary handled failure with a stack you can find, instead of a silent stall. Do the same for the serving path: the deployed process should have exactly one HTTP entry point and it should not be the development one. Interviewers like this question because the mechanism is small and the blast radius is not, and because the good answer is layered rather than "we review carefully".
- Does running with -O remove a breakpoint() call the way it removes asserts?No. -O and -OO make __debug__ False, which strips assert statements and, at -OO, docstrings. breakpoint() is an ordinary builtin call and compiles into the bytecode like any other, so it ships and runs. The controls for it are PYTHONBREAKPOINT and sys.breakpointhook, not the optimization level.
- Why is http.server unsafe as a production serving path?Its own documentation says it is not recommended for production because it performs only basic security checks. SimpleHTTPRequestHandler serves the process's working directory, so a stray use exposes the source tree, adjacent configuration files and directory listings. Deployed processes should have one intentional HTTP entry point, and it should not be the development helper.
- What would you set PYTHONBREAKPOINT to other than 0?A dotted path to your own callable, which the interpreter imports and calls in place of pdb. In production that is a function that logs the location at error level and raises; in a debugging session it can be a remote or non-interactive debugger front end. It redirects every breakpoint() in the process without touching the source.
It is the maintenance hatch left unbolted on a moving train: harmless in the depot, and either a stalled service or an open door once the thing is out on the line.
saying these in an interview costs you the question
- Believes -O strips breakpoint() along with asserts
- Thinks a breakpoint crashes the process rather than hanging it
- Relies only on code review to catch a stray breakpoint
- Treats http.server as fine behind a reverse proxy
- Assumes no terminal means the debugger is harmless
- Never sets PYTHONBREAKPOINT in the production environment