TL;DR
- Python debugging.
pdbis Python's built-in debugger, which lets you step through code line by line, inspect variables, and set breakpoints without installing anything extra. - Better tooling.
ipdb,pdb++, VS Code, and PyCharm make debugging Python code easier when you want tab completion, richer variable inspection, or a visual debug console. - Browser scripts. If you're debugging a Python browser automation,
pdbonly shows part of the story because the real failure may be in the page, the network, or the browser state. - Browserless fills that gap. Live Debugger sandbox for reproducing issues interactively, Hybrid Automation LiveURLs for watching your own running sessions, and Session Replay for reviewing completed runs in your dashboard.
Introduction
Python gets used for just about everything: APIs, data jobs, CLI tools, internal automation, tests, and a lot of browser scripting. That range is part of the appeal, but it also means bugs show up in very different ways. A bad string parse, a race condition in a background worker, and a headless browser timing issue can all surface as the same thing at first: a Python program that stopped doing what you expected.
That's why Python debugging skills matter so much. Debugging Python code is part of everyday development, especially when you're shipping code that touches external services, scrapes sites, or drives browser automation.
In this guide, you'll learn how the built-in Python debugger works, how to start debugging with breakpoint(), which pdb commands matter day to day, when a different Python debug tool makes more sense, and how to debug Python-controlled browser sessions when the problem is not really in the Python code at all.
What is the Python debugger?
Python's built-in debugger is pdb, an interactive debugger that ships with the standard library. It supports:
- Single-stepping
- Conditional breakpoints
- Stack frame inspection
- Source code listing
- Arbitrary Python code evaluation in the current context
- Post-mortem debugging
The real benefit is that pdb is portable. Because it's built in, you don't need to install anything before you can use it. That makes it useful in minimal environments such as containers, remote servers, or CI jobs where a full integrated development environment is not available.
It's not the fanciest debugger, but it is the one you can count on being there. If you need to pause code execution, inspect variables, look at a stack trace, or list source code from the terminal, using a debugger built directly into Python is often the fastest path from error to understanding.
It's also still an active topic inside the Python ecosystem. At the Python Language Summit in 2024, contributors discussed the current debugger's performance and usability limits, along with the case for improving it or eventually replacing parts of the underlying experience. So, that's where pdb sits today: it's practical, dependable, and still worth learning, even if nobody pretends it is perfect.
How to start the Python debugger
Once you know what pdb is and why it still matters, the next question is: how do you actually start the Python debugger in a real script?
You have two main entry points when you want to start debugging.
The first is from the command line:
python -m pdb your_script.py
When you launch a Python script with that command, pdb pauses execution before the first line and lets you step through the whole program.
The command-line interface supports debugging a file or a module. If you need to attach to a running process by PID, tools like debugpy (used by VS Code) support that workflow, but standard pdb does not. It also drops into post-mortem mode when the program exits abnormally, which is useful if you want to inspect the failure point instead of just reading the final traceback.
If your script takes command-line arguments, you pass them after the filename as usual.
The second entry point is the one that most developers use during normal work: put a breakpoint directly into the code where you want execution to stop.
def parse_price(text: str) -> float:
value = text.strip().replace("$", "").replace(",", "")
breakpoint() # inspect `value` before conversion
return float(value)
breakpoint() is the modern approach. It was added in Python 3.7, and, by default, it calls sys.breakpointhook(), which in turn calls pdb.set_trace(). Python also lets you change that behavior through the PYTHONBREAKPOINT environment variable, so the same source code can use a different debugger without editing the file.
Older code uses import pdb; pdb.set_trace(). That still works and is worth recognizing when you inherit an existing Python file. For new Python code, breakpoint() is usually the better default because it is cleaner and less tightly coupled to the pdb module itself.
Using breakpoint() also makes it easier to swap in another Python debug tool later without touching the source code.
Core pdb commands
You don't need to memorize every pdb command before you can debug effectively. Most day-to-day work comes down to a small set of commands you use over and over. These three commands cover most basic movement through a Python program.
n, short fornext, executes the current line and stops at the next line in the current function.s, orstep, also executes the current line, but it stops at the first possible occasion, including inside a called function.c, orcontinue, resumes code execution until the next breakpoint.
step is what you use when the bug is probably inside the function you are about to call. next is what you use when you want to move through the current function without diving into every helper.
Here are a few useful commands for inspection:
p expressionevaluates an expression in the current context and prints its value.pp expressiondoes the same thing with pretty-printing, which helps when a variable is a nested dict, a long list, or some other awkward data structure.aprints the current function's arguments and their values, which is often the quickest way to verify what actually reached the function instead of what you think should have reached it.
A few commands for orientation:
llists source code around the current line.llshows the full current function or frame. That command is especially handy when you break into the debugger deep inside a module and need to re-establish the surrounding context before stepping again.qquits the debugger entirely. It sounds trivial, but when you are bouncing between runs, what matters is knowing when you're leaving the debugger versus simply continuing execution.
A couple for flow control:
bsets a breakpoint.j linenochanges the next line that will be executed in the bottom-most frame.
The jump command is useful, but it is also easy to misuse. You can skip forward or re-run code, but not every jump is legal, and jumping can leave program state in a place that would never happen in normal execution. It's best treated as a repair tool, not your default way to navigate a Python script.
One command that deserves more attention is interact.
It opens an interactive Python interpreter seeded with the local and global names from the current scope, making it useful when you want to test an expression, poke at an object more freely, or try a small hypothesis without restarting the program.
The catch is that interact uses a separate namespace for code execution, so rebinding variables there does not change the original frame, even though mutating referenced objects still can.
As you use pdb more, you'll notice that most commands can be abbreviated to one or two characters. Abbreviation is a big reason why the interface feels faster over time. The debugger can feel clunky at first, but once n, s, c, l, and pp become muscle memory, moving through a stack frame from the terminal starts to feel surprisingly efficient.
To make those commands pay off in a real codebase, though, you need to stop in the right places, which brings you to breakpoints.
Setting and managing breakpoints
A lot of bad debugging sessions are really breakpoint problems.
You can set a breakpoint at a line number in the current file with b 42, point to another file with b path/to/file.py:42, or set one on a function name so execution stops at the first executable statement in that function. You can also make a breakpoint conditional by adding an expression after the location (order_id must already exist as a variable in the current scope):
(Pdb) b 42, order_id == "A-1024"
That condition must evaluate to true before the debugger stops. In practice, this quality-of-life feature is one of the biggest in pdb. If you're debugging a loop, a parser, or a scraper that processes hundreds of items, a conditional breakpoint is much better than stepping through every iteration and watching the clock.
You can list all breakpoints by running b with no arguments. Each one gets a number. From there, disable turns a breakpoint off without removing it, enable turns it back on, condition changes or removes the condition, and cl clears it.
There's also tbreak, which creates a temporary breakpoint that removes itself after the first hit, making it useful when you want to stop once in a noisy execution path and then continue debugging without hitting the same line again and again.
Once you know how to set breakpoints precisely, stepping from the first line becomes much less necessary. That naturally leads to another useful technique: entering the debugger only after the program has already crashed.
Post-mortem debugging
Post-mortem debugging enables you to inspect the program after an unhandled exception instead of trying to catch the problem ahead of time.
If you run your code with python -m pdb, the debugger automatically enters post-mortem mode when the program exits abnormally. You can also use pdb.pm() after an exception to jump into the most recent failure and inspect the traceback state interactively.
From there, you can inspect variables, move up and down the call stack, and look at the exact context where the error happened rather than treating the traceback as a static text dump.
When it comes to failures buried under several layers of helper functions, this process is especially helpful. A stack trace tells you where the exception surfaced. Post-mortem debugging tells you what values, branches, and assumptions got you there. When debugging Python code in real systems, that is often the difference between finding the bug and merely finding the line where the bug became visible.
Here are all the commands and their functions:
| pdb command | What it does |
|---|---|
n | Runs the current line and stops at the next line in the current function |
s | Steps into a function call so you can debug inside it |
c | Continues execution until the next breakpoint |
b | Sets a breakpoint at a line number, function, or file location |
p | Prints the value of an expression or variable in the current context |
pp | Pretty-prints the value of an expression, which is useful for nested data |
l | Lists source code around the current line |
ll | Lists the full source code for the current function or frame |
a | Prints the arguments passed to the current function |
j | Jumps to a different line in the current frame without executing the lines in between |
q | Quits the debugger and stops the program |
interact | Opens an interactive Python session in the current scope so you can test expressions manually |
disable | Disables a breakpoint without deleting it |
enable | Re-enables a previously disabled breakpoint |
condition | Adds, changes, or removes a condition on an existing breakpoint |
cl | Clears one breakpoint or all breakpoints |
tbreak | Sets a temporary breakpoint that removes itself after the first hit |
Once you are comfortable with pdb, you'll also start to notice where it feels a little rough. That does not make it the wrong tool; it just means there are times when a different Python debugger is more comfortable.
How to debug Python code without pdb
pdb is powerful, but it's not the only way to debug Python.
Some developers want tab completion and better tracebacks. Some prefer a GUI with a debug console, variable panes, and clickable breakpoints. Some need remote debugging that integrates cleanly with their editor. In those cases, the best Python debug tool is not always the built-in one.
The good news is that you do not need to pick a single approach forever. Many teams keep pdb as the baseline because it works everywhere, then use richer tools when the environment allows it.
ipdb
ipdb is one of the most common upgrades because it feels familiar right away. It exposes the IPython debugger with the same general interface as pdb, while adding tab completion, syntax highlighting, better tracebacks, and stronger introspection. In other words, you keep the command-line workflow, but the shell becomes much nicer to work in.
Usage is simple. You don't need to change any code. Just set this in your environment:
export PYTHONBREAKPOINT=ipdb.set_trace
Then use breakpoint() as normal:
def transform(payload):
breakpoint()
return payload.get("total", 0) / payload.get("count", 1)
You can also invoke ipdb directly if you prefer an explicit import:
import ipdb; ipdb.set_trace()
Or run an entire script under ipdb from the command line:
python -m ipdb your_script.py
That setup is useful in a virtual environment, a shared project config, or a test workflow where you want a better debugger without editing every Python file in the repo.
It also makes it easier to standardize a team's debug mode without changing application code.
pdb++
pdb++, installed as the pdbpp package, is another drop-in replacement aimed at developers who like the pdb model but want fewer rough edges.
Its feature set includes colorful tab completion, optional syntax highlighting, sticky mode that keeps the current code block visible while you step, smarter command parsing, and additional convenience commands. The project describes itself as a drop-in replacement, and because it installs a pdb.py module that takes precedence on the Python path, tools that import pdb directly will automatically use it instead. The project explicitly calls out flows such as pytest --pdb.
If standard pdb feels a little too bare, pdb++ is often the upgrade that keeps the workflow lightweight without making it primitive. It's the strong middle ground when you mostly debug from the terminal and don't need a full integrated development environment.
IDE debuggers
If your default workflow already lives in an editor, a visual debugger may be faster than a command prompt.
VS Code's Python debugging support covers local breakpoints, stepping, variable inspection, a debug console, and remote attach workflows. Expressions entered in the Debug Console during a remote session run on the remote computer, which is useful when you are debugging a process outside your local machine. For teams that want to share debugging setup, VS Code also supports project-level debug configurations.
PyCharm goes further into the integrated experience with named run/debug configurations, a dedicated debug tool window, frame and variable views, and an interactive debug console with code completion. JetBrains also documents runtime type collection and debugger-driven type information workflows, which can improve inspection and autocompletion while you debug larger Python applications.
The trade-off is portability. A GUI debugger is excellent when the code is easy to run locally and the environment is under your control. pdb still has the edge when you are on a remote box, inside a container, or debugging a Python program where the terminal is the only thing you can rely on.
Complementing your debugger with logging
Logging is not a replacement for an interactive debugger, but it is an important complement to one.
Python's built-in logging module provides a flexible event logging system for applications and libraries, which is valuable because your own logs can sit alongside logs from third-party modules in the same application flow, especially when you're debugging long-running processes, background workers, or production issues where you cannot pause code execution and inspect variables live.
In those cases, structured logs and proper log levels will get you much further than scattering print statements through the code.
For browser automation, logging becomes even more important because failures are often asynchronous or separated from the line of Python code that triggered them.
Our guide on logging for Playwright and Puppeteer highlights common cases such as redirects, bot blocks, slow page loads, and request failures.
Logging helps a lot, but it still only tells you what you remembered to capture. It doesn't show you what the page actually rendered or what the browser was doing at the moment things went wrong.
That limitation is where browser automation starts to feel different from ordinary application debugging. You can inspect Python state perfectly and still miss the real problem.
Debugging Python browser automation scripts
If you work on automation, scraping, or headless browser workflows, a normal Python debug setup only gets you part of the way.
A Python debugger can pause the Python interpreter, inspect variables, walk the stack frame, and evaluate arbitrary Python code.
What it can't do by itself is show you what the browser actually rendered, whether the target page redirected to a challenge, which network request returned a bad payload, or why a selector stopped matching after a frontend change.
In browser automation, the visible error often shows up in Python, but the root cause lives on the browser side.
Browserless's Browsers as a Service (BaaS) exposes a WebSocket endpoint that works with Puppeteer's connect() and Playwright's connect_over_cdp(). You point your existing code at a Browserless URL and it runs against a managed, cloud-hosted browser.
In practice, that means you can keep your existing automation code and point it at Browserless by changing the connection URL instead of rebuilding your stack. If you're using Playwright with Python, that's a clean fit: your automation logic stays in Python while Browserless runs and manages the remote browser session.
That means the right workflow is usually not either Python debugging or browser debugging. It's both.
Using pdb alongside browser automation
You can absolutely use breakpoint() in a Playwright-driven Python script, and you should. The connect_over_cdp call points to your Browserless endpoint. Replace YOUR_TOKEN with your API token from the Browserless dashboard.
Note that browser.contexts[0] works here because Browserless provides an existing context over CDP; if adapting this for a locally launched browser, use browser.new_context() instead.
from playwright.sync_api import sync_playwright
def run():
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(
"wss://production-sfo.browserless.io?token=YOUR_TOKEN"
)
context = browser.contexts[0]
page = context.new_page()
page.goto("https://example.com")
breakpoint()
title = page.title()
print(title)
browser.close()
if __name__ == "__main__":
run()
That breakpoint is useful for inspecting the page object, checking response data you have already captured, verifying selectors you are about to use, or looking at transformed data before you store it somewhere. If your issue is in your Python code, this kind of pause is often enough.
What it can't tell you is what the page looked like at the moment the automation failed. The browser may already have redirected, loaded partial markup, hit a challenge, or returned an unexpected request response.
You can add screenshots and logs, and you should, but there's a limit to how much you can infer from artifacts after the fact. Once you hit that limit, the next logical step is a browser-aware debugger.
As well as screenshots and logs, Browserless offers two recording features that capture the full browser session for later review:
- Screen Recording generates WebM video files you can save and share.
- Session Replay uses RRWeb to capture DOM mutations, mouse movements, clicks, console logs, and network requests as a lightweight, interactive replay you can scrub through in your Browserless dashboard.
Both work with Playwright and Puppeteer, including Python.
Browserless's Live Debugger
Browserless's Live Debugger is built for that missing half of the workflow. Headless runs hide what actually happened, and logs alone are not enough. The Live Debugger gives you a hosted sandbox with a real-time screencast, network panel, and DOM inspection, so you can reproduce browser-side issues in a controlled environment without setting up any local tooling.
The Live Debugger is a hosted sandbox where you can write, run, and debug Puppeteer scripts directly in your browser, with a real-time screencast of the session, network inspection, and DOM highlighting during actions. It's ideal for prototyping selectors, testing navigation flows, and reproducing issues before pushing changes to your production automation code.
The Live Debugger shortens the debugging loop by letting you iterate on selectors, navigation, and timing in a live environment without redeploying your full automation script. For flaky scraping jobs, login flows, and dynamic pages, reproducing the issue inside the sandbox is often the fastest path to a root cause.
When you need to watch your own running script from the outside, pair it with a Hybrid Automation LiveURL instead, as that gives you a shareable, real-time view of the session your code controls.
This fits the way many teams debug real automation systems: pdb or an IDE debugger for the Python side, logging for production visibility, and the Live Debugger sandbox (or a LiveURL session) to see what the browser actually did. When a run fails because of a selector change, a redirect, or unexpected page state, reproducing the scenario in a live browser environment is usually the fastest way to find the root cause.
Browserless is not doing something mystical here.
It's providing the same kind of visibility you'd want to build around remote browser sessions yourself, just as a managed, production-ready layer instead of another internal tool you have to create and maintain.
Conclusion
Good Python debug habits usually start small. You drop in breakpoint(). You run python -m pdb on a script that keeps failing. You learn a handful of pdb commands such as n, s, c, b, pp, and ll, and suddenly a stack trace stops feeling like the end of the story.
From there, the toolchain expands naturally. ipdb gives you a better terminal experience. pdb++ keeps the same basic model but smooths out the interface. VS Code and PyCharm add a visual debugger when you want breakpoints, variables, a debug console, and remote debugging in a GUI. Logging fills the gaps when you cannot stop execution live.
If the target is a browser automation script, standard Python debugging only gets you halfway.
Your Python code may be fine while the browser session is quietly failing on a selector, a redirect, a blocked request, or a page that never rendered the way you expected. In that case, pairing your normal Python debugger with Browserless's Live Debugger gives you the kind of visibility you expect from a local debugger, but for cloud browser sessions and headless automation.
If you want to debug browser-driven Python workflows without flying blind, start a free Browserless account.
Python debugging FAQs
What does breakpoint() do in Python?
breakpoint() pauses execution and drops you into a debugger at that point in the code. It was added in Python 3.7 and, by default, calls pdb. You can redirect it to another debugger with the PYTHONBREAKPOINT environment variable, so the same source can use a different tool without edits.
What is the difference between pdb and ipdb?
pdb is the standard library debugger that ships with Python and works everywhere. ipdb exposes the IPython debugger with the same general interface, adding tab completion, syntax highlighting, better tracebacks, and stronger introspection. Many teams use pdb as the baseline and switch to ipdb when the environment allows it.
How do I debug a headless browser script in Python?
Use a Python debugger such as pdb for your Python logic, then add browser-aware visibility for the page itself. A breakpoint() lets you inspect the page object, response data, and selectors, while tools like the Browserless Live Debugger, Hybrid Automation LiveURLs, and Session Replay show you what the browser actually rendered.
What is post-mortem debugging?
Post-mortem debugging lets you inspect a program after an unhandled exception instead of catching the problem ahead of time. Running with python -m pdb enters post-mortem mode automatically on an abnormal exit, or you can call pdb.pm() to jump into the most recent failure and inspect the traceback state interactively.