Free lesson · Python Basic to Advance
Print Debugging
Print Debugging
What Print Debugging Is
Print debugging - adding print() calls to see what code is doing - is the most basic debugging technique. A line printed at the right place shows what value a variable held, whether a branch ran, or how often a function was called. No tools to install, no syntax to learn, instant visibility.
def calculate_discount(price, customer_type):
print(f"calculating discount: price={price}, type={customer_type}")
if customer_type == "premium":
discount = 0.2
elif customer_type == "regular":
discount = 0.1
else:
discount = 0.0
print(f"discount applied: {discount}")
result = price * (1 - discount)
print(f"final price: {result}")
return result
This code shows print debugging. Each print statement reveals state at a point in the function. Useful for understanding how data flows and which branches execute. The information appears in the terminal as the function runs - immediate feedback without any tooling overhead.
Print debugging works because it requires nothing. Open a file, add prints, run, see output. That's it.
When Print Debugging Earns Its Place
Print debugging is the right tool when:
- The bug is reproducible - running the code produces the print output reliably
- The codebase is small - finding the right place to add prints is easy
- The fix will be quick - the prints are temporary scaffolding
- The environment is interactive - you can run, edit, run again rapidly
- You want a quick sanity check - confirm a value before reaching for heavier tools
For scripts, small programs, and the early stages of investigating any bug, prints are often faster than firing up a debugger.
# Quick sanity check - print is the right tool
def process(data):
print(f"got {len(data)} items") # quick confirmation
return [item.upper() for item in data]
This code uses print as a quick check. The single print confirms the function received the expected input size. Useful as a fast way to verify assumptions during development. The print can be removed once the assumption is confirmed.
When Print Debugging Is the Wrong Tool
Print debugging stops working when:
- The bug isn't reproducible - by the time you find a print to add, the bug is gone
- The codebase is large - finding where to print is itself the hard part
- The output is enormous - too many prints to read
- The fix involves many iterations - editing prints in and out gets tedious
- The bug is in production - you can't add prints to a running service
- The bug involves timing - prints can change behavior in concurrent code
- The bug is in third-party code - you can't edit those files
For these cases, the pdb debugger (Topic 24.2) or the logging module (Topic 24.3) is usually a better tool.
# Bad fit for print - production code
def serve_request(request):
# Can't ship code with prints to production
# print(f"handling request: {request}") # NO
# Use logging instead
logger.info("handling request", extra={"request_id": request.id})
This code shows where print fails. Production code can't ship with print statements - they pollute logs, can't be filtered, and provide no structured context. Logging - Topic 24.3 - provides leveled, configurable output that works the same in development and production.
The repr vs str Distinction
When printing a value, repr is often more useful than str:
text = "hello\nworld"
print(text)
# hello
# world
print(repr(text))
# 'hello\nworld'
# In f-strings, !r calls repr
print(f"got: {text!r}")
# got: 'hello\nworld'
This code contrasts str and repr. Str shows the value as it should appear to users. Repr shows the value as Python source - quotes around strings, escape sequences visible. For debugging, repr is usually clearer - it distinguishes the empty string from None, shows whitespace, and makes types visible at a glance.
For debugging, f"{value!r}" is often more useful than f"{value}".
Using print for Structured Output
Adding labels and structure makes prints scannable:
def process(items):
print(f"[process] start, len={len(items)}")
for i, item in enumerate(items):
print(f"[process] item {i}: {item!r}")
if item.is_invalid():
print(f"[process] WARN: invalid item at {i}")
continue
print(f"[process] processed {i} ok")
print("[process] done")
This code uses structured prints. The bracket prefix identifies which function the output came from - useful when many functions print at once. The label after the bracket categorizes the message - start, item, warn, done. Useful for tracing flow through a function with many steps. As prints accumulate, structure helps reading them later.
For more than a handful of prints, structure is the difference between useful output and noise.
Print to stderr for Diagnostics
By default, print writes to stdout. Diagnostic output is often better on stderr - separates it from the program's regular output:
import sys
def parse(text):
if not text:
print("warning: empty input", file=sys.stderr)
return None
return text.upper()
# stdout and stderr can be redirected separately
# python script.py 2>errors.log
This code uses stderr. The file argument routes the print to standard error instead of standard output. Useful for scripts that produce data on stdout - diagnostics on stderr don't pollute the data stream. Shells can redirect them separately, capturing data while showing diagnostics or vice versa.
For diagnostic prints in scripts, file=sys.stderr is a good default.
Cleaning Up After Yourself
Print statements added for debugging should be removed before committing - or replaced with proper logging. A common discipline:
- Tag debug prints with a distinctive prefix (
DEBUG:,XXX:, etc.) so they're easy to grep - Use a
git diffreview pass before committing to catch leftover prints - Configure a pre-commit hook that warns or rejects
printlines in committed code - Replace with logging when the diagnostic value is permanent
# Bad - orphan prints left in production code
def calculate(x):
print(x) # what is this for?
return x * 2
# Better - explicit debug tagging
def calculate(x):
# XXX DEBUG - remove before commit
print(f"calculate input: {x!r}")
return x * 2
# Best - proper logging when diagnostic value is permanent
import logging
logger = logging.getLogger(__name__)
def calculate(x):
logger.debug("calculate input: %r", x)
return x * 2
This code shows the progression. Orphan prints with no context are confusing. Tagged prints can be found and cleaned up. Proper logging persists the diagnostic with controllable verbosity - debug level can be enabled in development and disabled in production without code changes.
Print with pprint for Complex Data
For nested data structures, pprint produces readable output:
from pprint import pprint
data = {
"users": [
{"id": 1, "name": "alice", "roles": ["admin", "user"]},
{"id": 2, "name": "bob", "roles": ["user"]},
],
"meta": {"page": 1, "total": 2},
}
# Default print - single line, hard to read
print(data)
# pprint - formatted, indented
pprint(data)
# {'meta': {'page': 1, 'total': 2},
# 'users': [{'id': 1, 'name': 'alice', 'roles': ['admin', 'user']},
# {'id': 2, 'name': 'bob', 'roles': ['user']}]}
This code uses pprint. The pretty printer formats nested structures with indentation and line breaks. Useful when print would produce a wall of text. Pprint accepts a width parameter to control line length and a depth parameter to truncate very deep structures.
For deeply nested or large data, pprint is usually more useful than plain print.
Print and Flushing
By default, print buffers output. In some cases - especially during long-running programs - buffering delays the output:
import time
# Default - output may not appear immediately
for i in range(5):
print(f"step {i}")
time.sleep(1)
# Flush=True - output appears immediately
for i in range(5):
print(f"step {i}", flush=True)
time.sleep(1)
This code shows flushing. Without flush, prints may be buffered and appear in batches. With flush, each line appears immediately. Useful for long-running programs where the output should track progress in real time. For most cases, the default buffering is fine.
For progress-bar-style output or anything time-sensitive, flush=True ensures immediate visibility.
The Limits of Print
Print is a hammer. Some bugs need different tools:
- State at a specific moment -
pdb.set_trace()orbreakpoint()lets you inspect interactively (Topic 24.2) - Performance - profiling shows where time is spent (Topic 24.6)
- Production diagnostics - structured logging with levels (Topic 24.3-24.5)
- Memory issues - memory profiling and
tracemalloc - Concurrent bugs - logs with timestamps and thread IDs are clearer than prints
# Print can show what - but not interactively
def find_bug():
x = compute()
print(f"x = {x}") # see the value, but can't probe further
return process(x)
# breakpoint() lets you inspect x and everything else
def find_bug():
x = compute()
breakpoint() # interactive - inspect x, call methods, change values
return process(x)
This code contrasts print and breakpoint. Print shows one value. Breakpoint stops execution and gives an interactive prompt - inspect any variable, run arbitrary expressions, step through subsequent lines. Topic 24.2 covers the pdb debugger that breakpoint launches.
Performance
Print overhead is small but real:
- One
printcall: microseconds, mostly the I/O printto a terminal: slower than to a file (terminal rendering)printin tight loops: can dominate the loop's time
For very hot code, even prints can change timing. For most debugging, the overhead is negligible.
Interview Relevance
Interview: Print debugging is fundamental:
- When prints are the right tool (small, reproducible, interactive)
- When they aren't (production, large codebases, timing-sensitive)
reprvsstrfor showing valuesfile=sys.stderrfor diagnosticspprintfor nested data- The need to clean up debug prints before committing
- That logging exists for permanent diagnostics
A common interview moment: a candidate dismisses print debugging as primitive. The right framing is that prints are a valid tool for many real situations - small, fast, reliable, and zero-cost to set up.
Another moment: a candidate uses print everywhere instead of logging. The right framing is that prints are scaffolding; logging is infrastructure.
Module 30 covers Python interview preparation.
Common Pitfalls
Warning: Leaving debug prints in committed code. Pollutes production logs, confuses readers.
Using
reprwould clarify. Empty strings, whitespace, None all look the same with plain print.Buffering hiding output during long runs. Use
flush=Truefor progress-style output.Print in tight loops. Generates thousands of lines that drown the signal.
Print for concurrent code. Output from multiple threads interleaves unpredictably.
No structure or labels. Many prints with no context become noise.
Print to
stdoutpolluting script output. Diagnostics should go tostderr.
Try It
Exercise: Practice with print debugging:
- Add prints to a function to trace its execution. Verify the output shows what you expected.
- Try
print(value)andprint(repr(value))for a string with a newline. Note the difference.- Use
pprinton a nested dict. Compare with plain- Route a diagnostic message to
stderrwithfile=sys.stderr. Redirect stdout to a file and confirm only data is captured.- Add
flush=Trueto prints in a sleeping loop. Note the difference in timing.- Take code with many prints and convert them to
logging.debugcalls. Note the difference in control.