Tips for debugging with print()

A machine of much printing.

If you’re embarrassed at debugging with print(), please don’t be - it’s perfectly fine! Many bugs are easily tackled with just a few checks in the right places. As much as I love using a debugger, I often reach for a print() statement first.

Here are five tips to get the most out of debugging with print().

1. Debug variables with f-strings and =

Often we use a print() to debug the value of a variable, like:

>>> print("widget_count =", widget_count)
widget_count = 9001

On Python 3.8+ we can use an f-string with the = specifier to achieve the same with less typing. This specifier prints the variable’s name, “=”, and the repr() of its value:

>>> print(f"{widget_count=}")
widget_count=9001

Less typing for the win!

We aren’t limited to variable names with =. We can use any expression:

>>> print(f"{(widget_count / factories)=}")
(widget_count / factories)=750.0833333333334

If you prefer spaces around your =, you can add them in the f-string and they will appear in the output:

>>> print(f"{(widget_count / factories) = }")
(widget_count / factories) = 750.0833333333334

Neat!

2. Make output “pop” with emoji

Make your debug statements stand out among other output with emoji:

print("👉 spam()")

You can also then jump to your debug output with your terminal’s “find” function.

Here are some good emojis for debugging, which may also help express associated emotions:

To type emoji faster, use the keyboard shortcut for your OS:

3. Use rich or pprint for pretty printing

Rich is a terminal formatting library. It bundles many tools for prettifying terminal output and can be installed with pip install rich.

Rich’s print() function is useful for debugging objects. It neatly indents large, nested data structures, and adds syntax highlighting:

Screenshot using rich.print()

Cool beans.

Using from rich import print replaces the builtin print() function. This is normally safe since the Rich version is designed as a drop-in replacement, but it does mean everything passed to print() is formatted. To preserve our application’s non-debug output exactly, we can use an import alias like from rich import print as rprint, keeping rprint() for debugging.

For more info see the Rich quick start guide.

If you’re not at liberty to install Rich, you can use Python’s pprint() function instead. The extra “p” stands for “pretty”. pprint() also indents data structures, albeit without colour or style:

>>> from pprint import pprint
>>> pprint(luke)
{'birth_year': '19BBY',
 'films': [3, 4, 5, 6, 7, 8],
 'id': 1,
 'name': 'Luke Skywalker'}

Handy.

4. Use locals() to debug all local variables

Local variables are all the variables defined within the current function. We can grab all the local variables with the locals() builtin, which returns them in a dictionary. This is convenient for debugging several variables at once, even more so when combined with Rich or pprint.

We can use locals() like so:

from rich import print as rprint


def broken():
    numerator = 1
    denominator = 0
    rprint("👉", locals())
    return numerator / denominator

When we run this code, we see the dictionary of values before the exception:

>>> broken()
👉 {'numerator': 1, 'denominator': 0}
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero

There’s also the globals() builtin which returns all the global variables, that is, those defined at the module scope, such as imported variables and classes. globals() is less useful for debugging since global variables don’t usually change, but it is good to know about.

5. Use vars() to debug all of an object’s attributes

The vars() builtin returns a dictionary of an object's attributes. This is useful when we want to debug many attributes at once:

>>> rprint(vars(widget))
{'id': 1, 'name': 'Batara-Widget'}

Brillo.

(vars() without an argument is also equivalent to locals(), in about half the typing.)

vars() works by accessing the __dict__ attribute of the object. Most Python objects have this as the dictionary of their (writeable) attributes. We can also use __dict__ directly, although it’s a little more typing:

>>> rprint(widget.__dict__)
{'id': 1, 'name': 'Batara-Widget'}

vars() and __dict__ don’t work for every object. If an object’s class uses __slots__, or it’s built in C, then it won’t have a __dict__ attribute.

6. Debug your filename and line number to make returning there easy

Update (2021-10-09): Thanks to Lim H for the tip.

Many terminals allow us to open on filenames from output. And text editors support opening files at a given line when a colon and the line number follows the filename. For example, this output would allow us to open example.py, line 12:

./example.py:12

In iTerm on macOS, we can command-click to open the file (“smart selection”). For other terminals, check the documentation.

We can use this capability in our print() calls to ease reopening the code we’re trying to debug:

print(f"{__file__}:12 {widget_count=}")

This uses the Python magic variable __file__ to get the name of the current file. We have to provide the line number ourselves.

Running this we see:

$ python example.py
/Users/me/project/example.py:12 widget_count=9001

And in iTerm we can command-click the start of the line to jump right back to the broken code.

I’m told that to open in VSCode specifically, you can output a link like vscode://file/<filename>.

✨Bonus✨ 7. Try icecream

Update (2021-10-09): Thanks to Malcolme Greene for reminding me of this package.

The icecream package provides a handy debugging shortcut function, ic(). This function combines some of the tools we’ve been looking at. Called without arguments, ic() debugs details about where and when it was called:

from icecream import ic


def main():
    print("Starting main")
    ic()
    print("Finishing")


if __name__ == "__main__":
    main()
$ python example.py
Starting main
ic| example.py:6 in main() at 11:28:27.609
Finishing

Called with arguments, ic() inspects and prints each expression (through source inspection) and its result:

from icecream import ic


def main():
    a = 1
    b = 2
    ic(a, b, a / b)


if __name__ == "__main__":
    main()
$ python example.py
ic| a: 1, b: 2, a / b: 0.5

This includes some syntax highlighting like Rich.

icecream also has some other handy abilities like installing as a builtin so you don’t need to import it. Check out its documentation for more info.

Fin

May you ever print() your bugs away,

—Adam


Read my book Boost Your Git DX to Git better.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: