Print Debugging Is Still Good Debugging

There is a point in almost every debugging session where you stop guessing the bug and start setting up the investigation.

  1. You open the debugger.
  2. Add breakpoints.
  3. Configure the launch settings.
  4. Restart the application.
  5. Step through several functions.
  6. Inspect a few objects.

And eventually discover that the value you needed to see could have been found with:

python

1
print(user_id)

I have done this more times than I can count.

Modern debugging tools are powerful. Proper logging is essential in many applications. Tracing, profiling, error monitoring, and interactive debuggers can reveal problems that a few printed values never will.

But for quick fixes and simple applications, print() is still one of the most useful debugging tools available.

Sometimes it is the best one.


The Tool Developers Are Supposed to Outgrow

Print debugging is often presented as something beginners do before they learn “real” debugging.

The implied progression looks like this:

Beginner    → print()
Experienced → debugger
Professional → logging and observability platform

Real development does not work that neatly.

Experienced programmers still use print statements because they are:

  • immediate
  • visible
  • flexible
  • available everywhere
  • easy to remove
  • usually good enough

A debugger may be more powerful.

That does not mean it is always more useful.

The best debugging tool is the one that answers the current question with the least friction.

Sometimes that is a breakpoint.

Sometimes it is a structured log.

Sometimes it is one ugly print statement in the middle of a loop.


What Print Debugging Actually Does Well

Most small bugs begin with a simple question:

What value does this variable have here?

Or:

Is this function being called?

Or:

Which branch is the code entering?

Or:

How many times is this loop running?

Print statements answer these questions directly.

python

1
print("user_id:", user_id)
python

1
print("entered checkout view")
python

1
print("using cached result")
python

1
print("items:", len(items))

There is no setup.

There is no debugger configuration.

There is no need to pause execution or navigate an inspection panel.

You add the line, run the code, and look at the output.

For a large category of everyday bugs, that is enough.


The Speed Advantage

The main advantage of print debugging is not sophistication.

It is speed.

Imagine a Django view returning the wrong template:

python

1
2
3
4
5
6
7
8
def dashboard(request):
    if request.user.is_staff:
        template = "dashboard/admin.html"

    else:
        template = "dashboard/user.html"

    return render(request, template)

You could start an interactive debugger and step through the request.

Or you could add:

python

1
2
print("is_staff:", request.user.is_staff)
print("template:", template)

Run the request once.

See the problem.

Remove the statements.

Done.

The debugger gives you more control, but more control was not the requirement.

The requirement was knowing which template had been selected.

When the question is simple, the debugging method should be simple too.


It Shows the Program as It Runs

Breakpoints pause a program.

That is often useful, but pausing can also change how the program behaves.

Timing-sensitive code, concurrent tasks, network operations, animations, and request flows may behave differently when stopped.

Print debugging lets the application continue running.

You can observe:

  • the order of operations
  • repeated function calls
  • values changing over time
  • branches taken during execution
  • data passing through several layers
  • events that happen too quickly to inspect manually

For example:

python

1
2
3
for item in items:
    print("processing:", item.id)
    process(item)

The output gives you a simple execution trace:

text

1
2
3
processing: 41
processing: 42
processing: 43

Add one more line:

python

1
2
3
4
for item in items:
    print("processing:", item.id)
    result = process(item)
    print("result:", result)

Now you can see input and output together.

It is not a full tracing system.

It often does not need to be.


Interactive debuggers work best when the runtime, editor, and execution environment cooperate.

Print statements have fewer requirements.

They work in:

  • scripts
  • command-line applications
  • Django views
  • management commands
  • background tasks
  • test runs
  • containers
  • remote development environments
  • small automation tools
  • temporary data-processing jobs

They also work when the bug appears in code that is awkward to pause.

You can place a print statement:

  • before a return
  • inside a condition
  • inside a loop
  • during exception handling
  • between two transformations
  • immediately before a database query
  • immediately after an API response
python

1
2
3
4
5
print("before normalization:", value)

value = normalize(value)

print("after normalization:", value)

This gives you a before-and-after comparison with almost no effort.


It Forces You to Ask a Specific Question

One underrated advantage of print debugging is that it forces you to decide what you are trying to learn.

