Print Debugging Is Underrated and I Will Defend It
Using a debugger is supposedly the mark of a serious engineer. Print statements are supposedly what beginners do. Both of those claims are wrong.
There is a piece of received wisdom in software that goes roughly: real engineers use debuggers, and print statements are what you do before you learn better.
I have used debuggers for years. I like them. I still reach for a print statement most of the time, and I have stopped feeling bad about it, because the more I understand about how debuggers actually work the more I think the received wisdom has it backwards in a specific and important set of cases.
This is not an argument that debuggers are bad. It is an argument that the two tools answer different questions, and that the industry has attached a status hierarchy to a technical choice.
The one thing a debugger cannot show you
A debugger shows you the state of a program at one moment. You stop at a line, you inspect variables, you step forward.
A print statement shows you the history of a program across time.
That difference is the whole argument. Some bugs are questions about state. Many more are questions about sequence:
- How many times did this function run?
- In what order did these three callbacks fire?
- Which of these 4,000 iterations was the one where the value went wrong?
- Did this run before or after that?
- Is this the same object each time or a new one?
For every one of those, a debugger is the wrong shape. You can stop at a breakpoint and look at state, but you cannot see the shape of a sequence by looking at one point in it. You would have to hit continue four thousand times and hold the pattern in your head.
Print statements produce a log, and a log is a record of time. You can scroll it, grep it, count it, diff it against a working run. That last one especially. Diffing the output of a working case against a broken case is one of the highest yield debugging techniques I know, and it is not available to you in a debugger at all.
The observer effect is real
Attaching a debugger changes the program. Not in a hand wavy way, in a concrete mechanical way.
Setting a breakpoint on x86 means the debugger overwrites the first byte of an instruction with 0xCC, the INT3 trap instruction. When execution reaches it, the CPU traps into the kernel, the kernel signals the debugger, and the process stops. To resume, the debugger restores the byte, rewinds the instruction pointer, single steps, rewrites the trap byte, and continues.
That is several context switches per breakpoint hit. In managed runtimes it is worse in a different way: V8 deoptimises the containing function from optimised machine code back to bytecode so it can insert the check, and the JVM does much the same. Your function is no longer running the code it runs in production.
So when you attach a debugger to a race condition, you have:
- Added tens of microseconds of latency at each breakpoint
- Deoptimised at least one function
- Serialised thread scheduling around signal delivery
A race that depends on two threads arriving within nanoseconds of each other will not survive that. This is not the bug being shy. You genuinely changed the conditions of the experiment.
Print statements perturb too, of course. Writing to stdout is a syscall and it is not free. But the perturbation is smaller, more uniform, and critically it is the same on every iteration, which means the relative timing between events is largely preserved even if everything is a bit slower.
For concurrency bugs specifically, I now reach for logging first as a matter of policy, not laziness.
Where a debugger genuinely wins
I want to be fair, because there are cases where stepping is clearly better and I use it there.
You have no idea where you are. Unfamiliar codebase, an exception from somewhere deep, and you cannot even locate the relevant file. A breakpoint on the exception plus a stack walk orients you in seconds where print debugging would require you to know where to put the print.
Rich object state. You need to inspect a large nested object, or walk a tree, or check twenty fields. Printing that is miserable. A watch window is exactly right.
Conditional inspection deep in a call chain. break process_order if order_id == 4471 is genuinely excellent when the condition is rare and the call is deep. Though note this can be slow if the function is hot, because each hit costs a full trap and round trip even when the condition is false.
You want to change values and continue. Setting a variable mid execution to test a hypothesis without a rebuild is something print debugging simply cannot do.
Post mortem debugging from a core dump. No print statement is going to help you there.
Compiled languages with slow builds. If a rebuild is four minutes, the iteration cost of add a print, rebuild, rerun is brutal, and the debugger wins on cycle time alone. This is a big part of why C++ culture is more debugger centric than Python culture, and it is a rational response to the tooling rather than a difference in rigour.
Where printing wins
The build is fast or there is no build. In Python, JavaScript, Ruby, or Liquid, adding a line and re running costs a second. The economics are completely different from C++.
You need the sequence. Discussed above. This is the big one.
The bug is in production. You cannot attach a debugger to production. You can add a log line, deploy it, and read it. Every serious production debugging story ends up being about logs, which is just print debugging with infrastructure.
Concurrency and timing. Discussed above.
The environment makes attaching hard. A container without SYS_PTRACE, a serverless function, a CI runner, a Shopify theme rendering on Shopify's servers, an embedded target, a browser on a colleague's phone. In a great many real environments the debugger is not available and the print statement always is.
I want to dwell on that last category, because it is bigger than people acknowledge. A large fraction of the software I have debugged professionally ran somewhere I could not attach a debugger to. Not because of a tooling gap I should have closed, but because that is the nature of the environment.
You want a record you can share. A log paste in a ticket is evidence. A debugger session is an experience you had.
Print debugging done properly
Most of the disdain for print debugging is really disdain for bad print debugging, which is fair, because print("here") scattered through a file and then deleted in a panic is genuinely not a technique.
Here is what makes the difference.
Label with the variable name and use structured output. Python 3.8 added the self documenting f string:
print(f"{user_id=} {order_total=} {retry_count=}")
# user_id=8823 order_total=Decimal('149.00') retry_count=2
In JavaScript, console.log({ userId, orderTotal, retryCount }) does the same thing by printing the object with keys.
Include a sequence marker. For anything ordering related, a counter or a high resolution timestamp turns a pile of lines into a timeline:
print(f"[{time.monotonic():.6f}] [{threading.current_thread().name}] entering {fn}")
Thread name plus monotonic clock is the minimum viable concurrency log, and it has solved more ordering bugs for me than any tool.
Use a logger, not print. Once your investigation is more than a few minutes old, switch to your logging framework at DEBUG level. Then you get timestamps and module names for free, you can enable it per module, and critically you can leave it in. The single biggest waste in print debugging is doing the work, learning something, deleting all of it, and then needing it again next week.
Log the negative case too. People log when something happens. Log when it does not:
if not matched:
logger.debug("no match for %s among %d candidates", key, len(candidates))
Silence is ambiguous. It could mean the code did not run or that it ran and found nothing, and those are very different.
Diff two runs. Capture output from a working case and a broken case and diff them. The first line that differs is very often the bug or one step from it. This technique is the single strongest argument for logs over stepping, and I use it constantly.
python job.py --date 2026-03-10 > tuesday.log 2>&1
python job.py --date 2026-03-11 > wednesday.log 2>&1
diff tuesday.log wednesday.log | head -40
Use logpoints where your tooling supports them. Chrome DevTools and VS Code let you attach a "print this expression and continue" breakpoint without editing the file. You get printing ergonomics with no source changes and nothing to clean up. This is the best of both and it is badly underused.
The status thing
I think the real reason this debate persists is not technical.
Debuggers are harder to learn. They have keyboard shortcuts and configuration and a vocabulary. Knowing your way around GDB or a native debugger reads as expertise, and it genuinely is a valuable skill.
Print statements have no learning curve, so using them reads as not having bothered to learn the better tool. That inference is wrong. It confuses the sophistication of the instrument with the quality of the reasoning.
The actual skill in debugging is not operating a tool. It is forming a hypothesis about which of your assumptions is false, and then choosing the cheapest experiment that would falsify it. Sometimes that experiment is a breakpoint. Very often it is one line of output in a loop.
Nobody watching you work can tell whether your hypothesis was good. They can tell which tool you used. So the tool becomes the proxy, and the proxy becomes the status marker.
Ignore it. Use whichever answers your question fastest, and be honest with yourself about which question you are actually asking. If it is a question about state, step. If it is a question about time, print.