The 30-Line Debug Print I Keep in My Dotfiles
A pretty-printer for Python objects that I paste into every new project. Shows types, depth, and truncates long containers, so I stop reaching for `pprint` mid-incident.
By @owentoure
February 14, 2026
·
Updated August 12, 2026
995 views
4
4.5 (13)
from __future__ import annotations
import sys
from typing import Any
_MAX_ITEMS = 5
_MAX_STR = 80
def dprint(value: Any, label: str = '', depth: int = 0, _seen: set | None = None) -> None:
if _seen is None:
_seen = set()
pad = ' ' * depth
head = f'{pad}{label + ": " if label else ""}'
obj_id = id(value)
if obj_id in _seen and isinstance(value, (dict, list, tuple, set)):
print(f'{head}<cycle {type(value).__name__}>', file=sys.stderr)
return
_seen.add(obj_id)
if isinstance(value, dict):
print(f'{head}dict({len(value)})', file=sys.stderr)
for i, (k, v) in enumerate(value.items()):
if i >= _MAX_ITEMS:
print(f'{pad} ...{len(value) - _MAX_ITEMS} more', file=sys.stderr)
break
dprint(v, repr(k), depth + 1, _seen)
elif isinstance(value, (list, tuple, set)):
print(f'{head}{type(value).__name__}({len(value)})', file=sys.stderr)
for i, v in enumerate(list(value)[:_MAX_ITEMS]):
dprint(v, f'[{i}]', depth + 1, _seen)
if len(value) > _MAX_ITEMS:
print(f'{pad} ...{len(value) - _MAX_ITEMS} more', file=sys.stderr)
elif isinstance(value, str):
s = value if len(value) <= _MAX_STR else value[:_MAX_STR] + f'...({len(value)})'
print(f'{head}str {s!r}', file=sys.stderr)
else:
print(f'{head}{type(value).__name__} {value!r}', file=sys.stderr)
if __name__ == '__main__':
dprint({'user': {'id': 7, 'tags': ['admin', 'beta', 'eu', 'mfa', 'gdpr', 'soc2']}}, 'payload')This is the debug-print I have been pasting into Python projects since around 2019. It prints to stderr so it never mixes with structured stdout, recurses one level at a time with two-space indentation, and bails out on cycles via an id() set. The two constants at the top, _MAX_ITEMS and _MAX_STR, are the part that matters most during real incidents: when a payload is 50k items deep, pprint will lock your terminal, but dprint shows the first five and a trailing count. I leave the file at ~/.local/lib/python/dprint.py and import sys; sys.path.insert(0, ...) it from a breakpoint() shell.
from __future__ import annotations
import sys
from typing import Any
# Inline copy of dprint() from accordion 1, so this runs standalone.
_MAX_ITEMS = 5
_MAX_STR = 80
def dprint(value: Any, label: str = '', depth: int = 0, _seen: set | None = None) -> None:
if _seen is None:
_seen = set()
pad = ' ' * depth
head = f'{pad}{label + ": " if label else ""}'
obj_id = id(value)
if isinstance(value, (dict, list, tuple, set)) and obj_id in _seen:
print(head + '<cycle>', file=sys.stderr); return
if isinstance(value, dict):
_seen.add(obj_id)
print(head + '{', file=sys.stderr)
for k, v in list(value.items())[:_MAX_ITEMS]:
dprint(v, repr(k), depth + 1, _seen)
if len(value) > _MAX_ITEMS:
print(f'{pad} ...{len(value) - _MAX_ITEMS} more', file=sys.stderr)
print(pad + '}', file=sys.stderr)
elif isinstance(value, (list, tuple, set)):
_seen.add(obj_id)
kind = type(value).__name__
print(head + f'{kind}(', file=sys.stderr)
for v in list(value)[:_MAX_ITEMS]:
dprint(v, '', depth + 1, _seen)
if len(value) > _MAX_ITEMS:
print(f'{pad} ...{len(value) - _MAX_ITEMS} more', file=sys.stderr)
print(pad + ')', file=sys.stderr)
else:
s = repr(value)
if len(s) > _MAX_STR:
s = s[:_MAX_STR] + '...'
print(head + s, file=sys.stderr)
def handle_webhook(event):
dprint(event, 'event')
# Drill down on the noisy part:
dprint(event['data']['object']['lines'], 'lines')
return {'ok': True}
handle_webhook({
'id': 'evt_42',
'type': 'invoice.paid',
'data': {'object': {'amount': 4200, 'lines': list(range(50))}},
})
print('# returned: handled webhook')Webhook payloads from Stripe and similar services have deeply nested objects that turn print(event) into a wall of single-line JSON. I drop dprint into a handler, look at the structure, and zoom in on the path that matters (event['data']['object']['lines']). The depth-aware indent and 5-item truncation mean a 50-line list collapses to five lines plus ...45 more, which is what I want when I'm just trying to confirm the shape. (The accordion inlines dprint so it runs standalone in the playground; in real use I import it from the file in accordion 1.)
# Put this in ~/.config/python/sitecustomize.py and export PYTHONSTARTUP
import builtins
try:
from dprint import dprint as _dprint
builtins.dprint = _dprint # type: ignore[attr-defined]
except ImportError:
pass
# Now in any REPL or breakpoint:
# dprint({'a': [1, 2, 3]})
print('dprint ready' if hasattr(__builtins__, 'dprint') else 'fallback active')The trick that makes this stick is wiring dprint into builtins from a sitecustomize.py, so it is available without an import inside any breakpoint() or python -i. I gate it on try/except ImportError so a stripped-down container without my dotfiles still runs. The cost is one global name, which I have decided is worth it for a tool I reach for daily. If you are on a team where polluting builtins is frowned upon, drop this step and import dprint per-file instead.