A debugger can expose an entire runtime state.

That is powerful, but it can also encourage wandering.

You pause the program and start inspecting everything:

  • local variables
  • request data
  • object attributes
  • stack frames
  • imported modules
  • unrelated values

A print statement is more deliberate:

python

1
2
print("expected total:", expected_total)
print("actual total:", actual_total)

You have defined the question.

You have selected the evidence.

You are testing a specific assumption.

That often leads to faster debugging because most bugs are caused by one incorrect assumption:

  • the function was never called
  • the condition was false
  • the value was already modified
  • the list was empty
  • the object was None
  • the wrong ID was passed
  • the code ran twice

Print debugging is good at confirming or rejecting those assumptions quickly.


The Labels Matter

A plain print statement can create more confusion than it solves.

This is not helpful:

python

1
print(value)

Output:

text

1
12

What is 12?

A user ID?

A count?

A price?

A status code?

Label the output:

python

1
print("order count:", value)

Better still, include the location or stage:

python

1
print("[checkout] order count:", value)

For several values, use a clear structure:

python

1
2
3
4
5
6
7
8
print(
    "[checkout]",
    {
        "user_id": user.id,
        "cart_id": cart.id,
        "item_count": cart.items.count(),
    },
)

Useful print debugging should make the output easier to understand, not simply produce more output.


A good place to add print statements is at the boundaries between parts of a program.

For example:

  • when a function receives data
  • before data is transformed
  • after data is transformed
  • before calling another service
  • after receiving its response
  • before saving to the database
  • after reading from the database
python

1
2
3
4
5
6
7
8
def calculate_total(items):
    print("[calculate_total] input:", items)

    total = sum(item.price for item in items)

    print("[calculate_total] output:", total)

    return total

This helps answer an important debugging question:

Did the wrong value enter this function, or did this function create the wrong value?

Without checking the boundary, developers often debug the wrong layer.


Print statements are especially useful while developing small Django applications.

A view can quickly confirm request data:

python

1
2
3
4
5
def create_article(request):
    print("method:", request.method)
    print("POST data:", request.POST)

    # ...

A form can show validation results:

python

1
2
3
4
form = ArticleForm(request.POST)

print("is_valid:", form.is_valid())
print("errors:", form.errors)

A query can be inspected:

python

1
2
3
4
articles = Article.objects.filter(is_published=True)

print("article count:", articles.count())
print("query:", articles.query)

A model method can expose its inputs:

python

1
2
3
4
5
6
7
8
9
def calculate_discount(self):
    print(
        "price:",
        self.price,
        "discount rate:",
        self.discount_rate,
    )

    return self.price * self.discount_rate

For a small local project, these statements can reveal the problem before a more elaborate debugging setup would even be ready.


The Quick-Fix Workflow

For simple problems, my usual print-debugging workflow looks like this:

  1. Identify the first assumption that might be wrong.
  2. Print the value related to that assumption.
  3. Run the smallest action that reproduces the bug.
  4. Move the print statement earlier or later in the flow.
  5. Repeat until the value changes unexpectedly.
  6. Fix the cause.
  7. Remove the temporary output.

Suppose a calculated total is wrong:

python

1
2
3
4
def get_total(items):
    subtotal = sum(item.price for item in items)
    discount = get_discount(subtotal)
    return subtotal - discount

Start with:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def get_total(items):
    subtotal = sum(item.price for item in items)
    print("subtotal:", subtotal)

    discount = get_discount(subtotal)
    print("discount:", discount)

    total = subtotal - discount
    print("total:", total)

    return total

If the subtotal is already wrong, inspect the items.

If the subtotal is correct and the discount is wrong, inspect get_discount().

If both are correct, inspect the final operation or the code using the result.

You are narrowing the failure point one boundary at a time.


When a Debugger Is Better

Print statements are not superior in every situation.

A proper debugger is better when you need to:

  • pause execution
  • inspect a large object graph
  • examine several stack frames
  • step through complex branching logic
  • change values during execution
  • inspect state without editing the code
  • repeatedly investigate the same execution path

For example, stepping through a complicated recursive algorithm is usually easier with a debugger than with dozens of print statements.

The debugger gives you control over the runtime.

