For my first few months writing Python, I debugged complex data with print(). Every API response, every nested config, every dictionary-of-lists—all rendered as a single unreadable line that wrapped off my terminal.

Then a senior engineer watched me squint at a 500-character blob and asked, "Why aren't you using pprint?"

That question changed how I debug Python code. Here's everything I've learned about the pprint module and why it's now my go-to for understanding what's actually in my data.

The Problem pprint Solves

Let me show you what I used to stare at:

data = {
    "users": [
        {"id": 1, "name": "Alice", "email": "alice@example.com", "roles": ["admin", "user"], "settings": {"theme": "dark", "notifications": True}},
        {"id": 2, "name": "Bob", "email": "bob@example.com", "roles": ["user"], "settings": {"theme": "light", "notifications": False}},
    ],
    "metadata": {"version": "2.1.0", "generated": "2026-03-22T10:30:00Z", "count": 2},
}
 
print(data)

Output:

{'users': [{'id': 1, 'name': 'Alice', 'email': 'alice@example.com', 'roles': ['admin', 'user'], 'settings': {'theme': 'dark', 'notifications': True}}, {'id': 2, 'name': 'Bob', 'email': 'bob@example.com', 'roles': ['user'], 'settings': {'theme': 'light', 'notifications': False}}], 'metadata': {'version': '2.1.0', 'generated': '2026-03-22T10:30:00Z', 'count': 2}}

One line. No indentation. Finding "Bob's notification setting" in that blob requires careful scanning and bracket-counting.

Now with pprint:

from pprint import pprint
 
pprint(data)

Output:

{'metadata': {'count': 2,
              'generated': '2026-03-22T10:30:00Z',
              'version': '2.1.0'},
 'users': [{'email': 'alice@example.com',
            'id': 1,
            'name': 'Alice',
            'roles': ['admin', 'user'],
            'settings': {'notifications': True, 'theme': 'dark'}},
           {'email': 'bob@example.com',
            'id': 2,
            'name': 'Bob',
            'roles': ['user'],
            'settings': {'notifications': False, 'theme': 'light'}}]}

Immediately scannable. I can see the structure at a glance—two users, each with nested settings. Finding Bob's notification setting takes a second.

pprint Basics

The simplest use is just replacing print() with pprint():

from pprint import pprint
 
# Instead of print(some_dict)
pprint(some_dict)

That's it for basic usage. Import, call, done.

But pprint has parameters that make it even more useful. Let me walk through the ones I use constantly.

Width: Controlling Line Length

By default, pprint targets 80-character lines. When your data fits on one line within that width, it stays compact:

from pprint import pprint
 
short_list = [1, 2, 3, 4, 5]
pprint(short_list)
# [1, 2, 3, 4, 5]

But sometimes you want to force expansion—maybe you're comparing two structures and want them formatted identically. The width parameter controls this:

# Force narrow width to see structure
pprint(short_list, width=10)
# [1,
#  2,
#  3,
#  4,
#  5]
 
# Or go wider for dense data
pprint(large_dict, width=120)

I usually drop width to around 40-60 when debugging nested structures. It forces more line breaks, which makes the nesting levels obvious.

Depth: When You Don't Need All the Details

Sometimes data structures go deep. Like, really deep. Nested configs, recursive tree structures, API responses with embedded objects inside embedded objects.

The depth parameter limits how far pprint goes before giving up and printing ...:

from pprint import pprint
 
deep_structure = {
    "level1": {
        "level2": {
            "level3": {
                "level4": {
                    "level5": "finally some data"
                }
            }
        }
    }
}
 
# See everything
pprint(deep_structure)
# {'level1': {'level2': {'level3': {'level4': {'level5': 'finally some data'}}}}}
 
# Limit depth
pprint(deep_structure, depth=2)
# {'level1': {'level2': {...}}}
 
pprint(deep_structure, depth=3)
# {'level1': {'level2': {'level3': {...}}}}

This is invaluable when you're working with something like a Kubernetes config or a deeply nested JSON response. You get the shape without drowning in details.

Real Example: Debugging API Responses

I hit this constantly with API responses that embed related objects:

# GitHub API response with nested data
response = {
    "repository": {
        "name": "my-repo",
        "owner": {
            "login": "username",
            "id": 12345,
            "avatar_url": "...",
            "organizations": [{"name": "org1", "members": [...]}, ...],
            # ... 20 more fields
        },
        "branches": [
            {"name": "main", "protection": {"rules": [...]}},
            # ... many more
        ],
    }
}
 
# First pass: see the shape
pprint(response, depth=2)
# Shows repository > name, owner {...}, branches [...]
 
# Then drill down
pprint(response["repository"]["owner"], depth=1)

Start shallow, then go deeper where you need to.

Compact: Dense but Readable

The compact parameter (added in Python 3.8) tells pprint to fit as many items as possible on each line, while still respecting the width limit:

