Tips for debugging with print()
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:
- 👉 “Reached this point”
- ❌ “Failed as expected”
- ✅ “Worked randomly”
- 🥲 “Trying hard”
- 🤡 “I feel like a software clown”
- 🤯 “WTF”
To type emoji faster, use the keyboard shortcut for your OS:
- Windows: Windows Key + .
- macOS: Control + Command + Space
- Ubuntu: Control + .
- Other Linuxes: 🤷♂️ consult documentation
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:
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
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
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.
Read my book Boost Your Git DX to Git better.
One summary email a week, no spam, I pinky promise.
Related posts:
Tags: python