Print debugging gives you a lightweight trace.

They solve different problems.


When Logging Is Better

Print statements are also not a replacement for application logging.

Logging is better when output must be:

  • preserved
  • searchable
  • filtered by severity
  • collected from multiple processes
  • analyzed after a failure
  • sent to monitoring systems
  • used in production
  • associated with timestamps and request IDs

Production code should not rely on random print statements scattered across the application.

A logging call provides structure:

python

1
2
3
4
5
6
7
8
9
import logging

logger = logging.getLogger(__name__)


logger.info(
    "Order created",
    extra={"order_id": order.id},
)

Logging is part of the application.

Print debugging is usually temporary investigation.


The Problem With Tooling by Default

Debugging tools can become another form of overengineering.

A developer sees a wrong value and immediately reaches for:

  • a remote debugger
  • a tracing service
  • a logging framework configuration
  • a browser extension
  • an IDE plugin
  • a profiling suite
  • a monitoring dashboard

All of those tools can be useful.

But they also add:

  • setup time
  • configuration
  • unfamiliar interfaces
  • additional failure points
  • more context switching

Sometimes you spend more time preparing to observe the bug than fixing it.

This is the same mistake developers make elsewhere:

choosing the most capable tool instead of the simplest sufficient tool.


Make Temporary Prints Easy to Find

One problem with print debugging is forgetting to remove the statements.

A simple prefix makes them easy to search for:

python

1
print("DEBUG:", value)

Or:

python

1
print("[DEBUG checkout]:", value)

Before committing, search the project:

bash

1
grep -R "DEBUG:" .

You can also inspect the diff:

bash

1
git diff

Temporary debugging code should rarely survive code review unnoticed if the changes are reviewed properly.


Print statements are also useful while writing or fixing tests.

Suppose a test fails because two objects are different:

python

1
assert result == expected

Before changing the assertion, inspect both values:

python

1
2
print("result:", result)
print("expected:", expected)

For nested data:

python

1
2
3
4
5
6
7
8
from pprint import pprint


print("result:")
pprint(result)

print("expected:")
pprint(expected)

This can quickly reveal:

  • a missing key
  • a type mismatch
  • different ordering
  • an incorrect date format
  • an unexpected None
  • extra whitespace

Once the problem is understood, remove the prints and keep the meaningful assertion.


Better Than print() Without Becoming Complicated

Plain print() is often enough, but Python includes a few simple tools that improve readability.

Use repr() to expose invisible characters:

python

1
print("username:", repr(username))

This can reveal:

text

1
'admin '

instead of:

text

1
admin

Use type() to confirm the data type:

python

1
2
print("value:", value)
print("type:", type(value))

Use pprint() for nested structures:

python

1
2
3
4
from pprint import pprint


pprint(response_data)

Use f-string debugging syntax:

python

1
2
print(f"{user_id=}")
print(f"{total=}")

Output:

text

1
2
user_id=42
total=19.99

These are still simple tools.

They keep the debugging loop fast without introducing a full debugging setup.


A Better Default

Instead of asking:

What is the most advanced tool available for debugging this?

Ask:

What is the quickest reliable way to answer the next question?

Start with the smallest useful tool.

Maybe that is:

python

1
print(f"{value=}")

If it answers the question, fix the bug and move on.

If it does not, increase the level of tooling:

Print statement
    ↓
Targeted logging
    ↓
Interactive debugger
    ↓
Profiler or tracing
    ↓
Production observability

This is not a strict hierarchy.

It is a reminder to add complexity when the investigation requires it.


The Quiet Advantage of Print Debugging

Print debugging has survived because it has almost no ceremony.

It does not need to be installed.

It does not need to be configured.

It does not care which editor you use.

It does not require a specific project structure.

It asks only one thing:

What do you want to see?

Then it shows you.

There are better tools for complex investigations.

There are safer tools for production.

There are more structured tools for long-term monitoring.

But when a simple application behaves incorrectly and you need an answer now, a well-placed print statement is still hard to beat.

You do not get extra points for debugging with the most impressive tool.

You get results for finding the bug clearly and quickly.

Use the debugger when you need a debugger. Use logging when you need logging. But do not underestimate the value of printing one variable and seeing the truth immediately.