from pprint import pprint
 
numbers = list(range(20))
 
# Normal: one per line if it doesn't fit
pprint(numbers, width=40)
# [0,
#  1,
#  2,
#  3,
#  ... every number on its own line
 
# Compact: pack them in
pprint(numbers, width=40, compact=True)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
#  10, 11, 12, 13, 14, 15, 16,
#  17, 18, 19]

I use compact=True for lists of simple values—numbers, strings, IDs. It keeps the output scannable without wasting vertical space.

Sorting Dicts: Consistent Output

Before Python 3.7, dicts didn't preserve insertion order. pprint historically sorted keys to make output deterministic. That default stuck around.

Sometimes sorted keys aren't what you want. If your dict has a meaningful order (like steps in a pipeline), sorting scrambles it:

from pprint import pprint
 
pipeline = {
    "step_1_fetch": {"url": "..."},
    "step_2_parse": {"format": "json"},
    "step_3_validate": {"schema": "..."},
    "step_4_store": {"table": "results"},
}
 
# Default: sorted alphabetically
pprint(pipeline)
# {'step_1_fetch': {...},
#  'step_2_parse': {...},
#  'step_3_validate': {...},
#  'step_4_store': {...}}
# (This happens to be the same, but imagine steps named differently)
 
# Preserve insertion order
pprint(pipeline, sort_dicts=False)

I generally use sort_dicts=False when I care about order. When I don't care, sorted keys make comparisons easier—two dicts with the same keys will always print identically.

pformat: When You Need a String

pprint() writes directly to stdout (or whatever stream you specify). But often I need the formatted string itself—for logging, saving to a file, or building a larger message.

That's what pformat() is for:

from pprint import pformat
 
data = {"users": [...], "config": {...}}
 
# Get the formatted string
formatted = pformat(data)
 
# Now you can do whatever with it
log.debug(f"Received data:\n{formatted}")
 
with open("debug_output.txt", "w") as f:
    f.write(formatted)
 
error_message = f"Unexpected structure:\n{formatted}"
raise ValueError(error_message)

pformat Accepts the Same Parameters

formatted = pformat(
    data,
    width=60,
    depth=3,
    compact=True,
    sort_dicts=False,
)

I use pformat way more than pprint in production code. pprint is for quick debugging at the console; pformat is for logging and error messages.

PrettyPrinter: Reusable Configuration

If you're using the same formatting options repeatedly, create a PrettyPrinter instance:

from pprint import PrettyPrinter
 
# Configure once
pp = PrettyPrinter(
    indent=2,        # Spaces per nesting level
    width=60,        # Target line width
    depth=4,         # Max depth before ...
    compact=True,    # Pack items densely
    sort_dicts=False # Preserve insertion order
)
 
# Use everywhere
pp.pprint(data1)
pp.pprint(data2)
 
# Also has pformat
formatted = pp.pformat(data3)

Why Bother?

Consistency. When debugging a complex issue, I want all my debug output formatted the same way. Setting up a PrettyPrinter means I don't have to remember to pass width=60, sort_dicts=False every time.

My Debug Printer

I keep this in my toolkit:

from pprint import PrettyPrinter
 
# Narrow width forces clear nesting, preserve dict order
debug_pp = PrettyPrinter(width=50, depth=5, sort_dicts=False)
 
def debug(*args):
    """Quick debug print with formatting."""
    for arg in args:
        debug_pp.pprint(arg)

Common Patterns

Let me share the patterns I actually use in real code.

Pattern 1: Debug Logger

The most common pattern—integrating pformat with Python logging:

import logging
from pprint import pformat
 
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
 
def process_request(request_data: dict) -> dict:
    logger.debug(f"Incoming request:\n{pformat(request_data, depth=3)}")
    
    result = do_processing(request_data)
    
    logger.debug(f"Response:\n{pformat(result, depth=3)}")
    return result

The depth limit is important here—you don't want log files full of 1000-line pretty-prints. Show enough structure to debug, but not everything.

Pattern 2: Exception Context

When raising exceptions, include formatted data:

from pprint import pformat
 
def validate_config(config: dict):
    required = {"database", "api_key", "environment"}
    missing = required - config.keys()
    
    if missing:
        raise ValueError(
            f"Missing required config keys: {missing}\n"
            f"Provided config:\n{pformat(config)}"
        )

When this exception hits your logs, you'll see exactly what config was provided. No more "I wonder what was in that dict."

Pattern 3: Test Assertions

When tests fail on complex data, the default diff can be hard to read. I format expected vs actual:

from pprint import pformat
 
def assert_dicts_equal(actual, expected, msg=""):
    if actual != expected:
        raise AssertionError(
            f"{msg}\n"
            f"Expected:\n{pformat(expected)}\n"
            f"Actual:\n{pformat(actual)}"
        )

Though honestly, pytest's built-in assertion rewriting is usually good enough. I use this pattern more in non-pytest contexts.

Pattern 4: Configuration Dump

At startup, log the effective configuration (with secrets masked):

from pprint import pformat
import logging
 
logger = logging.getLogger(__name__)
 
def log_config(config: dict):
    """Log config at startup, masking sensitive values."""
    safe_config = mask_secrets(config)
    logger.info(f"Starting with configuration:\n{pformat(safe_config)}")
 
def mask_secrets(d: dict) -> dict:
    """Replace sensitive values with *****."""
    sensitive_keys = {"password", "api_key", "secret", "token"}
    result = {}
    for k, v in d.items():
        if isinstance(v, dict):
            result[k] = mask_secrets(v)
        elif any(s in k.lower() for s in sensitive_keys):
            result[k] = "*****"
        else:
            result[k] = v
    return result

Pattern 5: API Response Inspection

When debugging API integrations, I dump responses to understand their structure:

from pprint import pprint
import httpx
 
def inspect_api(url: str):
    """Quick inspection of an API endpoint."""
    response = httpx.get(url)
    
    print(f"Status: {response.status_code}")
    print(f"\nHeaders:")
    pprint(dict(response.headers), depth=1)
    
    if response.headers.get("content-type", "").startswith("application/json"):
        print(f"\nBody (depth=3):")
        pprint(response.json(), depth=3)
    else:
        print(f"\nBody: {response.text[:200]}...")

Pattern 6: REPL Enhancement

In interactive sessions, make pprint the default:

import sys
from pprint import pprint
 
# Every expression result gets pretty-printed
sys.displayhook = lambda x: pprint(x) if x is not None else None

Or just import pprint and use it manually. But for long exploratory sessions, this saves a lot of typing.

pprint vs json.dumps: Which to Use?

Both can format nested structures. Here's when I use each:

import json
from pprint import pformat
 
data = {"name": "Alice", "scores": [95, 87, 92], "active": True}
 
# pprint: Python repr format
print(pformat(data))
# {'active': True, 'name': 'Alice', 'scores': [95, 87, 92]}
 
# json.dumps: JSON format
print(json.dumps(data, indent=2))
# {
#   "name": "Alice",
#   "scores": [95, 87, 92],
#   "active": true
# }

Use pformat when:

  • Debugging Python code
  • Data contains Python-specific types (sets, tuples, custom objects)
  • You want valid Python output you could paste back
  • Working with Python devs who'll read the output

Use json.dumps when:

  • Output needs to be valid JSON
  • Sharing with non-Python systems
  • The data is already JSON-like (dicts, lists, strings, numbers, bools, None)
  • You need specific JSON formatting (indent, separators)

I use pformat for debugging and json.dumps for APIs/configs.

Handling Non-Standard Types

One thing that tripped me up: pprint uses repr() for objects it doesn't know how to format. If your class doesn't have a useful __repr__, you'll see something like:

<__main__.User object at 0x7f8b8c0d2e90>

The fix is to add a __repr__ method:

from dataclasses import dataclass
 
@dataclass
class User:
    name: str
    email: str
    
# dataclass auto-generates __repr__
from pprint import pprint
pprint([User("Alice", "alice@ex.com"), User("Bob", "bob@ex.com")])
# [User(name='Alice', email='alice@ex.com'),
#  User(name='Bob', email='bob@ex.com')]

If you're working with objects from libraries that have bad repr, you might need to convert to dicts first:

# For objects with __dict__
pprint(vars(some_object))
 
# For dataclasses
from dataclasses import asdict
pprint(asdict(some_dataclass))

The Parameters Cheat Sheet

ParameterDefaultWhat It Does
width80Target line width before wrapping
depthNoneMax nesting depth (None = unlimited)
indent1Spaces per indentation level
compactFalsePack items densely within width
sort_dictsTrueAlphabetize dict keys
streamstdoutWhere to write output

Common combinations I use:

# Debugging: narrow, preserve order
pprint(data, width=50, sort_dicts=False)
 
# Overview of deep structure
pprint(data, depth=2)
 
# Dense list data
pprint(items, compact=True)
 
# Log-friendly
pformat(data, width=100, depth=4)

Final Thoughts

pprint is one of those modules that seems trivial until you actually use it. Then you wonder how you ever debugged without it.

The key insights for me were:

  1. Use it everywhere during debugging. The two seconds to import pprint saves minutes of squinting at collapsed output.

  2. pformat for production code. Integrate it with your logger so complex data is always readable in logs.

  3. depth limits matter. Don't dump 1000-line structures into your logs. Show enough to debug, not everything.

  4. It's in the standard library. No pip install, no dependencies. Just import and go.

Start using pprint today. Your future self—the one debugging at 2 AM—will thank you.

React to this post: