How To Get CPU Core Usage with psutil in Python

How To Get CPU Core Usage with psutil in Python

Intermediate

Your server is running slow, but top shows average CPU at 45% — nothing alarming. Then a colleague points out that core 3 has been pinned at 100% for the last hour while the other seven cores sit idle. A single-threaded bottleneck is strangling your app, invisible to anyone watching only the aggregate number. This is exactly the kind of problem you cannot catch without per-core monitoring, and Python makes it surprisingly easy to build.

The psutil library gives you cross-platform access to CPU usage per core, per-core clock frequency, per-core time breakdowns (user, system, idle), and memory statistics — all in a few lines of Python. It works identically on Windows, macOS, and Linux without requiring root access or system-specific tools like top, htop, or Task Manager. Install it once with pip and you are ready to go.

In this article we will cover everything you need to build a CPU monitoring tool with psutil. We start with a Quick Example so you get per-core numbers immediately. Then we dig into cpu_percent(), physical vs logical core counts, per-core frequency with cpu_freq(), time breakdowns with cpu_times(), memory monitoring, and threshold-based alerting. By the end you will have a real-time terminal dashboard you can point at any machine.

Getting Per-Core CPU Usage: Quick Example

Let us start with the most useful function in psutil for this task. The key is the percpu=True flag on cpu_percent() — without it you get one aggregate number; with it you get a list of percentages, one per logical core.

# quick_cpu_check.py
import psutil
import time

# Pass interval=1 to measure over a 1-second window (recommended)
# percpu=True returns a list -- one value per logical CPU core
core_usage = psutil.cpu_percent(interval=1, percpu=True)

print(f"Logical cores detected: {len(core_usage)}")
print()

for i, pct in enumerate(core_usage):
    bar = "#" * int(pct / 5)
    print(f"  Core {i:>2}: {pct:5.1f}%  [{bar:<20}]")

print()
print(f"  Overall: {psutil.cpu_percent(interval=None):.1f}%")

Output:

Logical cores detected: 8

  Core  0:  23.4%  [####                ]
  Core  1:   8.1%  [#                   ]
  Core  2:  91.3%  [##################  ]
  Core  3:   6.2%  [#                   ]
  Core  4:  12.7%  [##                  ]
  Core  5:   9.4%  [#                   ]
  Core  6:  17.6%  [###                 ]
  Core  7:   5.0%  [#                   ]

  Overall: 21.7%

The output instantly reveals that core 2 is at 91% while the overall average looks benign at 21.7%. That discrepancy is exactly what aggregate monitoring misses. The interval=1 parameter tells psutil to collect a sample, wait one second, collect another, and return the difference -- this gives you a meaningful measurement rather than a snapshot that could be zero. The len(core_usage) check tells you how many logical cores the machine has, which varies from 2 on a budget laptop to 128 on a high-end server.

The rest of this article explains how each piece works, adds frequency and memory data, and builds toward a live refreshing terminal dashboard. Read on for the details, or jump straight to the Real-Life Example if you want the full script now.

What is psutil and Why Use It?

psutil (process and system utilities) is a cross-platform library for retrieving information on running processes and system utilization -- CPU, memory, disks, network, and sensors. It wraps the underlying OS interfaces (/proc on Linux, sysctl on macOS, Win32 API on Windows) so your Python code runs unchanged on all three platforms.

The alternative to psutil is platform-specific shell commands: mpstat -P ALL 1 on Linux, sysctl hw.perflevel0.physicalcpu on macOS, or WMI queries on Windows. You could parse their output with subprocess, but you would need separate code paths for each OS and your script would break every time the command output format changes. psutil solves all of that.

MethodPlatformRoot RequiredPer-Core DataPython API
psutilWindows / macOS / LinuxNoYesYes -- clean objects
mpstatLinux onlyNoYesParse subprocess output
top / htopUnix-likeNoYesNo -- interactive only
WMIWindows onlyAdmin for somePartialVia pywin32
/proc/statLinux onlyNoYesManual file parsing

Install psutil with pip -- it has no dependencies and compiles quickly:

# install_psutil.sh
pip install psutil

Once installed you can import it and immediately start querying system metrics. The sections below walk through each function you need for CPU monitoring.

Python developer at terminal showing per-core CPU usage bars with psutil
cpu_percent(percpu=True) -- because one number hides too much.

Logical vs Physical Cores: What cpu_count() Returns

Before diving deeper into usage numbers, it helps to understand what "core" actually means here. Modern CPUs expose more logical cores than they have physical cores because of hyperthreading (Intel) or SMT (AMD). A 4-core chip with hyperthreading shows up as 8 logical cores. psutil lets you query both counts.

# core_count.py
import psutil

logical = psutil.cpu_count(logical=True)   # includes hyperthreads
physical = psutil.cpu_count(logical=False)  # physical cores only

print(f"Physical cores:  {physical}")
print(f"Logical cores:   {logical}")
print(f"Hyperthreading:  {'Yes' if logical > physical else 'No'}")
print(f"HT ratio:        {logical // physical}x" if physical else "")

Output:

Physical cores:  4
Logical cores:   8
Hyperthreading:  Yes
HT ratio:        2x

The number of items in the list returned by cpu_percent(percpu=True) always matches cpu_count(logical=True) -- you get one entry per logical core. Physical core count matters for workloads that benefit from true parallelism (CPU-bound Python processes, for example) vs workloads that are mostly I/O-bound and can share a core fine. Knowing the physical count also helps you interpret the per-core usage: if logical cores 0 and 1 are both busy, that is likely one physical core under full load.

Per-Core Frequency with cpu_freq()

CPU frequency tells you whether a core is running at full speed or has been throttled by thermal limits. Modern processors use dynamic frequency scaling: they boost above the rated speed when the workload demands it (and the chip is cool enough), and throttle down to save power or prevent overheating.

# cpu_frequency.py
import psutil

# percpu=True returns a list of scpufreq namedtuples
freqs = psutil.cpu_freq(percpu=True)

if freqs:
    print(f"{'Core':<8} {'Current MHz':>12} {'Min MHz':>10} {'Max MHz':>10}")
    print("-" * 44)
    for i, f in enumerate(freqs):
        print(f"Core {i:<3}  {f.current:>10.0f}   {f.min:>9.0f}  {f.max:>9.0f}")
else:
    # Some Linux VMs do not expose per-core frequency
    overall = psutil.cpu_freq()
    print(f"Per-core freq not available. Overall: {overall.current:.0f} MHz")

Output:

Core     Current MHz    Min MHz    Max MHz
--------------------------------------------
Core 0      3600        800       4200
Core 1      4100        800       4200
Core 2      4200        800       4200
Core 3      3200        800       4200
Core 4      3800        800       4200
Core 5      4000        800       4200
Core 6      4200        800       4200
Core 7      2900        800       4200

A core sitting at its maximum frequency (4200 MHz here) that also shows high CPU usage is healthy -- it is working hard and boosting as designed. A core showing high CPU usage but stuck at minimum frequency (800 MHz) is likely being throttled due to heat, and you have a cooling problem rather than a workload problem. The defensive check for if freqs: is important: some virtualized Linux environments do not expose per-core frequency and return an empty list.

Developer diagnosing a throttled CPU core with psutil cpu_freq
Throttled cores are the silent killers. psutil.cpu_freq() exposes them.

Per-Core Time Breakdown with cpu_times()

CPU usage percentage tells you HOW MUCH a core is working, but not what it is doing. cpu_times() breaks the time a CPU has spent into categories: user space (your code), kernel space (system calls), idle, and on Linux you also get I/O wait and steal time (from hypervisor overhead in VMs).

# cpu_times_breakdown.py
import psutil

times = psutil.cpu_times(percpu=True)

print(f"{'Core':<6} {'User%':>7} {'Sys%':>7} {'Idle%':>7} {'IOWait%':>9}")
print("-" * 40)

for i, t in enumerate(times):
    total = t.user + t.system + t.idle + getattr(t, 'iowait', 0.0)
    if total == 0:
        continue
    user_pct   = t.user   / total * 100
    sys_pct    = t.system / total * 100
    idle_pct   = t.idle   / total * 100
    iowait_pct = getattr(t, 'iowait', 0.0) / total * 100
    print(f"Core {i:<1}  {user_pct:>7.1f} {sys_pct:>7.1f} {idle_pct:>7.1f} {iowait_pct:>9.1f}")

Output:

Core   User%    Sys%   Idle%   IOWait%
----------------------------------------
Core 0   18.2     4.1    77.7       0.0
Core 1    6.5     1.6    91.9       0.0
Core 2   88.4     2.9     8.7       0.0
Core 3    5.1     1.1    93.8       0.0
Core 4   11.3     0.8    87.9       0.1
Core 5    8.7     0.9    90.4       0.0
Core 6   15.2     1.4    83.4       0.0
Core 7    4.2     0.6    95.2       0.0

Note the getattr(t, 'iowait', 0.0) pattern. The iowait field only exists on Linux; using getattr with a default keeps the code portable to macOS and Windows. A core with high user% is running application code. High sys% means lots of system calls (file I/O, socket operations). High iowait% means the core is waiting on storage -- often a sign that your database or file access is the real bottleneck, not CPU.

Memory Monitoring: virtual_memory()

CPU monitoring is rarely useful in isolation -- memory pressure often causes CPU spikes as the OS spends cycles on swapping. Adding memory data to your monitor gives a more complete picture.

# memory_check.py
import psutil

mem = psutil.virtual_memory()
swap = psutil.swap_memory()

def fmt_bytes(n):
    for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
        if n < 1024:
            return f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} PB"

print("RAM:")
print(f"  Total:     {fmt_bytes(mem.total)}")
print(f"  Available: {fmt_bytes(mem.available)}")
print(f"  Used:      {fmt_bytes(mem.used)}  ({mem.percent:.1f}%)")
print(f"  Buffers:   {fmt_bytes(getattr(mem, 'buffers', 0))}")
print(f"  Cached:    {fmt_bytes(getattr(mem, 'cached', 0))}")
print()
print("Swap:")
print(f"  Total:     {fmt_bytes(swap.total)}")
print(f"  Used:      {fmt_bytes(swap.used)}  ({swap.percent:.1f}%)")

Output:

RAM:
  Total:     15.9 GB
  Available: 9.3 GB
  Used:      6.1 GB  (38.7%)
  Buffers:   312.0 MB
  Cached:    4.2 GB

Swap:
  Total:     2.0 GB
  Used:      0.0 MB  (0.0%)

The mem.available field is the most actionable metric here -- it is not the same as mem.total - mem.used. Available includes memory that is currently used for caches but can be reclaimed immediately by applications. If mem.available drops near zero while swap.percent climbs, your machine is under genuine memory pressure and performance will degrade. The getattr calls on buffers and cached guard against Windows, which does not expose those fields.

Developer overloading server RAM visualizing memory pressure
swap.percent > 0 is the system's way of asking for help.

Threshold Alerting: Raising Warnings When Cores Spike

Collecting metrics is only useful if something reacts to them. The next step is adding threshold checks so your monitoring code can trigger an alert, write to a log file, or send a notification when a core crosses a usage limit you define.

# cpu_alerts.py
import psutil
import time
import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
)

CPU_WARN_PCT  = 70.0   # warn if any single core exceeds this
CPU_CRIT_PCT  = 90.0   # critical if any core exceeds this
MEM_WARN_PCT  = 80.0   # warn if RAM usage exceeds this
CHECK_INTERVAL = 5     # seconds between checks

def check_once():
    per_core = psutil.cpu_percent(interval=1, percpu=True)
    mem = psutil.virtual_memory()

    for i, pct in enumerate(per_core):
        if pct >= CPU_CRIT_PCT:
            logging.critical("Core %d at %.1f%% -- CRITICAL", i, pct)
        elif pct >= CPU_WARN_PCT:
            logging.warning("Core %d at %.1f%% -- high usage", i, pct)

    if mem.percent >= MEM_WARN_PCT:
        logging.warning("RAM at %.1f%% -- available: %.1f GB",
                        mem.percent, mem.available / 1e9)

if __name__ == "__main__":
    logging.info("Starting CPU/memory monitor (Ctrl+C to stop)")
    try:
        while True:
            check_once()
            time.sleep(CHECK_INTERVAL)
    except KeyboardInterrupt:
        logging.info("Monitor stopped.")

Output:

09:14:01 [INFO] Starting CPU/memory monitor (Ctrl+C to stop)
09:14:02 [WARNING] Core 2 at 73.5% -- high usage
09:14:07 [CRITICAL] Core 2 at 94.1% -- CRITICAL
09:14:12 [CRITICAL] Core 2 at 98.7% -- CRITICAL
09:14:17 [INFO] Monitor stopped.

Using the standard logging module rather than print() means you can redirect this output to a file with one line change (filename="monitor.log" in the basicConfig call), or hook it into any structured logging pipeline. The CHECK_INTERVAL constant separated from cpu_percent(interval=1) is intentional -- the interval on cpu_percent controls measurement accuracy, while CHECK_INTERVAL controls how often you act on the results.

Real-Life Example: Live Terminal CPU Dashboard

Let us combine everything into a dashboard that refreshes in place every two seconds, showing per-core bars, frequency, and memory -- all in one compact terminal view.

Developer satisfied at terminal showing live CPU usage dashboard built with psutil
A dashboard that actually tells you which core is screaming.
# cpu_dashboard.py
import psutil
import time
import os

CPU_WARN  = 70.0
CPU_CRIT  = 90.0
REFRESH   = 2.0   # seconds between refreshes

def color(pct):
    """Return ANSI color code based on usage percentage."""
    if pct >= CPU_CRIT:
        return "\033[91m"   # bright red
    if pct >= CPU_WARN:
        return "\033[93m"   # yellow
    return "\033[92m"       # green

RESET = "\033[0m"

def make_bar(pct, width=24):
    filled = int(pct / 100 * width)
    return "#" * filled + "-" * (width - filled)

def render():
    os.system("cls" if os.name == "nt" else "clear")
    print("=" * 56)
    print("  psutil CPU Dashboard -- press Ctrl+C to exit")
    print("=" * 56)

    per_core = psutil.cpu_percent(interval=1, percpu=True)
    freqs    = psutil.cpu_freq(percpu=True) or []
    mem      = psutil.virtual_memory()
    logical  = psutil.cpu_count(logical=True)
    physical = psutil.cpu_count(logical=False)

    print(f"  Cores: {physical} physical / {logical} logical\n")

    for i, pct in enumerate(per_core):
        freq_str = ""
        if i < len(freqs):
            freq_str = f"  {freqs[i].current:>5.0f} MHz"
        bar = make_bar(pct)
        c = color(pct)
        print(f"  Core {i:>2}: {c}[{bar}]{RESET} {pct:5.1f}%{freq_str}")

    avg = sum(per_core) / len(per_core) if per_core else 0
    print(f"\n  Avg:    [{make_bar(avg)}] {avg:5.1f}%")
    print()

    mem_bar = make_bar(mem.percent, width=24)
    mc = color(mem.percent)
    avail_gb = mem.available / 1e9
    print(f"  RAM:    {mc}[{mem_bar}]{RESET} {mem.percent:5.1f}%  "
          f"({avail_gb:.1f} GB free)")

    swap = psutil.swap_memory()
    if swap.total > 0:
        swap_bar = make_bar(swap.percent, width=24)
        sc = color(swap.percent)
        print(f"  Swap:   {sc}[{swap_bar}]{RESET} {swap.percent:5.1f}%")

    print()
    print(f"  Updated every {REFRESH}s -- {time.strftime('%H:%M:%S')}")
    print("=" * 56)

if __name__ == "__main__":
    try:
        while True:
            render()
            time.sleep(REFRESH)
    except KeyboardInterrupt:
        print("\nDashboard stopped.")

Output (sample frame):

========================================================
  psutil CPU Dashboard -- press Ctrl+C to exit
========================================================
  Cores: 4 physical / 8 logical

  Core  0: [######------------------]  25.4%   3600 MHz
  Core  1: [#-----------------------]   8.1%   2900 MHz
  Core  2: [######################--]  91.3%   4200 MHz
  Core  3: [#-----------------------]   6.2%   3100 MHz
  Core  4: [###---------------------]  12.7%   3400 MHz
  Core  5: [##----------------------]   9.4%   3200 MHz
  Core  6: [###---------------------]  17.6%   3800 MHz
  Core  7: [#-----------------------]   5.0%   2800 MHz

  Avg:    [####--------------------]  22.0%

  RAM:    [############------------]  51.2%  (7.8 GB free)
  Swap:   [------------------------]   0.0%

  Updated every 2s -- 09:17:44
========================================================

The os.system("cls" if os.name == "nt" else "clear") call clears the terminal before each refresh, giving the appearance of an in-place update rather than scrolling output. The ANSI color codes turn critical cores red and high-usage cores yellow in any terminal that supports them (macOS Terminal, Linux terminals, Windows Terminal). To log to a file instead of the terminal, replace the render() call with the check_once() pattern from the alerting section. You can also extend this script by adding disk I/O stats with psutil.disk_io_counters(perdisk=True) or network throughput with psutil.net_io_counters(pernic=True).

Frequently Asked Questions

Why does cpu_percent() return 0.0 when I call it with no arguments?

The first call to psutil.cpu_percent() with no interval and no previous call in the same process always returns 0.0. psutil calculates CPU usage as the difference between two samples taken some time apart. The first call just sets the baseline; the second call (or a call with interval=N) returns the actual measurement. Always use interval=1 (or at least 0.1) for accurate readings, or call the function once at startup to prime it and then call it again after a small sleep.

When should I use logical=True vs logical=False in cpu_count()?

Use cpu_count(logical=True) when you want to know how many workers to create for I/O-bound tasks -- more logical cores means more threads can be useful. Use cpu_count(logical=False) for CPU-bound work where you spawn Python processes -- extra logical cores from hyperthreading rarely help CPU-bound code and can actually hurt throughput by competing for the same physical core resources. When in doubt, benchmark both: run your workload with physical workers and with logical workers and compare wall-clock time.

Does psutil need root/admin privileges?

No -- reading CPU usage percentages, frequencies, core counts, and memory stats does not require elevated permissions on Windows, macOS, or Linux. Some psutil functions DO require root, such as reading per-process memory maps or certain sensor temperatures (psutil.sensors_temperatures()). For a pure CPU and memory monitoring script like the one in this article, you can run as a regular user. If you get a psutil.AccessDenied exception, check which specific function triggered it -- it is almost certainly a process-level function, not a system-level one.

cpu_freq(percpu=True) returns an empty list on my Linux VM. What is wrong?

This is expected behavior on many virtualized Linux environments. The guest OS does not always have access to the host CPU's frequency scaling information. The psutil.cpu_freq() function reads from /sys/devices/system/cpu/cpu*/cpufreq/ on Linux, which may not be populated by the hypervisor. Some cloud VMs (AWS, GCP, Azure) intentionally withhold this data. The safe approach is to always check if freqs: before iterating, and fall back to a single aggregate call (psutil.cpu_freq(percpu=False)) or simply skip the frequency column. The CPU usage percentage from cpu_percent() remains accurate even when frequency data is unavailable.

Does this code work on Windows without any changes?

Yes, with one small caveat: the ANSI color codes in the dashboard script require Windows 10 version 1607 or later with Windows Terminal or a VT100-compatible terminal. The standard Windows Command Prompt (cmd.exe) on older Windows versions does not render ANSI codes and will display them as literal characters like [91m. You can guard against this by wrapping the ANSI output in a try/except or by using the colorama library (pip install colorama), which translates ANSI codes to Win32 console calls. Everything else -- cpu_percent(), cpu_count(), cpu_freq(), virtual_memory(), and swap_memory() -- works identically on Windows.

Can I get CPU temperature with psutil?

On Linux and some macOS hardware, yes: psutil.sensors_temperatures() returns a dictionary of sensor readings grouped by device name. The key for CPU cores is usually 'coretemp' or 'k10temp' depending on the chip. Each entry has current, high, and critical temperature values in Celsius. This function is not available on Windows -- psutil simply does not expose it there because the Windows thermal sensor APIs require platform-specific third-party libraries. On unsupported platforms the call raises AttributeError, so always check hasattr(psutil, 'sensors_temperatures') before using it.

Conclusion

psutil makes per-core CPU monitoring a matter of two function calls. cpu_percent(interval=1, percpu=True) gives you a list of usage values -- one per logical core -- that reveals the imbalances a single aggregate number would hide. cpu_count(logical=True/False) tells you whether extra cores come from hyperthreading or are genuine physical cores. cpu_freq(percpu=True) shows whether cores are boosting or being throttled. cpu_times(percpu=True) breaks usage down into user, system, and iowait time so you know whether CPU cycles are spent on application code, kernel calls, or waiting on storage. And virtual_memory() and swap_memory() round out the picture by capturing memory pressure alongside CPU load.

Extend the dashboard by adding psutil.disk_io_counters(perdisk=True) for storage throughput, psutil.net_io_counters(pernic=True) for network stats, or hook the alert thresholds into a notification service like Slack or PagerDuty. You could also export metrics to a time-series database like Prometheus by wrapping the psutil calls in a Flask endpoint and adding a Prometheus client. The psutil documentation at psutil.readthedocs.io covers every available function in depth.

For deeper exploration, the Python Scalene profiler article shows how to go beyond monitoring into detailed line-level CPU and memory profiling within your own code, and the Python task automation guide covers scheduling monitoring scripts to run on a cron job.

How To Build a Desktop File Organizer in Python

How To Build a Desktop File Organizer in Python

Intermediate

Your Downloads folder is a graveyard. PDFs from three jobs ago, screenshots with names like image(47).png, ZIP archives you extracted months ago, and a dozen video files you swore you would watch. Every time you need to find something, you scroll past hundreds of unrelated files. Sound familiar? The good news is you can fix this permanently with about 50 lines of Python.

Python’s standard library gives you everything you need to build a real file organizer: pathlib for readable path manipulation, shutil for safe file moves, os for directory creation, and argparse for a proper command-line interface. No third-party packages required. The whole script runs on any machine with Python 3.6 or later installed.

In this article, we will build a desktop file organizer step by step. First, we will cover the Quick Example to get a feel for the approach. Then we will look at scanning directories with pathlib, moving files safely with shutil, organizing by file extension, and adding a CLI with dry-run mode. By the end you will have a production-ready script you can run on any folder — and set up as a scheduled task if you want it to run automatically.

Organizing a Folder: Quick Example

Here is the core idea in 25 lines. This script scans a target folder and moves every file into a subfolder named after its category (Images, Documents, Videos, and so on).

# quick_organizer.py
import shutil
from pathlib import Path

FOLDER = Path.home() / "Downloads"

EXT_MAP = {
    ".pdf": "Documents", ".docx": "Documents", ".txt": "Documents",
    ".jpg": "Images",    ".jpeg": "Images",    ".png": "Images", ".gif": "Images",
    ".mp4": "Videos",    ".mov": "Videos",     ".avi": "Videos",
    ".mp3": "Audio",     ".wav": "Audio",
    ".zip": "Archives",  ".tar": "Archives",   ".gz":  "Archives",
    ".py":  "Code",      ".js":  "Code",       ".html":"Code",
}

moved = 0
for item in FOLDER.iterdir():
    if not item.is_file():
        continue
    category = EXT_MAP.get(item.suffix.lower(), "Other")
    dest_dir = FOLDER / category
    dest_dir.mkdir(exist_ok=True)
    shutil.move(str(item), str(dest_dir / item.name))
    print(f"Moved: {item.name}  ->  {category}/")
    moved += 1

print(f"\nDone. {moved} file(s) organized.")

Output:

Moved: budget-2025.pdf  ->  Documents/
Moved: screenshot-2026-01.png  ->  Images/
Moved: project.zip  ->  Archives/
Moved: notes.txt  ->  Documents/

Done. 4 file(s) organized.

The key players are Path.iterdir() to list every item in the folder, item.suffix.lower() to get the file extension normalized to lowercase, and shutil.move() to relocate the file. The dest_dir.mkdir(exist_ok=True) call creates the category subfolder only if it does not already exist — so running the script twice is completely safe.

The sections below build this into a proper, reusable tool with a CLI, duplicate protection, dry-run mode, and a configurable extension map.

Python file organizer extension map sorted bins
shutil.move() doesn’t ask questions. It just moves.

What Is a File Organizer and Why Build One in Python?

A file organizer is a script that inspects the name or metadata of each file in a directory and moves it to a predefined location based on rules you define. The simplest rule is the one we use here: sort by file extension. An extension like .pdf tells you the file is a document; .mp4 tells you it is a video. More sophisticated organizers can read EXIF metadata from photos to sort by date, or inspect file contents for type detection — but extension-based sorting covers 90% of real-world use cases with zero added complexity.

Why Python? Because the standard library is batteries-included for this task. Compare your options:

ApproachSetupFlexibilityCross-platform
Python script (this article)No install neededFull controlYes
Folder rules in Windows ExplorerBuilt-in GUIVery limitedWindows only
Automator (macOS)Built-in GUIModeratemacOS only
Bash scriptNo installHigh (but verbose)Unix only

Python gives you the most flexibility with the least friction. Once written, you can run the same script on Windows, macOS, and Linux without changing a line.

Scanning Directories with pathlib

The pathlib module, introduced in Python 3.4, treats file paths as objects with useful methods instead of plain strings. For a file organizer, the two most important methods are Path.iterdir() — which lists everything in a single directory — and Path.rglob() — which searches recursively through nested subdirectories.

# scan_demo.py
from pathlib import Path

target = Path("/tmp/messy_folder")

# List only direct children (non-recursive)
print("Direct children:")
for item in target.iterdir():
    kind = "DIR " if item.is_dir() else "FILE"
    print(f"  [{kind}] {item.name}  (suffix: {repr(item.suffix)})")

Output:

Direct children:
  [FILE] report.pdf  (suffix: '.pdf')
  [FILE] Photo_2026.JPG  (suffix: '.JPG')
  [DIR ] old_projects  (suffix: '')
  [FILE] notes.txt  (suffix: '.txt')

Notice that item.suffix preserves the original case (e.g., '.JPG' not '.jpg'). Always call .lower() on it before looking it up in your extension map, otherwise .JPG and .jpg will be treated as different file types. The item.is_dir() check lets us skip subdirectories so we only process actual files.

For deep folder trees, swap iterdir() for rglob("*") and add an if item.is_file() check:

# recursive_scan.py
from pathlib import Path

target = Path("/tmp/messy_folder")

print("All files (recursive):")
for item in target.rglob("*"):
    if item.is_file():
        print(f"  {item.relative_to(target)}  ->  ext: {item.suffix.lower() or 'none'}")

Output:

All files (recursive):
  report.pdf  ->  ext: .pdf
  Photo_2026.JPG  ->  ext: .jpg
  notes.txt  ->  ext: .txt
  old_projects/draft.docx  ->  ext: .docx

item.relative_to(target) shows a clean path relative to the root folder rather than the full absolute path — useful for readable log output.

Python pathlib iterdir scanning folder tree for file extensions
item.suffix.lower() — because .JPG and .jpg are not the same type.

Moving Files Safely with shutil

Once you know where a file should go, shutil.move(src, dst) relocates it. The function accepts both string paths and Path objects. It handles the cross-device case automatically — if source and destination are on different drives, it copies then deletes rather than doing a raw rename that would fail.

# move_demo.py
import shutil
from pathlib import Path

src = Path("/tmp/messy_folder/report.pdf")
dst_dir = Path("/tmp/messy_folder/Documents")
dst_dir.mkdir(parents=True, exist_ok=True)

dst = dst_dir / src.name
shutil.move(str(src), str(dst))
print(f"Moved: {src.name} -> Documents/")

Output:

Moved: report.pdf -> Documents/

The dst_dir.mkdir(parents=True, exist_ok=True) call is a two-for-one: parents=True creates any missing parent directories, and exist_ok=True suppresses the error if the folder already exists. Always include both flags so the script is idempotent — safe to run multiple times on the same folder.

One edge case to handle: what if a file with the same name already exists in the destination? shutil.move() will silently overwrite it. To prevent data loss, check for a collision and rename the incoming file if needed:

# safe_move.py
import shutil
from pathlib import Path

def safe_move(src, dst_dir, dry_run=False):
    dst_dir.mkdir(parents=True, exist_ok=True)
    dst = dst_dir / src.name
    counter = 1
    while dst.exists():
        dst = dst_dir / f"{src.stem}({counter}){src.suffix}"
        counter += 1
    if not dry_run:
        shutil.move(str(src), str(dst))
    return dst

src = Path("/tmp/messy_folder/report.pdf")
final = safe_move(src, Path("/tmp/messy_folder/Documents"))
print(f"File is now at: {final}")

Output (when a report.pdf already exists in Documents/):

File is now at: /tmp/messy_folder/Documents/report(1).pdf

This pattern — loop until the target path does not exist — is simple and collision-proof. It is the same approach used by macOS Finder and Windows Explorer when you copy a file into a folder that already contains a file with the same name.

Python file extension map dictionary mapping extensions to folder names
Your extension map is a lookup table. Garbage in, chaos out.

Adding a CLI with argparse

Hard-coding the target folder path inside the script is fine for personal use but gets annoying fast. Adding an argparse CLI lets you pass the folder as an argument — and add useful flags like --dry-run to preview what would move without actually moving anything.

# cli_demo.py
import argparse
from pathlib import Path

def parse_args():
    parser = argparse.ArgumentParser(
        description="Organize files in a folder by extension."
    )
    parser.add_argument(
        "folder",
        type=Path,
        help="Path to the folder you want to organize."
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Preview what would be moved without making any changes."
    )
    parser.add_argument(
        "--recursive",
        action="store_true",
        help="Also organize files in subdirectories."
    )
    return parser.parse_args()

args = parse_args()
print(f"Target folder : {args.folder}")
print(f"Dry run       : {args.dry_run}")
print(f"Recursive     : {args.recursive}")

Output (run as: python cli_demo.py ~/Downloads –dry-run):

Target folder : /home/user/Downloads
Dry run       : True
Recursive     : False

The --dry-run flag is invaluable when you are running the organizer on an unfamiliar folder. It lets you audit the plan before committing. We will wire it into the main loop in the Real-Life Example so that passing --dry-run prints every planned move but touches nothing on disk.

Real-Life Example: A Production-Ready File Organizer

Here is the complete script combining everything above — scanning, safe moving, a full extension map, CLI arguments, dry-run mode, and a summary report at the end. Save it as organize.py and run it from anywhere.

Python argparse CLI dry-run mode file organizer terminal output
python organize.py ~/Downloads –dry-run — before you commit to anything.
# organize.py
import argparse
import shutil
from pathlib import Path

EXT_MAP = {
    ".pdf": "Documents",  ".docx": "Documents", ".txt": "Documents",
    ".xls": "Documents",  ".xlsx": "Documents", ".pptx": "Documents",
    ".csv": "Documents",  ".doc": "Documents",
    ".jpg": "Images",  ".jpeg": "Images", ".png": "Images",
    ".gif": "Images",  ".webp": "Images", ".heic": "Images",
    ".svg": "Images",  ".raw": "Images",
    ".mp4": "Videos",  ".mov": "Videos",  ".avi": "Videos",
    ".mkv": "Videos",  ".webm": "Videos",
    ".mp3": "Audio",   ".wav": "Audio",   ".aac": "Audio",
    ".flac": "Audio",  ".m4a": "Audio",
    ".zip": "Archives", ".tar": "Archives", ".gz": "Archives",
    ".rar": "Archives", ".7z": "Archives",
    ".py": "Code",  ".js": "Code",   ".ts": "Code",
    ".html": "Code", ".css": "Code", ".json": "Code",
    ".yaml": "Code", ".yml": "Code", ".ipynb": "Code",
    ".exe": "Installers", ".dmg": "Installers", ".pkg": "Installers",
}

def safe_move(src, dst_dir, dry_run):
    dst_dir.mkdir(parents=True, exist_ok=True)
    dst = dst_dir / src.name
    counter = 1
    while dst.exists():
        dst = dst_dir / f"{src.stem}({counter}){src.suffix}"
        counter += 1
    if not dry_run:
        shutil.move(str(src), str(dst))
    return dst

def organize(folder, recursive, dry_run):
    stats = {}
    iterator = folder.rglob("*") if recursive else folder.iterdir()
    for item in iterator:
        if not item.is_file() or not item.suffix:
            continue
        category = EXT_MAP.get(item.suffix.lower(), "Other")
        dst_dir = folder / category
        dst = safe_move(item, dst_dir, dry_run)
        label = "[DRY RUN] " if dry_run else ""
        print(f"  {label}{item.name}  ->  {category}/")
        stats[category] = stats.get(category, 0) + 1
    return stats

def main():
    parser = argparse.ArgumentParser(
        description="Organize files in a folder by extension."
    )
    parser.add_argument("folder", type=Path, help="Folder to organize.")
    parser.add_argument("--dry-run", action="store_true",
                        help="Preview moves without touching files.")
    parser.add_argument("--recursive", action="store_true",
                        help="Organize subdirectories too.")
    args = parser.parse_args()

    if not args.folder.is_dir():
        print(f"Error: {args.folder} is not a valid directory.")
        return

    print(f"Organizing: {args.folder}")
    if args.dry_run:
        print("(DRY RUN -- no files will be moved)\n")

    stats = organize(args.folder, args.recursive, args.dry_run)

    print("\n--- Summary ---")
    for category, count in sorted(stats.items()):
        print(f"  {category:15s} {count} file(s)")
    print(f"  {'TOTAL':15s} {sum(stats.values())} file(s)")

if __name__ == "__main__":
    main()

Output (dry-run on a cluttered Downloads folder):

Organizing: /home/user/Downloads
(DRY RUN -- no files will be moved)

  [DRY RUN] budget-2025.pdf  ->  Documents/
  [DRY RUN] photo-holiday.jpg  ->  Images/
  [DRY RUN] project.zip  ->  Archives/
  [DRY RUN] meeting-notes.docx  ->  Documents/
  [DRY RUN] setup.exe  ->  Installers/
  [DRY RUN] demo-clip.mp4  ->  Videos/

--- Summary ---
  Archives        1 file(s)
  Documents       2 file(s)
  Images          1 file(s)
  Installers      1 file(s)
  Videos          1 file(s)
  TOTAL           6 file(s)

Run it for real by dropping --dry-run: python organize.py ~/Downloads. The script is structured so you can extend it easily — add more extensions to EXT_MAP, add a --log flag that writes moves to a text file, or drop in a date-based sorting layer for photos.

Python file organizer summary report organized files by category
Summary report: because ‘it probably worked’ is not a monitoring strategy.

Frequently Asked Questions

What happens if two files have the same name?

The safe_move() function handles this with a counter loop. If report.pdf already exists in Documents/, the incoming file becomes report(1).pdf. If that also exists, it becomes report(2).pdf, and so on. This protects you from silent data overwrites that shutil.move() would otherwise allow by default.

Can I undo the organization after running it?

Not automatically — shutil.move() does not maintain an undo log. For a safety net, always run with --dry-run first to verify the plan. For a real undo capability, modify the script to write every move to a JSON log file ({"src": "...", "dst": "..."}), then write a companion script that reverses each entry. Alternatively, test on a copy of the folder before touching the original.

How do I organize files by date instead of extension?

Swap the extension lookup for a date lookup using the file’s modification time. item.stat().st_mtime returns a Unix timestamp; pass it to datetime.fromtimestamp() and use strftime("%Y/%m") to get a 2026/08 folder path. For photos with EXIF data, the Pillow library can read the DateTimeOriginal tag for more accurate dates than the filesystem timestamp.

How do I run this automatically every day?

On macOS and Linux, add a cron job: open a terminal, run crontab -e, and add 0 8 * * * python3 /path/to/organize.py ~/Downloads to run it at 8 AM daily. On Windows, use Task Scheduler and point it at a batch file that calls your Python script. Both approaches run the script silently in the background without any manual action required.

Does this script handle hidden files (dotfiles)?

Path.iterdir() returns hidden files (files starting with . on Unix, like .DS_Store or .gitignore). The script currently skips files with no extension (the if not item.suffix guard), which silently ignores many dotfiles. If you want to explicitly skip all hidden files, add if item.name.startswith("."): continue right after the is_file() check.

What if I want different category names than the defaults?

Edit the EXT_MAP dictionary directly. The values are plain strings — change "Documents" to "Docs", or "Images" to "Photos", and the script will create folders with those names. You can also load the map from a JSON config file at runtime if you want to share settings across multiple machines without editing the script itself.

Conclusion

You have built a complete, production-ready file organizer in Python using only the standard library. The key tools are pathlib.Path.iterdir() for scanning, item.suffix.lower() for extension detection, shutil.move() for relocating files, and argparse for a proper CLI with --dry-run and --recursive flags. The safe_move() helper prevents silent data overwrites by appending a counter to duplicate filenames.

From here, consider extending the script with date-based sorting for photos (using datetime.fromtimestamp(item.stat().st_mtime)), a JSON move log for undo support, or a --watch mode using the watchdog library to organize files as they arrive. The structure is already in place — each extension is another entry in the map, and each new mode is a flag away.

Official documentation: pathlib — Object-oriented filesystem paths and shutil — High-level file operations.

How To Chain and Re-raise Exceptions in Python with raise from

How To Chain and Re-raise Exceptions in Python with raise from

Intermediate

You are debugging a crash in production. The traceback lands on a generic RuntimeError("Failed to load config") — but you have no idea what actually went wrong. Was it a missing file? A JSON parse error? A permissions problem? The original exception is gone, buried somewhere inside the code that caught and re-raised it without preserving the cause. You are left guessing at the root cause from a vague error message, and that is a bad situation to be in at 2am when a service is down.

Python’s exception chaining mechanism exists to solve exactly this problem. When you catch an exception and need to raise a different one, you can use raise NewException(...) from original_exc to link the two together. The traceback then shows the full chain — both the original cause and the new exception — so whoever reads it can trace the problem all the way back to its source. Everything covered here is built into Python’s standard exception system, with no third-party packages required.

This article covers how Python’s implicit and explicit exception chaining works, how to use raise from to set an explicit cause, the difference between __cause__ and __context__, when to use raise from None to suppress the original exception, how to re-raise a caught exception cleanly with bare raise, and how to inspect exception chains programmatically. By the end you will be able to write error-handling code that gives callers accurate, complete stack traces instead of mystery messages.

Exception Chaining in Python: Quick Example

Here is a minimal example showing the two most common patterns — re-raising with a cause, and suppressing the cause — side by side:

# exception_chain_quick.py

def load_config(path):
    try:
        with open(path) as f:
            import json
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError) as exc:
        # Chain: tell the caller WHY loading failed, and preserve the original error
        raise RuntimeError(f"Failed to load config from {path}") from exc

def get_user_id(raw_value):
    try:
        return int(raw_value)
    except ValueError as exc:
        # Suppress: the ValueError is an internal detail; expose a cleaner error
        raise ValueError(f"User ID must be a number, got: {raw_value!r}") from None

# Try chaining
try:
    load_config("/nonexistent/config.json")
except RuntimeError as exc:
    print(type(exc).__name__, "->", exc)
    print("Caused by:", type(exc.__cause__).__name__, exc.__cause__)

# Try suppression
try:
    get_user_id("abc")
except ValueError as exc:
    print(type(exc).__name__, "->", exc)
    print("Caused by:", exc.__cause__)   # None -- suppressed

Output:

RuntimeError -> Failed to load config from /nonexistent/config.json
Caused by: FileNotFoundError [Errno 2] No such file or directory: '/nonexistent/config.json'
ValueError -> User ID must be a number, got: 'abc'
Caused by: None

The first case shows chaining: the RuntimeError carries its cause in __cause__, and a real traceback would print both exceptions. The second case shows suppression: the inner ValueError is discarded, and the caller sees only the clean public error. The sections below explain when to use each pattern and how the underlying machinery works.

What Is Exception Chaining and Why Does It Matter?

Every Python exception object can hold a reference to another exception. This lets you capture the full history of what went wrong, not just the final symptom. Without chaining, catching an exception and raising a new one loses all the context from the original — the stack frame, the message, the type. With chaining, a reader of the traceback sees the complete story from root cause to final failure.

Python has two forms of exception chaining. Implicit chaining happens automatically whenever you raise an exception inside an except block. Python sets exc.__context__ to the active exception automatically, so the “During handling of the above exception, another exception occurred” message in a traceback is implicit chaining at work. Explicit chaining happens when you use raise NewExc from original_exc — this sets exc.__cause__ and also sets exc.__suppress_context__ = True, which tells Python to display “The above exception was the direct cause of the following exception” instead of the implicit message.

MechanismHow it is setAttributeTraceback message
Implicit chainingRaising inside except block__context__“During handling of the above exception, another exception occurred”
Explicit chainingraise Exc from cause__cause__“The above exception was the direct cause of the following exception”
Suppressionraise Exc from NoneBoth are NoneNo chained traceback displayed

The rule of thumb: use explicit chaining (raise from) when the original exception is genuinely useful context for the caller. Use suppression (raise from None) when the original exception is an internal implementation detail that would only confuse the caller. Use bare raise when you want to re-raise the exact same exception without modification.

Python exception chain traceback on dual monitors
The full chain. No more guessing what actually broke.

Using raise from to Set an Explicit Cause

The raise NewException from original syntax explicitly links two exceptions. The new exception is what propagates to the caller; the original is attached as __cause__ and displayed in the traceback above the new exception. Use this pattern when your function catches a low-level exception (like a database error or a network timeout) and raises a higher-level one that makes sense in your abstraction layer.

# raise_from_demo.py
import json

class ConfigError(Exception):
    """Raised when configuration loading or parsing fails."""

def parse_settings(raw_json: str) -> dict:
    """Parse a JSON settings string. Raises ConfigError on failure."""
    try:
        return json.loads(raw_json)
    except json.JSONDecodeError as exc:
        raise ConfigError(
            f"Settings file contains invalid JSON at line {exc.lineno}, col {exc.colno}"
        ) from exc

# Simulate bad JSON input
bad_json = '{"timeout": 30, "host": "db.example.com"'  # missing closing brace

try:
    parse_settings(bad_json)
except ConfigError as exc:
    print(f"ConfigError: {exc}")
    print(f"__cause__ type: {type(exc.__cause__).__name__}")
    print(f"__cause__ msg:  {exc.__cause__}")
    print(f"__suppress_context__: {exc.__suppress_context__}")

Output:

ConfigError: Settings file contains invalid JSON at line 1, col 41
__cause__ type: JSONDecodeError
__cause__ msg:  Expecting property name enclosed in double quotes: line 1 column 41 (char 40)
__suppress_context__: True

The caller catches a clean ConfigError with a human-readable message, but the original JSONDecodeError is preserved in __cause__. In a real traceback (not caught by a try/except), Python would print the JSONDecodeError first with “The above exception was the direct cause of the following exception”, then print the ConfigError. Notice that __suppress_context__ is automatically set to True by raise from — this tells Python to use the “direct cause” wording instead of the implicit “during handling” wording.

Understanding __cause__ vs __context__

Python maintains both __cause__ and __context__ on every exception, and they serve different purposes. __context__ is set automatically whenever an exception is raised while another exception is already being handled — it is the exception that was active at the time. __cause__ is only set when you use raise ... from ..., and it signals an intentional, semantic relationship: “this exception was directly caused by that one.”

# cause_vs_context.py

def implicit_context():
    """Raises inside an except block -- sets __context__ automatically."""
    try:
        int("not-a-number")       # raises ValueError
    except ValueError:
        raise RuntimeError("Something went wrong internally")

def explicit_cause():
    """Uses raise from -- sets __cause__ explicitly."""
    try:
        int("not-a-number")       # raises ValueError
    except ValueError as exc:
        raise RuntimeError("Input conversion failed") from exc

def mixed():
    """Both are set when you use raise from inside an except block."""
    try:
        int("not-a-number")
    except ValueError as exc:
        new_exc = RuntimeError("Conversion error")
        raise new_exc from exc
    # new_exc.__cause__ == the ValueError (explicit)
    # new_exc.__context__ == the ValueError (implicit, same exception here)

# Inspect implicit chaining
try:
    implicit_context()
except RuntimeError as exc:
    print("=== Implicit context ===")
    print(f"__cause__:              {exc.__cause__}")
    print(f"__context__ type:       {type(exc.__context__).__name__}")
    print(f"__suppress_context__:   {exc.__suppress_context__}")

# Inspect explicit cause
try:
    explicit_cause()
except RuntimeError as exc:
    print("\n=== Explicit cause ===")
    print(f"__cause__ type:         {type(exc.__cause__).__name__}")
    print(f"__context__ type:       {type(exc.__context__).__name__}")
    print(f"__suppress_context__:   {exc.__suppress_context__}")

Output:

=== Implicit context ===
__cause__:              None
__context__ type:       ValueError
__suppress_context__:   False

=== Explicit cause ===
__cause__ type:         ValueError
__context__ type:       ValueError
__suppress_context__:   True

In the implicit case, __context__ is set (because we raised inside an except block) but __cause__ is None and __suppress_context__ is False. Python will show the “During handling” message. In the explicit case, both __cause__ and __context__ point to the same ValueError, but __suppress_context__ is True — so Python uses the “direct cause” wording and ignores __context__ for display purposes. The key rule: raise from always sets __suppress_context__ = True, which changes both the traceback wording and which exception Python chooses to display.

Two boxes labeled __cause__ and __context__ with a toggle switch
__suppress_context__ is the switch. raise from flips it.

Suppressing the Original Exception with raise from None

Sometimes you catch a low-level exception but the implementation detail it represents should not leak to the caller. A classic example: a KeyError inside a dictionary lookup that you expose as a public LookupError. If you let the KeyError surface in the traceback, callers start depending on your internal data structure — they write except KeyError instead of except LookupError, and now you cannot change the implementation. Using raise NewExc from None suppresses both __cause__ and __context__, producing a clean traceback that shows only the new exception.

# raise_from_none.py

class UserNotFoundError(Exception):
    """Public exception for the user lookup API."""

# Internal implementation uses a dict -- an implementation detail
_USER_STORE = {
    "alice": {"id": 1, "role": "admin"},
    "bob":   {"id": 2, "role": "viewer"},
}

def get_user(username: str) -> dict:
    """Look up a user by name. Raises UserNotFoundError if not found."""
    try:
        return _USER_STORE[username]    # raises KeyError internally
    except KeyError:
        # Suppress: callers should not see or depend on KeyError
        raise UserNotFoundError(f"No user named {username!r}") from None

# Caller never sees the KeyError
try:
    get_user("mallory")
except UserNotFoundError as exc:
    print(f"Caught: {exc}")
    print(f"__cause__:            {exc.__cause__}")
    print(f"__context__:          {exc.__context__}")
    print(f"__suppress_context__: {exc.__suppress_context__}")

Output:

Caught: No user named 'mallory'
__cause__:            None
__context__:          None
__suppress_context__: True

Both __cause__ and __context__ are None. This is the special case of raise from None: it sets __cause__ = None, __context__ = None, and __suppress_context__ = True, so the traceback shows only the new exception with no chaining information. Use this sparingly — only when you are certain the original exception would actively mislead callers. In most cases, preserving the cause with raise from exc is more helpful for debugging.

Re-raising Exceptions with bare raise

Sometimes you want to inspect a caught exception, take some action (log it, update a counter, release a resource), and then let it propagate unchanged. Bare raise — with no argument — re-raises the current exception exactly as it was, including all its attributes, original traceback, and any chaining information. It does not create a new exception or modify the existing one.

# bare_raise.py
import logging

logging.basicConfig(level=logging.ERROR, format="%(levelname)s: %(message)s")

_error_count = 0

def risky_parse(value: str) -> int:
    """Parse an integer, log any failure, then re-raise."""
    global _error_count
    try:
        return int(value)
    except ValueError:
        _error_count += 1
        logging.error("Parse failed for value %r (total errors: %d)", value, _error_count)
        raise   # re-raise the original ValueError unchanged

# First call -- fails
try:
    risky_parse("bad")
except ValueError as exc:
    print(f"Caller caught: {type(exc).__name__}: {exc}")
    print(f"Error count so far: {_error_count}")

# Second call -- also fails
try:
    risky_parse("also bad")
except ValueError as exc:
    print(f"Caller caught: {type(exc).__name__}: {exc}")
    print(f"Error count so far: {_error_count}")

# Third call -- succeeds
result = risky_parse("42")
print(f"Parsed successfully: {result}")

Output:

ERROR: Parse failed for value 'bad' (total errors: 1)
Caller caught: ValueError: invalid literal for int() with base 10: 'bad'
Error count so far: 1
ERROR: Parse failed for value 'also bad' (total errors: 2)
Caller caught: ValueError: invalid literal for int() with base 10: 'also bad'
Error count so far: 2
Parsed successfully: 42

The ValueError the caller receives is the original exception object — not a copy, not a new instance. Its traceback points back to the int(value) call inside risky_parse, not to the bare raise statement. This is the critical difference between bare raise and raise exc (re-raising by variable): raise exc resets the traceback start to the line containing raise exc, hiding where the exception actually originated. Always prefer bare raise when re-raising the same exception in an except block.

Carefully passing an exception object without dropping it
Bare raise. Same exception, same traceback, no fingerprints.

Inspecting Exception Chains Programmatically

For logging frameworks, error-reporting systems, or debugging utilities, you sometimes need to walk the full exception chain rather than just letting Python print it. You can traverse __cause__ and __context__ yourself to collect every exception in the chain, from the outermost to the innermost root cause.

# inspect_chain.py

def collect_chain(exc):
    """Walk the exception chain and return a list of all exceptions, outermost first."""
    chain = []
    visited = set()
    current = exc
    while current is not None and id(current) not in visited:
        chain.append(current)
        visited.add(id(current))
        # Prefer __cause__ (explicit) over __context__ (implicit)
        if current.__cause__ is not None:
            current = current.__cause__
        elif not current.__suppress_context__ and current.__context__ is not None:
            current = current.__context__
        else:
            break
    return chain

def format_chain(exc):
    """Return a structured summary of the full exception chain."""
    chain = collect_chain(exc)
    parts = []
    for i, e in enumerate(chain):
        relationship = ""
        if i == 0:
            relationship = "[outermost]"
        elif chain[i-1].__cause__ is e:
            relationship = "[direct cause]"
        else:
            relationship = "[context]"
        parts.append(f"  {relationship} {type(e).__name__}: {e}")
    return "\n".join(parts)

# Build a three-level chain
try:
    try:
        try:
            int("xyz")                          # root: ValueError
        except ValueError as e1:
            raise OSError("IO layer failed") from e1   # middle: OSError
    except OSError as e2:
        raise RuntimeError("Top-level failure") from e2  # outer: RuntimeError
except RuntimeError as final:
    print("Exception chain (outermost -> root cause):")
    print(format_chain(final))
    print()
    chain = collect_chain(final)
    print(f"Root cause: {type(chain[-1]).__name__}: {chain[-1]}")

Output:

Exception chain (outermost -> root cause):
  [outermost] RuntimeError: Top-level failure
  [direct cause] OSError: IO layer failed
  [direct cause] ValueError: invalid literal for int() with base 10: 'xyz'

Root cause: ValueError: invalid literal for int() with base 10: 'xyz'

The traversal prefers __cause__ over __context__ because explicit chaining is the intentional relationship. It also guards against cycles using a visited set — a defensive measure since circular exception chains are theoretically possible (though rare in practice). The format_chain function is a lightweight building block you can drop into any logging or error-reporting utility to include the full chain in your structured logs.

Following a chain of exception boxes to the root cause
Walk __cause__ until you hit None. That’s where it started.

Real-Life Example: Resilient Database Query Handler

Here is a practical module that wraps a database layer (simulated with a dictionary) and demonstrates all four patterns — explicit chaining, suppression, bare re-raise, and chain inspection — in a realistic context. The module translates low-level database exceptions into domain-specific ones while preserving enough context for effective debugging.

# db_handler.py
import logging

logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("db_handler")


# --- Exceptions ---

class DatabaseError(Exception):
    """Base class for all database errors in this module."""

class RecordNotFoundError(DatabaseError):
    """Raised when a queried record does not exist."""

class DataCorruptionError(DatabaseError):
    """Raised when stored data cannot be deserialized."""


# --- Simulated low-level storage ---

import json

_STORE = {
    "user:1": '{"name": "Alice", "role": "admin"}',
    "user:2": '{"name": "Bob", "role": "viewer"}',
    "user:3": '{CORRUPTED DATA}',        # simulates corruption
}

def _raw_fetch(key: str) -> str:
    """Low-level fetch -- raises KeyError if the key does not exist."""
    return _STORE[key]                   # KeyError is an implementation detail


# --- Public API ---

def get_user(user_id: int) -> dict:
    """
    Fetch a user record by ID.

    Raises:
        RecordNotFoundError: if the user does not exist.
        DataCorruptionError: if the stored data cannot be parsed.
        DatabaseError: for unexpected low-level failures.
    """
    key = f"user:{user_id}"

    # Fetch the raw record
    try:
        raw = _raw_fetch(key)
    except KeyError:
        # Suppress: KeyError is internal; the public contract is RecordNotFoundError
        raise RecordNotFoundError(f"User {user_id} not found") from None

    # Parse the record
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        # Chain: the JSON error is directly responsible and useful for debugging
        raise DataCorruptionError(
            f"Stored data for user {user_id} is not valid JSON"
        ) from exc

    # Validate expected shape
    if not isinstance(data, dict) or "name" not in data:
        raise DataCorruptionError(
            f"User {user_id} record is missing required fields"
        ) from None

    return data


def safe_get_user(user_id: int) -> dict | None:
    """
    Like get_user, but returns None for missing records instead of raising.
    Re-raises DataCorruptionError -- corruption is always worth knowing about.
    """
    try:
        return get_user(user_id)
    except RecordNotFoundError:
        return None
    except DataCorruptionError:
        logger.warning("Corruption detected for user %d -- propagating", user_id)
        raise   # bare raise: preserve the original exception and its chain


# --- Demo ---

print("=== Normal fetch ===")
user = get_user(1)
print(f"Found: {user}")

print("\n=== Missing user (suppressed) ===")
try:
    get_user(99)
except RecordNotFoundError as exc:
    print(f"RecordNotFoundError: {exc}")
    print(f"  __cause__: {exc.__cause__}")

print("\n=== Corrupted record (chained) ===")
try:
    get_user(3)
except DataCorruptionError as exc:
    print(f"DataCorruptionError: {exc}")
    print(f"  __cause__ type: {type(exc.__cause__).__name__}")
    print(f"  __cause__ msg:  {exc.__cause__}")

print("\n=== safe_get_user: missing ===")
result = safe_get_user(99)
print(f"Result: {result}")

print("\n=== safe_get_user: corrupted -- re-raised ===")
try:
    safe_get_user(3)
except DataCorruptionError as exc:
    print(f"Re-raised: {type(exc).__name__}: {exc}")
    # __cause__ is still intact from the original raise in get_user
    print(f"  __cause__ still attached: {type(exc.__cause__).__name__}")

Output:

=== Normal fetch ===
Found: {'name': 'Alice', 'role': 'admin'}

=== Missing user (suppressed) ===
RecordNotFoundError: User 99 not found
  __cause__: None

=== Corrupted record (chained) ===
DataCorruptionError: Stored data for user 3 is not valid JSON
  __cause__ type: JSONDecodeError
  __cause__ msg:  Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

=== safe_get_user: missing ===
Result: None

=== safe_get_user: corrupted -- re-raised ===
WARNING db_handler: Corruption detected for user 3 -- propagating
Re-raised: DataCorruptionError: Stored data for user 3 is not valid JSON
  __cause__ still attached: JSONDecodeError

The module demonstrates all four patterns in a coherent API. get_user uses suppression for the missing-record case (callers should not see KeyError) and explicit chaining for the corruption case (the JSONDecodeError details are valuable). safe_get_user uses bare raise to let corruption propagate without altering the exception — the __cause__ chain from the original raise in get_user is still fully intact when the caller catches it. Extend this pattern by adding retry logic around _raw_fetch, structured JSON logging of the chain using collect_chain, or a context manager that wraps arbitrary code and translates all Exception subclasses into domain errors.

Frequently Asked Questions

What is the difference between bare raise and raise exc?

Bare raise (with no argument) re-raises the current exception exactly as it was, preserving its original traceback. The traceback will point to the original site where the exception was first raised, not to the bare raise statement. raise exc (raising the caught exception by name) creates a new raise site — the traceback will now start at the line with raise exc, hiding the original origin. In an except block, always prefer bare raise unless you intentionally want to change the traceback origin. The only reason to use raise exc is if you are storing the exception and raising it later, outside of the except block where it was caught.

When should I use raise from None instead of raise from exc?

Use raise from None when the original exception is an internal implementation detail that would actively mislead callers. The classic cases are: catching a KeyError from a private dictionary and raising a public LookupError, catching a StopIteration inside a generator and re-raising a different error, or translating third-party library exceptions into your own domain exceptions when the library’s exception type would expose your dependency choices. If you are unsure, default to raise from exc — preserving context is almost always more helpful for debugging than hiding it.

How do I read a chained exception traceback?

Python prints exception chains from the oldest (innermost) exception at the top to the newest (outermost) at the bottom. Read them bottom-up to understand what failed from a user perspective, and top-down to understand the root cause. The separator line tells you the relationship: “The above exception was the direct cause of the following exception” means explicit chaining (raise from); “During handling of the above exception, another exception occurred” means implicit chaining (raised in an except block without from). The outermost exception (at the very bottom) is the one your except clause catches.

Can exception chaining happen in a finally block?

Yes, and it can be surprising. If an exception is raised inside a finally block while another exception is already propagating, Python sets __context__ on the new exception, creating an implicit chain. The new exception from the finally block replaces the original as the propagating exception, but the original is still accessible via __context__. This means a noisy finally block (one that can itself raise) can swallow or obscure the original error. The fix is to wrap the body of your finally block in its own try/except and handle errors there, so cleanup failures do not mask the original exception.

Do I need to do anything special for custom exception classes?

No — all exception chaining behavior is inherited from BaseException, so any class that inherits from Exception (directly or indirectly) already has __cause__, __context__, and __suppress_context__ as built-in attributes. You do not need to add anything to your custom exception class to use raise from with it. If you want to add extra context fields (like a request ID or a user ID) that travel with the exception, define them as constructor arguments and store them as instance attributes — they will be accessible alongside the chaining attributes.

Does exception chaining work the same way in async code?

Yes, exception chaining works identically in async code. raise from, bare raise, and __cause__/__context__ all behave the same inside async def functions, async with blocks, and async for loops. The only complication is with asyncio.TaskGroup or asyncio.gather: when multiple tasks fail simultaneously, Python wraps them in an ExceptionGroup (Python 3.11+), which is a separate mechanism for collecting multiple simultaneous exceptions. The except* syntax handles those. For single-task async error handling, use the same patterns covered in this article.

Conclusion

In this article we covered how Python’s two exception chaining mechanisms work — implicit chaining via __context__ and explicit chaining via raise from and __cause__. We saw how to use raise NewExc from original to preserve context across abstraction boundaries, how raise from None suppresses the original exception when it would only mislead callers, and how bare raise re-raises the current exception without altering its traceback origin. The collect_chain utility showed how to walk the chain programmatically for logging and reporting, and the database handler example tied all four patterns together in a realistic API.

A good next step is to add collect_chain to your application’s logging configuration so that every logged exception automatically includes its full chain, not just the outermost message. For structured logging (JSON logs), serialize each exception in the chain as a separate entry in a list field — that way log analysis tools can filter by root cause type without parsing traceback strings. The official Python documentation on exception chaining covers the language spec in full detail, and PEP 3134 is the original proposal that introduced the mechanism — it explains the design rationale clearly and is worth reading if you want to understand why the two-chain model exists.

How To Manage Multiple Context Managers with contextlib.ExitStack

How To Manage Multiple Context Managers with contextlib.ExitStack

Intermediate

You are writing a data pipeline that opens five CSV files, acquires a threading lock, and connects to a database — all at once. The classic with statement handles one or two resources cleanly, but by the time you have five nested with blocks, your code has drifted four indentation levels to the right and cleanup order is anyone’s guess. Worse, the number of files is only known at runtime, so you cannot even write five static with statements — you need to open them in a loop.

Python’s contextlib.ExitStack was designed exactly for this situation. It is a context manager that manages other context managers, letting you enter and exit any number of them — determined at runtime — with a single with block. Everything in the standard library that works with with works with ExitStack. No third-party packages required.

This article covers what ExitStack is and when to reach for it, how to add context managers dynamically with enter_context(), how to register arbitrary cleanup functions with callback(), how error handling and suppression work, and patterns for optional or conditional resources. By the end you will have built a real multi-file log merger that opens any number of files cleanly and guarantees cleanup even when exceptions occur.

ExitStack in Python: Quick Example

Here is the minimal case: opening a variable number of files with ExitStack and reading from all of them, with guaranteed cleanup regardless of errors.

# exitstack_quick.py
import contextlib
import tempfile
import os

# Create three temporary files with sample data
paths = []
for i in range(3):
    f = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
    f.write(f"Line from file {i+1}\n")
    f.close()
    paths.append(f.name)

# Open all three files at once using ExitStack
with contextlib.ExitStack() as stack:
    file_handles = [stack.enter_context(open(p)) for p in paths]
    for fh in file_handles:
        print(fh.read().strip())

# Cleanup
for p in paths:
    os.unlink(p)

Output:

Line from file 1
Line from file 2
Line from file 3

The key call is stack.enter_context(open(p)). Each call activates one context manager and registers its __exit__ method with the stack. When the with block ends — normally or via exception — the stack calls every registered __exit__ in reverse order, exactly as nested with statements would. Whether you enter two context managers or twenty, the pattern stays the same.

The sections below explain how this mechanism works, cover more advanced usage including callback() and error suppression, and show patterns for optional and conditional resources.

What Is ExitStack and When Do You Need It?

A context manager in Python is any object that implements __enter__ and __exit__. The with statement calls __enter__ on entry and __exit__ on exit — even if an exception is raised. This is why with open(...) closes the file even when your code crashes halfway through reading it.

Nested with statements handle two or three resources reasonably well. But they have two hard limits: the number must be known at write time, and each level adds one indentation. ExitStack removes both limits. Think of it as a list that accumulates __exit__ callbacks as you enter context managers, then drains the list in reverse order when the block ends. The “last in, first out” cleanup order mirrors what nested with statements would produce.

SituationNested withExitStack
Fixed 2-3 resourcesFineWorks, but overkill
Fixed 4+ resourcesDeep indentationFlat, readable
Variable number of resourcesNot possibleDesigned for this
Conditional resource (open only if flag is set)Awkward nested if/withenter_context() inside an if
Arbitrary cleanup callback (not a context manager)Not possiblestack.callback(fn)
Suppress exceptions from cleanupNot possible without try/exceptBuilt-in via __exit__ return value

ExitStack lives in the contextlib module, which ships with Python’s standard library — nothing to install. Import it with from contextlib import ExitStack or use contextlib.ExitStack directly.

Loop Larry juggling multiple open resource handles as nested context managers pile up
Nested with statements: indentation all the way down.

Adding Context Managers Dynamically with enter_context()

enter_context(cm) is the main API. It calls cm.__enter__(), registers cm.__exit__ with the stack, and returns whatever __enter__ returned — exactly the same value you would get from as in a plain with statement. You can call it as many times as you need, in any order, including inside loops and conditionals.

# enter_context_demo.py
import contextlib
import threading
import tempfile
import os

lock = threading.Lock()

# Open a dynamic number of files plus a lock in one block
filenames = [tempfile.mktemp(suffix='.log') for _ in range(4)]
for name in filenames:
    open(name, 'w').close()  # create them

with contextlib.ExitStack() as stack:
    # Acquire the lock first -- it is released last (LIFO order)
    stack.enter_context(lock)
    # Open all log files
    handles = [stack.enter_context(open(name, 'r')) for name in filenames]

    print(f"Lock acquired: {lock.locked()}")
    print(f"Files open: {len(handles)}")
    # ... process handles here ...

# After the block: lock released, all files closed
print(f"Lock still held: {lock.locked()}")
print(f"File closed: {handles[0].closed}")

# Cleanup temp files
for name in filenames:
    os.unlink(name)

Output:

Lock acquired: True
Files open: 4
Lock still held: False
File closed: True

Notice the cleanup order: the lock was entered first and released last. ExitStack always calls __exit__ in reverse entry order, which mirrors what you would get from innermost-to-outermost nested with blocks. If cleanup order matters for your resources — and for locks it usually does — enter them in the order you want them released in reverse.

Registering Arbitrary Cleanup with callback()

Not everything you need to clean up is a context manager. Sometimes you have a function call, a database rollback, a temp directory deletion, or a log flush that needs to run on exit. stack.callback(fn, *args, **kwargs) registers any callable as a cleanup step — it runs at the same time as __exit__ calls, in LIFO order, and is guaranteed to run even if an exception is raised.

# callback_demo.py
import contextlib
import tempfile
import shutil
import os

def create_workspace():
    """Create a temp directory and return its path."""
    return tempfile.mkdtemp(prefix='job_')

def log_cleanup(path):
    print(f"Cleaning up workspace: {path}")

with contextlib.ExitStack() as stack:
    workspace = create_workspace()
    # Register cleanup as a callback -- shutil.rmtree is not a context manager
    stack.callback(log_cleanup, workspace)
    stack.callback(shutil.rmtree, workspace, ignore_errors=True)

    # Do work inside the workspace
    output_file = os.path.join(workspace, 'result.txt')
    with open(output_file, 'w') as f:
        f.write('processing complete\n')

    print(f"Workspace exists: {os.path.isdir(workspace)}")
    print(f"Output file: {os.path.exists(output_file)}")

print(f"Workspace removed: {not os.path.isdir(workspace)}")

Output:

Workspace exists: True
Output file: True
Cleaning up workspace: /tmp/job_xyz123
Workspace removed: True

The callback registered last runs first. Here shutil.rmtree is registered before log_cleanup, so the log message prints before the directory is deleted — that is the reverse registration order. Think carefully about the order when callbacks depend on each other. Also note that callback() accepts positional and keyword arguments directly, so you never need a lambda wrapper: stack.callback(shutil.rmtree, workspace, ignore_errors=True) is cleaner and safer than stack.callback(lambda: shutil.rmtree(workspace, ignore_errors=True)).

Stack Trace Steve stacking cleanup handlers in LIFO order on an ExitStack
LIFO cleanup order. The resource you opened first is the last thing closed.

Optional and Conditional Resources

One of the most practical uses of ExitStack is managing resources that are only opened under certain conditions — for example, opening a second output file only when a --verbose flag is passed, or acquiring a distributed lock only in production. With nested with statements, conditional resources require awkward outer if blocks or dummy no-op context managers. With ExitStack, you simply call enter_context() inside a conditional.

# conditional_resource.py
import contextlib
import sys
import os
import tempfile

verbose = '--verbose' in sys.argv

# Simulate an output file path
output_path = tempfile.mktemp(suffix='.log')

with contextlib.ExitStack() as stack:
    out = open(output_path, 'w')
    stack.enter_context(out)

    # Only open a debug log if verbose mode is on
    if verbose:
        debug_path = tempfile.mktemp(suffix='.debug')
        debug = stack.enter_context(open(debug_path, 'w'))
        debug.write("debug mode active\n")
        print(f"Debug log: {debug_path}")
    else:
        debug = None
        print("Running in normal mode (no debug log)")

    out.write("result: OK\n")
    if debug:
        debug.write("result written to output\n")

print(f"Output file closed: {out.closed}")
os.unlink(output_path)

Output (without –verbose flag):

Running in normal mode (no debug log)
Output file closed: True

The single with contextlib.ExitStack() block handles both cases. When verbose is False, only the output file is registered. When it is True, both files are registered and both are closed on exit. No dummy context managers, no nested if/with combinations, no cleanup logic repeated in two branches.

Error Handling and Exception Suppression

ExitStack mirrors the exception behavior of nested with statements. If an exception occurs inside the block, it is passed to each registered __exit__ in reverse order. Any __exit__ that returns a truthy value suppresses the exception. If the exception survives all cleanup calls, it propagates normally. You can also suppress exceptions explicitly with contextlib.suppress entered onto the stack.

# error_handling.py
import contextlib

class NoisyResource:
    """A context manager that prints on entry and exit."""
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"  Opened: {self.name}")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"  Closed: {self.name} (exc={exc_type is not None})")
        return False  # do not suppress exceptions

print("--- Normal exit ---")
with contextlib.ExitStack() as stack:
    stack.enter_context(NoisyResource("A"))
    stack.enter_context(NoisyResource("B"))
    stack.enter_context(NoisyResource("C"))

print()
print("--- Exception during body ---")
try:
    with contextlib.ExitStack() as stack:
        stack.enter_context(NoisyResource("X"))
        stack.enter_context(NoisyResource("Y"))
        raise ValueError("something went wrong")
except ValueError as e:
    print(f"  Caught: {e}")

print()
print("--- Suppress a specific exception type ---")
with contextlib.ExitStack() as stack:
    stack.enter_context(contextlib.suppress(FileNotFoundError))
    stack.enter_context(NoisyResource("Z"))
    raise FileNotFoundError("missing.txt")  # suppressed
print("  Continued after suppressed exception")

Output:

--- Normal exit ---
  Opened: A
  Opened: B
  Opened: C
  Closed: C (exc=False)
  Closed: B (exc=False)
  Closed: A (exc=False)

--- Exception during body ---
  Opened: X
  Opened: Y
  Closed: Y (exc=True)
  Closed: X (exc=True)
  Caught: something went wrong

--- Suppress a specific exception type ---
  Opened: Z
  Closed: Z (exc=True)
  Continued after suppressed exception

All three resources are closed even when an exception is raised — the exception information is passed to each __exit__ but neither suppresses it, so it propagates after cleanup. The third block shows contextlib.suppress(FileNotFoundError) entered onto the stack: when FileNotFoundError is raised, the suppress context manager’s __exit__ returns True, and the exception is silently discarded.

API Alice defusing an exception while context managers safely close behind her
All context managers close, even when the body explodes.

Detaching the Stack for Deferred Cleanup

Sometimes you want to open resources and pass them elsewhere — to a class, a worker thread, or a long-lived object — and delay cleanup until that object is done. stack.pop_all() transfers all registered cleanup handlers to a new ExitStack and returns it, leaving the original empty. The new stack can then be stored as an instance variable and closed explicitly later.

# deferred_cleanup.py
import contextlib
import tempfile
import os

class FileProcessor:
    """Opens a set of files and keeps them available until close() is called."""

    def __init__(self, paths):
        self._stack = contextlib.ExitStack()
        self.handles = []
        try:
            for path in paths:
                fh = self._stack.enter_context(open(path))
                self.handles.append(fh)
        except Exception:
            # If any open fails, close what we opened so far
            self._stack.close()
            raise

    def process(self):
        for fh in self.handles:
            print(f"  Reading {fh.name}: {fh.read().strip()}")

    def close(self):
        self._stack.close()

# Create temp files
paths = []
for i in range(3):
    f = tempfile.NamedTemporaryFile(mode='w', suffix='.dat', delete=False)
    f.write(f"data from file {i+1}")
    f.close()
    paths.append(f.name)

processor = FileProcessor(paths)
print("Processing:")
processor.process()
processor.close()
print(f"File 0 closed: {processor.handles[0].closed}")

for p in paths:
    os.unlink(p)

Output:

Processing:
  Reading /tmp/tmpXXX.dat: data from file 1
  Reading /tmp/tmpXXX.dat: data from file 2
  Reading /tmp/tmpXXX.dat: data from file 3
File 0 closed: True

The try/except block inside __init__ is important: if the third file fails to open, self._stack.close() ensures the first two are also closed before the exception propagates. Without it, those file handles would leak. This pattern — open resources inside a stack, then store the stack, then close it explicitly — is the standard approach for long-lived objects that manage multiple resources.

Real-Life Example: Multi-File Log Merger

Here is a practical script that merges any number of timestamped log files into a single sorted output file. The number of input files is determined at runtime from a directory scan, making ExitStack the right tool for the job.

Sudo Sam operating a multi-stream log file merging station with ExitStack
n log files, 1 ExitStack, 0 leaked file handles.
# log_merger.py
import contextlib
import tempfile
import os
import heapq

def create_sample_logs(base_dir, num_files=4):
    """Create sample log files with interleaved timestamps."""
    import random
    paths = []
    for i in range(num_files):
        path = os.path.join(base_dir, f"service_{i+1}.log")
        with open(path, 'w') as f:
            lines = sorted(
                f"2026-08-13T0{h:02d}:{m:02d}:00 [service-{i+1}] event-{j}\n"
                for j, (h, m) in enumerate(
                    (random.randint(0, 9), random.randint(0, 59))
                    for _ in range(5)
                )
            )
            f.writelines(lines)
        paths.append(path)
    return paths

def merge_logs(input_paths, output_path):
    """Merge sorted log files into one sorted output file."""
    with contextlib.ExitStack() as stack:
        # Open all input files dynamically
        handles = [stack.enter_context(open(p)) for p in input_paths]
        # Open the output file on the same stack
        out = stack.enter_context(open(output_path, 'w'))

        # Register a callback to print a summary on exit
        line_count = [0]
        def report():
            print(f"  Merged {len(handles)} files -> {line_count[0]} lines total")
        stack.callback(report)

        # Merge-sort across all files using heapq
        for line in heapq.merge(*handles):
            out.write(line)
            line_count[0] += 1

    # Stack has exited: all files closed, callback fired
    print(f"  Output written to: {output_path}")

# Run the demo
with tempfile.TemporaryDirectory() as tmpdir:
    log_paths = create_sample_logs(tmpdir, num_files=4)
    output = os.path.join(tmpdir, 'merged.log')

    print("Merging logs...")
    merge_logs(log_paths, output)

    # Show first 5 lines of result
    print("\nFirst 5 lines of merged output:")
    with open(output) as f:
        for i, line in enumerate(f):
            if i >= 5:
                break
            print(f"  {line.rstrip()}")

Output:

Merging logs...
  Merged 4 files -> 20 lines total
  Output written to: /tmp/tmpXXX/merged.log

First 5 lines of merged output:
  2026-08-13T000:12:00 [service-2] event-3
  2026-08-13T000:17:00 [service-4] event-1
  2026-08-13T001:08:00 [service-1] event-0
  2026-08-13T001:23:00 [service-3] event-2
  2026-08-13T002:45:00 [service-1] event-4

The ExitStack block opens all four input files plus the output file in a single flat with. The callback() for the summary report fires after the output file is flushed and closed — the right order for accurate reporting. You can extend this by adding a threading lock to the stack (if merging concurrently), entering a contextlib.suppress(BrokenPipeError) for piped output, or registering a callback to upload the merged file when done.

Frequently Asked Questions

When should I use ExitStack instead of nested with statements?

Use ExitStack when the number of context managers is not known until runtime (e.g., opening files from a list), when you have more than three or four fixed resources and the indentation becomes unreadable, or when you need to conditionally enter a context manager inside an if block. For two or three fixed, always-opened resources, nested with is simpler and more readable. ExitStack is the upgrade path when nested with starts to hurt.

What happens if an exception is raised inside a cleanup callback?

If a callback registered with stack.callback() raises an exception, ExitStack catches it, continues running the remaining cleanup handlers, and then re-raises the exception (or the original body exception, whichever takes precedence). This means all registered cleanups run even if some of them fail — the same guarantee provided by nested with statements. To suppress errors from specific callbacks, wrap the callable yourself: stack.callback(lambda: quietly_close(resource)) where quietly_close catches and logs its own exceptions.

What does pop_all() do and when do I need it?

stack.pop_all() transfers all registered cleanup handlers from the current stack to a new ExitStack instance and returns it, leaving the original stack empty. This is used when you want to open resources inside a temporary setup block but then keep them alive beyond that block’s scope. The typical pattern is a class that opens resources in __init__ using a temporary ExitStack, then calls pop_all() at the end to store the cleanup handlers as self._stack, which is closed in close() or __exit__.

Is ExitStack thread-safe?

No — ExitStack itself is not thread-safe. If multiple threads could be entering or exiting context managers on the same stack simultaneously, you need to synchronize access with a threading.Lock. In practice, each thread typically has its own ExitStack for its own resources, which avoids the issue entirely. If you need a shared cleanup registry across threads, build one explicitly using a lock.

Does ExitStack work with async context managers?

Standard ExitStack only works with synchronous context managers (those using __enter__/__exit__). For async code using async with and __aenter__/__aexit__, use contextlib.AsyncExitStack instead. It has the same API — enter_async_context(), callback(), push() — but is awaitable and must be used with async with. Both classes are in contextlib and require no extra installation.

Can I use ExitStack without a with statement?

Yes — you can manage it manually. Call stack = contextlib.ExitStack(), use stack.enter_context() to add resources, and call stack.close() when done. Wrap the whole thing in a try/finally to guarantee close() runs even on exceptions: try: ... finally: stack.close(). Using with ExitStack() as stack is cleaner and equivalent, but the manual form is useful when the stack lives inside a class that needs explicit lifecycle management.

Conclusion

In this article we covered what contextlib.ExitStack is and how it manages multiple context managers in LIFO order, how to add context managers dynamically with enter_context(), how to register arbitrary cleanup functions with callback(), how to handle conditional and optional resources cleanly, how exception propagation and suppression work with the stack, and how to use pop_all() for long-lived objects that own their resources.

A good next step is to extend the log merger from the real-life example: add contextlib.suppress(BrokenPipeError) for piped output, enter a threading.Lock onto the stack when running the merger in a thread pool, or swap the callback for structured logging. For async pipelines, the same patterns apply to contextlib.AsyncExitStack with async with.

The official Python documentation for contextlib is at docs.python.org/3/library/contextlib.html. It covers ExitStack, AsyncExitStack, suppress, contextmanager, and the full list of utility context managers in the standard library.

How To Write a Custom JSON Encoder for Dates, Decimals, and Custom Objects

How To Write a Custom JSON Encoder for Dates, Decimals, and Custom Objects

Intermediate

You have a Python object — maybe a database query result full of datetime objects, a financial report where every number is a Decimal, or an API response that mixes dataclasses with standard types — and you call json.dumps(). Python stares back at you with a TypeError: Object of type datetime is not JSON serializable. This is one of the most common Python frustrations, and it comes up in virtually every discuss.python.org Help thread that touches JSON. The built-in json module knows about strings, numbers, booleans, lists, and dicts — that is it. The moment any other Python type walks through the door, it throws its hands up.

The fix is a custom JSON encoder — a small subclass of json.JSONEncoder that you define once and reuse everywhere. It lets you teach Python exactly how to serialize any type it does not understand: format a datetime as an ISO 8601 string, turn a Decimal into a lossless string, convert a dataclass into a dict. The json module is built into Python with no extra installation needed, and the pattern for extending it is the same across Python 3.7 through 3.12+.

In this article we will cover how json.JSONEncoder works and where it fits in Python’s serialization chain, how to encode datetime and date objects correctly, how to handle Decimal without losing precision, how to serialize dataclasses and custom classes, how to extend the encoder to UUID, Enum, and set, and how to use the default function argument as a lightweight alternative to subclassing. By the end you will have a production-ready encoder you can drop into any project.

Custom JSON Encoder in Python: Quick Example

Here is the shortest path from “TypeError” to working JSON — a custom encoder that handles datetime objects in under 15 lines:

# quick_encoder.py
import json
from datetime import datetime

class DateTimeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)

data = {
    "user": "alice",
    "login_at": datetime(2026, 8, 12, 9, 30, 0),
    "score": 98,
}

print(json.dumps(data, cls=DateTimeEncoder, indent=2))

Output:

{
  "user": "alice",
  "login_at": "2026-08-12T09:30:00",
  "score": 98
}

The key is the default() method — Python calls it whenever it encounters a type it cannot serialize. We check whether the object is a datetime, return its ISO 8601 string, and call super().default(obj) for anything else so the original TypeError is still raised on genuinely unserializable types. We pass our encoder to json.dumps() via the cls keyword argument. That is the whole pattern — the sections below show how to extend it to Decimal, dataclasses, and every other type your project throws at it.

What Is json.JSONEncoder and How Does It Work?

Python’s built-in json module uses an encoder class to convert Python objects into JSON strings. The default encoder, json.JSONEncoder, handles a fixed set of Python types. When it encounters any other type, it calls its own default() method — which by default raises a TypeError. Subclassing JSONEncoder and overriding default() is how you plug in support for additional types without touching the rest of the serialization logic.

Think of it like a hotel concierge who speaks English, French, and Spanish. When a guest speaks any of those languages, the concierge handles it directly. When a guest speaks Japanese, the concierge routes the call to a translator desk. By overriding default(), you are that translator — you decide how to convert any unknown type into something the serializer already understands.

Python TypeJSON Output (built-in)Needs Custom Encoder?
str"hello"No
int42No
float3.14No
booltrue / falseNo
NonenullNo
list, tuple[1, 2, 3]No
dict{"key": "val"}No
datetime(TypeError)Yes
date(TypeError)Yes
Decimal(TypeError)Yes
dataclass(TypeError)Yes
UUID(TypeError)Yes
Enum(TypeError)Yes
set(TypeError)Yes

The default() method receives one object at a time and must return a JSON-serializable value — a string, number, list, or dict that the encoder already knows how to handle. You can chain as many isinstance checks as you need in a single default() method, which is exactly how the combined encoder at the end of this article is built.

API Alice at JSON reception desk welcoming Python type objects
TypeError: Object of type datetime is not JSON serializable. Every API developer’s Tuesday.

Encoding datetime and date Objects

The most common cause of JSON serialization errors in Python is a datetime object. Django ORM results, SQLAlchemy rows, and Python’s datetime.now() all produce datetime objects. The safest format to serialize them to is ISO 8601 — the international standard that looks like "2026-08-12T09:30:00" — because every JSON consumer (JavaScript, Go, Rust, any SQL client) knows how to parse it.

Here is a complete encoder that handles both datetime and date objects. They are different types — a datetime has time components, a date does not — and the order you check them matters:

# datetime_encoder.py
import json
from datetime import datetime, date, timezone

class DateTimeEncoder(json.JSONEncoder):
    """Serialize datetime and date objects to ISO 8601 strings."""

    def default(self, obj):
        # Check datetime BEFORE date -- datetime is a subclass of date,
        # so checking date first would match datetime objects too early
        if isinstance(obj, datetime):
            return obj.isoformat()  # "2026-08-12T09:30:00+00:00"
        if isinstance(obj, date):
            return obj.isoformat()  # "2026-08-12"
        return super().default(obj)

# --- Example usage ---
event = {
    "title": "Python meetup",
    "event_date": date(2026, 9, 1),
    "created_at": datetime(2026, 8, 12, 9, 30, 0, tzinfo=timezone.utc),
    "updated_at": datetime(2026, 8, 12, 14, 0, 0),  # naive datetime (no tzinfo)
}

print(json.dumps(event, cls=DateTimeEncoder, indent=2))

Output:

{
  "title": "Python meetup",
  "event_date": "2026-09-01",
  "created_at": "2026-08-12T09:30:00+00:00",
  "updated_at": "2026-08-12T14:00:00"
}

The subclass ordering matters: datetime is a subclass of date, so if you check date first, every datetime will match it and you will lose the time component entirely. Always check the more specific type first. Aware datetimes (those with a tzinfo) produce an offset suffix like +00:00; naive datetimes produce no suffix. If your API consumers always expect a UTC offset, use datetime.now(tz=timezone.utc) when creating timestamps so the offset is never absent from the output.

Encoding Decimal Objects

Financial applications and any code that uses Python’s decimal.Decimal type hit the same wall. Decimal is not a subclass of float, so the JSON encoder rejects it. There are two sensible ways to serialize it: convert to float (fast but can introduce floating-point rounding errors) or convert to str (lossless but produces a JSON string instead of a number). Which you choose depends on your API contract and how precise the data needs to be.

# decimal_encoder.py
import json
from decimal import Decimal

class DecimalEncoder(json.JSONEncoder):
    """Serialize Decimal objects -- use str for financial data, float for analytics."""

    def default(self, obj):
        if isinstance(obj, Decimal):
            # Option A -- lossless string: "10.99"  (safe for money)
            return str(obj)
            # Option B -- float: 10.99  (only if rounding errors are acceptable)
            # return float(obj)
        return super().default(obj)

# --- Example usage ---
invoice = {
    "item": "Python course",
    "price": Decimal("10.99"),
    "tax": Decimal("1.099"),
    "total": Decimal("12.089"),
}

print(json.dumps(invoice, cls=DecimalEncoder, indent=2))

Output (string mode):

{
  "item": "Python course",
  "price": "10.99",
  "tax": "1.099",
  "total": "12.089"
}

Returning str(obj) preserves every digit exactly as stored — critical for invoice amounts and exchange rates where even a sub-cent rounding error compounds over thousands of transactions. If your JSON consumer is a JavaScript frontend, keep in mind that JavaScript’s parseFloat("10.99") reads a string back into a float anyway, so either representation lands in roughly the same place on the consumer side. The rule of thumb: pick str for financial APIs and ledgers, pick float for analytics payloads where a tiny rounding error is acceptable.

Debug Dee carefully measuring Decimal precision for JSON output
float(Decimal(‘0.1’)) is not 0.1. Your accounting team noticed.

Encoding Dataclasses and Custom Objects

When you work with Python 3.7+ dataclasses, you need to tell the encoder how to turn an instance into a dict. The dataclasses standard library module provides asdict(), which recursively converts a dataclass — and any nested dataclasses — into a plain dict. Once the encoder has a plain dict, it continues processing any non-standard values inside it (like Decimal or datetime fields) by calling default() on each one.

# dataclass_encoder.py
import json
import dataclasses
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal

@dataclass
class Product:
    name: str
    price: Decimal
    in_stock: bool
    last_updated: datetime

@dataclass
class Order:
    order_id: str
    customer: str
    items: list  # list of Product dataclasses

class DataclassEncoder(json.JSONEncoder):
    """Serialize dataclasses along with Decimal and datetime fields."""

    def default(self, obj):
        if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
            return dataclasses.asdict(obj)
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return str(obj)
        return super().default(obj)

# --- Example usage ---
order = Order(
    order_id="ORD-2026-001",
    customer="alice@example.com",
    items=[
        Product("Python Course", Decimal("10.99"), True,
                datetime(2026, 8, 1, tzinfo=timezone.utc)),
        Product("Debugging Guide", Decimal("7.50"), False,
                datetime(2026, 7, 15, tzinfo=timezone.utc)),
    ],
)

print(json.dumps(order, cls=DataclassEncoder, indent=2))

Output:

{
  "order_id": "ORD-2026-001",
  "customer": "alice@example.com",
  "items": [
    {
      "name": "Python Course",
      "price": "10.99",
      "in_stock": true,
      "last_updated": "2026-08-01T00:00:00+00:00"
    },
    {
      "name": "Debugging Guide",
      "price": "7.50",
      "in_stock": false,
      "last_updated": "2026-07-15T00:00:00+00:00"
    }
  ]
}

The guard not isinstance(obj, type) is important: dataclasses.is_dataclass() returns True for both the class itself and instances of it. Without this guard, passing the Order class (not an instance) to the encoder would trigger a confusing error. After asdict() converts the dataclass to a plain dict, the encoder recurses through it automatically — which is how the nested Decimal and datetime values in the Product fields get serialized without any extra code on your part.

Encoding UUID, Enum, and set

Three more types appear constantly in real-world APIs: UUID (used as primary keys in ORMs and databases), Enum (used for status fields and category values), and set (used for tag collections and permission sets). All three require custom handling, and all three follow the same pattern — return a JSON-native type that represents the same information:

# extended_encoder.py
import json
import uuid
from enum import Enum

class OrderStatus(Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"

class ExtendedEncoder(json.JSONEncoder):
    """Handles UUID, Enum, and set in addition to standard JSON types."""

    def default(self, obj):
        if isinstance(obj, uuid.UUID):
            return str(obj)  # "550e8400-e29b-41d4-a716-446655440000"
        if isinstance(obj, Enum):
            return obj.value  # The value, not "OrderStatus.CONFIRMED"
        if isinstance(obj, (set, frozenset)):
            return sorted(list(obj))  # Sorted list for deterministic output
        return super().default(obj)

# --- Example usage ---
user_record = {
    "id": uuid.UUID("550e8400-e29b-41d4-a716-446655440000"),
    "status": OrderStatus.CONFIRMED,
    "roles": {"admin", "editor", "viewer"},
}

print(json.dumps(user_record, cls=ExtendedEncoder, indent=2))

Output:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "confirmed",
  "roles": [
    "admin",
    "editor",
    "viewer"
  ]
}

For set and frozenset, calling sorted(list(obj)) rather than just list(obj) gives deterministic output — sets have no guaranteed order in Python, so without sorting you get different JSON strings for the same data, which breaks caching, checksumming, and tests that compare strings. For Enum, returning obj.value instead of str(obj) avoids leaking the class name into your API response — "confirmed" is a cleaner contract than "OrderStatus.CONFIRMED". For UUID, the canonical hyphenated string is universally understood by databases, JavaScript, and any language that has a UUID library.

Cache Katie sorting UUID tokens and Enum chips into JSON output boxes
Sets have no order. Your integration tests found out before your consumers did.

Using the default Function Argument

Subclassing JSONEncoder is the cleanest pattern for production code, but Python’s json.dumps() also accepts a default keyword argument that takes a plain callable. This is useful for one-off serialization in scripts, tests, or debugging sessions where defining a class feels like overkill.

# default_function.py
import json
from datetime import datetime, date
from decimal import Decimal

def json_default(obj):
    """Fallback serializer for use with json.dumps(default=...)."""
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, date):
        return obj.isoformat()
    if isinstance(obj, Decimal):
        return str(obj)
    raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")

# --- Example usage ---
data = {
    "event": "product launch",
    "date": date(2026, 9, 1),
    "revenue": Decimal("149999.99"),
    "recorded_at": datetime(2026, 8, 12, 9, 30, 0),
}

print(json.dumps(data, default=json_default, indent=2))

Output:

{
  "event": "product launch",
  "date": "2026-09-01",
  "revenue": "149999.99",
  "recorded_at": "2026-08-12T09:30:00"
}

The default function approach is simpler but slightly less flexible: it only handles the fallback path and cannot override how the encoder processes lists or dicts. The function must also raise TypeError itself for unknown types — it has no super().default() to fall back to. For consistent API-wide serialization, prefer the cls= subclass pattern. For quick scripts and tests, default=json_default is perfectly fine.

Building a Combined Production Encoder

In a real application you rarely hit just one non-standard type — you hit all of them at once. Here is a single encoder that handles every type covered in this article, ready to copy into a utils/json_encoder.py module in your project:

# utils/json_encoder.py
import json
import dataclasses
import uuid
from datetime import datetime, date
from decimal import Decimal
from enum import Enum

class AppEncoder(json.JSONEncoder):
    """
    Production-ready JSON encoder.
    Handles: datetime, date, Decimal, dataclass, UUID, Enum, set, frozenset.
    """

    def default(self, obj):
        # Dataclasses -- process before generic dict so nested types are handled
        if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
            return dataclasses.asdict(obj)
        # datetime before date -- datetime subclasses date, so order matters
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, date):
            return obj.isoformat()
        # Decimal -- string preserves precision; use float only if rounding is acceptable
        if isinstance(obj, Decimal):
            return str(obj)
        # UUID -- canonical hyphenated string
        if isinstance(obj, uuid.UUID):
            return str(obj)
        # Enum -- return the value, not the repr
        if isinstance(obj, Enum):
            return obj.value
        # set / frozenset -- sorted list for deterministic output
        if isinstance(obj, (set, frozenset)):
            return sorted(list(obj))
        return super().default(obj)

Use it anywhere you call json.dumps():

# usage_example.py
import json
from utils.json_encoder import AppEncoder

result = json.dumps(your_data, cls=AppEncoder, indent=2)

If you are using Django, you can set this as the global encoder in settings.py so JsonResponse uses it automatically:

# settings.py (Django 3.2+)
JSON_ENCODER = "utils.json_encoder.AppEncoder"
Sudo Sam assembling combined AppEncoder toolbox from individual type handlers
Write it once. Import it in every project for the rest of your career.

Real-Life Example: API Response Serializer for an E-Commerce Backend

Here is a practical example that puts everything together: a complete order serializer for an e-commerce API. It mixes dataclasses, Decimal prices, datetime timestamps, UUID identifiers, an Enum status, and a set of tags — exactly the kind of object graph you find in Django or FastAPI projects.

# ecommerce_serializer.py
import json
import dataclasses
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import List

# --- Domain models ---
class OrderStatus(Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"

@dataclass
class LineItem:
    product_id: uuid.UUID
    name: str
    quantity: int
    unit_price: Decimal

@dataclass
class Order:
    order_id: uuid.UUID
    customer_email: str
    status: OrderStatus
    items: List[LineItem]
    created_at: datetime
    tags: set = field(default_factory=set)

# --- Production encoder ---
class AppEncoder(json.JSONEncoder):
    def default(self, obj):
        if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
            return dataclasses.asdict(obj)
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return str(obj)
        if isinstance(obj, uuid.UUID):
            return str(obj)
        if isinstance(obj, Enum):
            return obj.value
        if isinstance(obj, (set, frozenset)):
            return sorted(list(obj))
        return super().default(obj)

# --- Build a sample order ---
order = Order(
    order_id=uuid.UUID("12345678-1234-5678-1234-567812345678"),
    customer_email="alice@example.com",
    status=OrderStatus.CONFIRMED,
    items=[
        LineItem(uuid.uuid4(), "Python Course", 2, Decimal("10.99")),
        LineItem(uuid.uuid4(), "Debugging Guide", 1, Decimal("7.50")),
    ],
    created_at=datetime.now(tz=timezone.utc),
    tags={"digital", "education", "python"},
)

payload = json.dumps(order, cls=AppEncoder, indent=2)
print(payload)

Output (UUIDs and timestamp vary each run):

{
  "order_id": "12345678-1234-5678-1234-567812345678",
  "customer_email": "alice@example.com",
  "status": "confirmed",
  "items": [
    {
      "product_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "name": "Python Course",
      "quantity": 2,
      "unit_price": "10.99"
    },
    {
      "product_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "name": "Debugging Guide",
      "quantity": 1,
      "unit_price": "7.50"
    }
  ],
  "created_at": "2026-08-12T09:30:00+00:00",
  "tags": [
    "digital",
    "education",
    "python"
  ]
}

The encoder processes each object in the graph exactly once. Nested dataclasses are handled when asdict() recursively converts them to dicts, and the encoder then processes any non-standard values in those dicts by calling default() on each one. To extend this for Pydantic v2 models, add a check for hasattr(obj, 'model_dump') and return obj.model_dump() — the same pattern works across the board. To include computed properties that asdict() skips (because they are not fields), you can add them after the asdict() call: d = dataclasses.asdict(obj); d['total'] = str(obj.total); return d.

API Alice serving a JSON response tray to an API consumer robot
asdict(), isoformat(), str() — it’s all just ‘make it a dict’ wearing a different hat.

Frequently Asked Questions

When exactly does Python call the default() method?

Python calls default() only for objects it cannot serialize natively — types not in its built-in set of str, int, float, bool, None, list, and dict. It is never called for those built-in types, even when they are nested inside a container. This means if you have a dict containing a datetime value, the dict itself goes through normal processing and only the datetime value triggers default(). This is why the dataclass pattern works: asdict() returns a plain dict, and the encoder recurses into that dict and calls default() on any non-standard values it finds inside — including nested dataclasses, Decimal fields, and datetime fields — automatically.

Should I serialize Decimal as float or string?

For financial data — prices, tax amounts, invoice totals — always use str(obj). Python’s Decimal("10.99") stores the value exactly, but converting to float can introduce errors like 10.989999999999999 due to IEEE 754 floating-point representation. For analytics or machine learning payloads where the downstream consumer needs a numeric type and tiny rounding errors are acceptable, float(obj) is fine. If your API contract specifies that money fields are strings (as Stripe’s API does, for example), using str also makes your output match the spec without any frontend parsing step.

Should I use orjson or Pydantic instead of a custom encoder?

orjson is a Rust-backed JSON library that natively supports datetime, UUID, dataclass, and numpy arrays without any custom encoder, and it serializes roughly 5x to 10x faster than the built-in json module. If you are serializing large payloads or doing high-throughput work, orjson is worth the dependency. Pydantic v2 models serialize themselves via .model_dump_json() without needing a custom encoder at all. The custom JSONEncoder approach is the right choice when you want zero extra dependencies and full control over how each type is represented, or when you are working in a restricted environment where you cannot add third-party packages.

How does the encoder handle deeply nested objects?

The encoder recurses automatically through any dict or list it processes. If your default() method returns a dict (as it does for dataclasses via asdict()), the encoder walks into that dict and calls default() on any non-standard values it finds inside. You do not need to manually recurse. The one case where this breaks down is if default() returns a custom object instead of a JSON-native type — the encoder will call default() on it again, and you risk infinite recursion. Always return a string, number, list, dict, or None from default(). Never return an object that would trigger another default() call.

Can I silently skip unserializable objects instead of raising TypeError?

Yes — in default(), instead of calling super().default(obj) as the final fallback, return a sentinel value like None or "[UNSERIALIZABLE]". This suppresses the TypeError but silently drops data, which is almost always the wrong choice for production APIs where missing fields cause bugs downstream. A better pattern is to log the unexpected type and return None so you have a record of what was dropped: import logging; logging.warning(f"Skipping unserializable type: {type(obj).__name__}"). Reserve silent-skip for debugging and logging scenarios where partial output is better than no output.

What other json.dumps() options are worth knowing?

Three options are especially useful alongside a custom encoder. sort_keys=True alphabetically sorts all dict keys in the output — great for producing deterministic JSON for checksums, diffs, and caching. separators=(", ", ": ") controls whitespace; for compact production JSON with no formatting overhead, use separators=(",", ":") to strip all spaces. ensure_ascii=True (the default) converts non-ASCII characters to \uXXXX escape sequences; set it to False if you want UTF-8 characters to appear literally in the output, which is more readable for non-English text. All three work alongside the cls= argument — they are not mutually exclusive.

Conclusion

In this article we covered how json.JSONEncoder works and when Python calls default(), how to serialize datetime and date to ISO 8601 with correct subclass ordering, how to handle Decimal safely as a string or float depending on your precision requirements, how to use dataclasses.asdict() to serialize dataclasses and their nested types, how to extend the encoder to cover UUID, Enum, set, and frozenset, and how to use the default function argument as a lightweight alternative to subclassing.

A good next step is to extend the AppEncoder from the real-life example: add Pydantic v2 model support via hasattr(obj, 'model_dump'), add numpy array support if you work in data science (isinstance(obj, numpy.ndarray) returns obj.tolist()), or wire the encoder into Django’s JSON_ENCODER setting or Flask’s app.json.encoder so it is the default everywhere in your application. Once you have a shared AppEncoder in your project, you can stop copy-pasting serialization workarounds across files.

The official documentation for the json module lives at docs.python.org/3/library/json.html and covers additional encoder options like sort_keys, separators, and ensure_ascii. The orjson project is worth reading if you need higher throughput or native support for types like numpy arrays and bytes.

How To Use Morphik for Multimodal Document AI in Python

How To Use Morphik for Multimodal Document AI in Python

Intermediate

You have a folder full of PDFs — technical manuals, scanned invoices, research papers with charts and diagrams — and you need your AI application to actually understand them. Not just the text layer, but the tables, the figures, the visual layout that gives the numbers meaning. Traditional RAG pipelines let you down here: they strip PDFs to plain text, lose every diagram, mangle every table, and then wonder why the AI confidently returns wrong answers. If you have hit this wall while building a document Q&A system, Morphik was built to solve exactly that problem.

Morphik is an open-source multimodal retrieval engine with a Python SDK that lets you ingest PDFs, images, and text documents into a unified knowledge store and then query them with natural language. Under the hood it uses ColPali — a technique that embeds each page of a document as an image, preserving visual context — so charts and schematics are searchable just like prose. The free tier at dev.morphik.ai gets you started with no infrastructure to manage. You install the SDK, grab your connection URI, and you are ingesting documents in three lines of Python.

In this article we will cover how to install the Morphik Python SDK, how to connect to the hosted service, how to ingest text and PDF files, how to run semantic search with retrieve_chunks(), how to use RAG queries with query(), how to organise documents into folders, and how to use the async client for production workloads. By the end you will have a working multimodal document search system you can drop into any Python project.

Morphik in Python: Quick Example

Here is the minimum viable Morphik workflow — ingest a piece of text and query it back with a natural language question — so you can see the shape of the API before we go deeper:

# quick_morphik.py
from morphik import Morphik

# Connect to Morphik (replace with your URI from dev.morphik.ai)
db = Morphik("morphik://owner_id:your_token@api.morphik.ai")

# Ingest a text document with metadata
doc = db.ingest_text(
    content="Python 3.12 introduced the experimental JIT compiler. "
            "Enable it with PYTHON_JIT=1 before running your script. "
            "Numeric loops and tight iteration show the biggest gains.",
    metadata={"topic": "python", "version": "3.12"}
)
print("Ingested doc ID:", doc.external_id)

# Query with RAG -- Morphik retrieves relevant chunks and generates an answer
response = db.query(
    query="How do I enable the Python JIT compiler?",
    filters={"topic": "python"}
)
print(response.completion)

Output:

Ingested doc ID: doc_a3f9c1b2e7d04512
To enable the Python JIT compiler, set the environment variable PYTHON_JIT=1
before running your script. This feature was introduced in Python 3.12 and
provides the most noticeable speedups for numeric loops and tight iteration.

The ingest_text() call stores the content in Morphik’s vector store with the metadata you supply. The query() call does retrieval and generation in one step — it finds the most relevant chunks, passes them to an LLM, and returns the synthesized answer in response.completion. The filters argument narrows the search to documents whose metadata matches, so your queries stay scoped to the right corpus.

The sections below show how to ingest PDFs, work with folders, run raw chunk retrieval, and use the async client for concurrent workloads.

Developer character holding glowing PDF document with multimodal data beams
Traditional RAG: strips your PDF to text soup. Morphik: actually reads the diagrams.

What Is Morphik and How Does It Work?

Morphik Core is a multimodal RAG engine — a system that stores, indexes, and retrieves documents of mixed content types (text, images, PDFs with visual elements) so that an LLM can answer questions over them accurately. The key differentiator is how it indexes PDFs: instead of running OCR and discarding the visual structure, Morphik embeds each page as an image using a technique called ColPali. This means a flowchart, a table with shaded header rows, or an engineering schematic is represented in the index as faithfully as a paragraph of prose.

Think of traditional RAG as a librarian who can only read books if someone first types out all the text by hand, throwing away any photos or tables. Morphik is more like a librarian who photographs each page and indexes the photograph — so when you ask “what does the diagram on page 14 show?”, the system actually has that diagram in its index.

FeatureTraditional RAGMorphik
PDF ingestionOCR to plain textPage-level image embeddings (ColPali)
Diagrams and chartsLost or garbledIndexed and searchable
TablesMangled to stringsPreserved visually
Metadata filteringVaries by frameworkBuilt-in on every call
Multi-tenancyDIYBuilt-in (folders + scoping)
MCP supportNoYes (Claude, Cursor)
Self-hostableDepends on stackYes (Docker or direct install)

You interact with Morphik through the Python SDK (pip install morphik) or the REST API. The SDK wraps the API into a clean synchronous or async client. Documents you ingest are stored server-side; your queries hit that stored index. Let us install everything and get connected.

Installing Morphik and Getting a Connection URI

Install the Morphik Python SDK with pip. It has no mandatory dependency on pandas, numpy, or any heavy ML library — the heavy lifting happens on the server:

# terminal
pip install morphik

Next, sign up for a free account at dev.morphik.ai/signup. After signing in, navigate to your dashboard and copy your connection URI — it looks like this:

morphik://owner_id:your_token@api.morphik.ai

This single URI contains your owner ID, authentication token, and the API endpoint. Pass it to the Morphik constructor and you are connected. If you are self-hosting Morphik on your own infrastructure, use a plain HTTP(S) base URL instead:

# connect_morphik.py
from morphik import Morphik

# Hosted service (replace with your real URI)
db = Morphik("morphik://owner_id:your_token@api.morphik.ai")

# Self-hosted (adjust host and port as needed)
# db = Morphik("http://localhost:8000")

# Verify the connection by listing documents
docs = db.list_documents()
print(f"Connected. Documents in store: {len(docs.documents)}")

Output:

Connected. Documents in store: 0

The list_documents() call returns a ListDocsResponse object — access the list of documents through .documents. An empty list is expected on a fresh account. Keep your URI out of source code: store it in an environment variable and read it with os.environ.get("MORPHIK_URI").

Developer character connecting to Morphik server with glowing data cable
One URI. No vector DB config, no embedding model setup, no CORS headaches.

Ingesting Text and Files

Morphik supports three main ingestion methods: plain text via ingest_text(), files (PDFs, images, Office docs) via ingest_file(), and entire directories via ingest_directory(). All three accept a metadata dict that you can use to filter results later.

Ingesting Plain Text

Use ingest_text() when you already have text content in memory — from a database field, an API response, or a scraped web page. Pass the text and any metadata key-value pairs you want to filter on later:

# ingest_text.py
import os
from morphik import Morphik

db = Morphik(os.environ["MORPHIK_URI"])

doc = db.ingest_text(
    content="""
    FastAPI is a modern Python web framework built on Starlette and Pydantic.
    It generates OpenAPI docs automatically, validates request/response data
    via type hints, and supports both sync and async handlers natively.
    Benchmark: FastAPI handles ~50,000 requests/sec on a single core.
    """,
    metadata={"category": "frameworks", "language": "python", "year": 2024}
)

print("Document ID:", doc.external_id)
print("Metadata stored:", doc.metadata)

Output:

Document ID: doc_b7c2a14f93e05821
Metadata stored: {'category': 'frameworks', 'language': 'python', 'year': 2024}

Ingesting PDF and Image Files

For files on disk — PDFs, PNGs, JPEGs, Word documents — use ingest_file(). This is where Morphik’s multimodal indexing kicks in: PDFs are processed page-by-page, preserving charts and diagrams in the index:

# ingest_pdf.py
import os
from morphik import Morphik

db = Morphik(os.environ["MORPHIK_URI"])

# Ingest a technical PDF -- diagrams, tables, and text all indexed
doc = db.ingest_file(
    file="reports/q2_technical_spec.pdf",
    metadata={"type": "spec", "department": "engineering", "quarter": "Q2"}
)

print(f"Ingested: {doc.external_id}")
print(f"Filename: {doc.filename}")

# Ingest an image -- useful for scanned documents or standalone charts
img_doc = db.ingest_file(
    file="charts/architecture_diagram.png",
    metadata={"type": "diagram", "project": "backend-v2"}
)
print(f"Ingested image: {img_doc.external_id}")

Output:

Ingested: doc_c9d4e26a0b1f7843
Filename: q2_technical_spec.pdf
Ingested image: doc_f1a3b85c2d094e67

Morphik handles the embedding and indexing asynchronously on the server — you get a document ID back immediately and the indexing completes in the background. For large PDFs, allow a few seconds before querying. The ingest_directory() method works the same way but accepts a folder path and recursively ingests all supported files inside it.

Python character feeding PDF documents into a scanning machine with colorful embeddings
OCR strips the context. ColPali keeps the diagram.

Retrieving Chunks and Querying with RAG

Morphik gives you two query modes: retrieve_chunks() for raw semantic search that returns matching text/page fragments, and query() for full RAG that returns an AI-generated answer grounded in those fragments. Use chunks when you want to build your own generation step; use query() when you want an answer directly.

Semantic Search with retrieve_chunks()

The retrieve_chunks() method runs a vector similarity search over your indexed documents and returns the top-k matching chunks with their scores and source metadata. This is useful when you want to inspect what the retrieval step is finding before passing it to a model:

# retrieve_chunks.py
import os
from morphik import Morphik

db = Morphik(os.environ["MORPHIK_URI"])

# Retrieve the 3 most relevant chunks for a query
chunks = db.retrieve_chunks(
    query="How does FastAPI handle request validation?",
    filters={"category": "frameworks"},
    k=3
)

for i, chunk in enumerate(chunks, 1):
    print(f"\n--- Chunk {i} ---")
    print(f"Score:  {chunk.score:.4f}")
    print(f"Source: {chunk.document_id}")
    print(f"Text:   {chunk.content[:200]}")

Output:

--- Chunk 1 ---
Score:  0.9421
Source: doc_b7c2a14f93e05821
Text:   FastAPI is a modern Python web framework built on Starlette and Pydantic.
        It generates OpenAPI docs automatically, validates request/response data
        via type hints, and supports both sync and async handlers natively.

--- Chunk 2 ---
Score:  0.7803
Source: doc_b7c2a14f93e05821
Text:   Benchmark: FastAPI handles ~50,000 requests/sec on a single core.

The score field is a cosine similarity value between 0 and 1 — higher means more relevant. The filters argument accepts any metadata key-value pairs you set during ingestion, so you can scope a query to a specific project, document type, or date range without fetching everything and filtering in Python.

RAG Queries with query()

The query() method combines retrieval and generation into one call. Morphik finds the most relevant chunks and passes them to an LLM with your question, returning a synthesized answer in response.completion. This is the method to use when building a chatbot or Q&A interface over your documents:

# rag_query.py
import os
from morphik import Morphik

db = Morphik(os.environ["MORPHIK_URI"])

response = db.query(
    query="What is the request throughput of FastAPI and how does it handle data validation?",
    filters={"category": "frameworks"},
    k=5   # how many chunks to use as context
)

print("Answer:")
print(response.completion)
print("\nSources used:", [s.document_id for s in response.sources])

Output:

Answer:
FastAPI handles approximately 50,000 requests per second on a single core.
It validates request and response data using Python type hints via Pydantic,
automatically generating OpenAPI documentation in the process. Both sync
and async handlers are supported natively through the Starlette foundation.

Sources used: ['doc_b7c2a14f93e05821']

The response.sources list tells you which documents contributed to the answer — essential for building citations into your UI or auditing hallucinations. The k parameter controls how many retrieved chunks get sent to the LLM as context; higher values give the model more information but cost more tokens per request.

Debug Dee watching document chunks flow into AI brain node with answer display
retrieve_chunks() shows you what it found. query() shows you what it means.

Organising Documents with Folders

When you are storing documents from multiple projects or teams, you want to keep them separate so queries from one project do not bleed into another. Morphik handles this with folders — named scopes that act like directories in a filesystem. You create a folder, ingest documents into it, and then pass the folder to your queries:

# folders.py
import os
from morphik import Morphik

db = Morphik(os.environ["MORPHIK_URI"])

# Create a folder for a project
folder = db.create_folder(
    name="backend-v2-docs",
    description="Architecture specs and API references for backend v2"
)
print(f"Folder created: {folder.name}")

# Ingest a file directly into the folder
doc = folder.ingest_file(
    file="specs/api_reference.pdf",
    metadata={"type": "reference"}
)
print(f"Ingested into folder: {doc.external_id}")

# Query scoped to this folder only
response = folder.query("What authentication method does the API use?")
print(response.completion)

# Retrieve chunks from this folder only
chunks = folder.retrieve_chunks("rate limiting configuration")
print(f"Found {len(chunks)} relevant chunks")

Output:

Folder created: backend-v2-docs
Ingested into folder: doc_e5f2c3a9b0d18764
The API uses JWT bearer tokens for authentication, issued at /auth/token
with a 24-hour expiry. Refresh tokens are valid for 30 days.
Found 3 relevant chunks

Folders also support nested paths for deeper hierarchies. Pass full_path="/projects/alpha/specs" to create_folder() and Morphik creates the parent folders automatically. Use folder_depth=-1 on list or retrieve calls to include all descendant folders in the scope.

Using the Async Client

For production applications that handle concurrent requests — a FastAPI endpoint that fields multiple users’ document queries at once, for example — the synchronous client will block your event loop. Switch to AsyncMorphik to use await throughout:

# async_morphik.py
import asyncio
import os
from morphik.async_ import AsyncMorphik

async def search_documents(query_text: str) -> str:
    async with AsyncMorphik(os.environ["MORPHIK_URI"]) as db:
        response = await db.query(
            query=query_text,
            filters={"category": "frameworks"},
        )
        return response.completion

async def main():
    # Run two queries concurrently
    results = await asyncio.gather(
        search_documents("How does FastAPI validate requests?"),
        search_documents("What is FastAPI built on top of?"),
    )
    for i, result in enumerate(results, 1):
        print(f"\nQuery {i} answer:")
        print(result[:200])

asyncio.run(main())

Output:

Query 1 answer:
FastAPI validates requests using Pydantic models and Python type hints,
automatically generating OpenAPI documentation from the same definitions.

Query 2 answer:
FastAPI is built on Starlette (the ASGI framework) and Pydantic (the
data validation library).

The async with AsyncMorphik(...) as db pattern is important — the context manager ensures the underlying HTTP session is properly closed when your function exits. Both queries above run concurrently using asyncio.gather(), so the total wall-clock time is roughly equal to the slower of the two, not their sum.

Two developer characters working in parallel at terminals with results converging
asyncio.gather() — because waiting for one query at a time is so 2019.

Real-Life Example: Building a PDF Knowledge Base CLI

Here is a practical command-line tool that lets you ingest a folder of PDF reports and then interactively query them. This pattern is useful for internal knowledge bases, legal document search, or engineering spec lookup:

# pdf_knowledge_base.py
import os
import sys
from pathlib import Path
from morphik import Morphik

def ingest_reports(db: Morphik, reports_dir: str) -> int:
    """Ingest all PDFs in a directory and return the count."""
    folder = db.create_folder(name="reports", description="Ingested PDF reports")
    pdf_files = list(Path(reports_dir).glob("*.pdf"))

    if not pdf_files:
        print(f"No PDFs found in {reports_dir}")
        return 0

    for pdf_path in pdf_files:
        doc = folder.ingest_file(
            file=str(pdf_path),
            metadata={
                "filename": pdf_path.name,
                "type": "report"
            }
        )
        print(f"Ingested: {pdf_path.name} -> {doc.external_id}")

    return len(pdf_files)

def interactive_query(db: Morphik) -> None:
    """Run an interactive Q&A session over ingested documents."""
    folder = db.create_folder(name="reports")  # Retrieve existing folder

    print("\nPDF Knowledge Base ready. Type 'quit' to exit.\n")
    while True:
        question = input("Ask a question: ").strip()
        if question.lower() in ("quit", "exit", "q"):
            print("Goodbye.")
            break
        if not question:
            continue

        response = folder.query(query=question, k=5)
        print(f"\nAnswer: {response.completion}")

        if response.sources:
            source_names = [s.metadata.get("filename", s.document_id)
                            for s in response.sources]
            print(f"Sources: {', '.join(set(source_names))}")
        print()

def main():
    uri = os.environ.get("MORPHIK_URI")
    if not uri:
        print("Error: set the MORPHIK_URI environment variable first.")
        sys.exit(1)

    db = Morphik(uri)

    if len(sys.argv) > 1 and sys.argv[1] == "ingest":
        reports_dir = sys.argv[2] if len(sys.argv) > 2 else "./reports"
        count = ingest_reports(db, reports_dir)
        print(f"\nIngested {count} PDF(s). Run without arguments to query.")
    else:
        interactive_query(db)

if __name__ == "__main__":
    main()

Sample session:

# First run: ingest the PDFs
$ python pdf_knowledge_base.py ingest ./reports
Ingested: q2_technical_spec.pdf -> doc_c9d4e26a0b1f7843
Ingested: api_reference.pdf     -> doc_e5f2c3a9b0d18764
Ingested: architecture_guide.pdf -> doc_a1b2c3d4e5f60789
Ingested 3 PDF(s). Run without arguments to query.

# Second run: interactive query
$ python pdf_knowledge_base.py
PDF Knowledge Base ready. Type 'quit' to exit.

Ask a question: What authentication method does the API use?
Answer: The API uses JWT bearer tokens issued at /auth/token with a 24-hour
expiry. Refresh tokens are valid for 30 days and must be rotated on each use.
Sources: api_reference.pdf

Ask a question: quit
Goodbye.

The ingest_reports() function uses a folder to scope all reports together. The interactive_query() function references the same folder by name, so queries only search the ingested reports — not any other documents in your Morphik account. You can extend this by adding metadata filters (e.g., {"quarter": "Q2"}), showing chunk scores in the output, or replacing the CLI loop with a FastAPI endpoint for a web-based interface.

Stack Trace Steve at command-line terminal with document pages floating around
grep can’t answer ‘what does Figure 4 show.’ Morphik can.

Frequently Asked Questions

What file types does Morphik support for ingestion?

Morphik supports PDFs (including scanned and visually rich ones), images (PNG, JPEG, and other common formats), plain text, and Office documents. PDFs are the primary use case where Morphik provides the biggest advantage over traditional RAG, because it indexes each page as an image via ColPali rather than stripping the content to plain text. For images — standalone charts, scanned invoices, whiteboard photos — Morphik embeds them directly without any OCR step.

Do I need to run my own vector database or embedding model?

No. The hosted Morphik service at dev.morphik.ai handles all of that server-side. You supply documents and queries; Morphik handles embedding, indexing, storage, and retrieval. If you self-host Morphik Core, you run it via Docker and it manages its own internal vector store. Either way, you do not configure a separate Pinecone, Weaviate, or Chroma instance — Morphik is a self-contained system.

How is morphik.query() different from retrieve_chunks()?

retrieve_chunks() runs the retrieval step only — it returns the raw text or page chunks that are most similar to your query, with similarity scores. query() does retrieval plus generation: it fetches relevant chunks and sends them to an LLM with your question, returning a synthesized answer. Use retrieve_chunks() when you want to build your own generation pipeline or inspect what the system is finding. Use query() when you want a direct answer and are happy with Morphik choosing the LLM.

How do I filter queries to specific documents or projects?

There are two complementary mechanisms. First, use the filters argument to match on metadata you set during ingestion — for example, filters={"department": "engineering", "year": 2024} restricts retrieval to documents with those exact metadata values. Second, use Folder objects to create hard namespace boundaries: documents ingested into a folder are only retrieved when you query through that folder object. Combining both gives you fine-grained control over multi-tenant and multi-project knowledge bases.

Is Morphik free to use?

Morphik Core is source-available under the Business Source License 1.1. The hosted service at dev.morphik.ai has a free tier for personal and indie use. Commercial production deployments that generate more than US $2,000 per month in gross revenue require a paid commercial key. Self-hosted deployments under that revenue threshold are free. Each code version automatically re-licenses to Apache 2.0 four years after its first release, so the project is on a clear path to becoming fully open source.

Can I use Morphik with Claude or other AI assistants via MCP?

Yes. Morphik ships with built-in Model Context Protocol (MCP) support, which means you can connect it to Claude Desktop, Cursor, and other MCP-compatible tools and use your Morphik knowledge base as a context source directly inside those tools. You configure the MCP server endpoint in your client’s settings and then your AI assistant can retrieve from Morphik-indexed documents without any additional code. Full setup instructions are in the Morphik MCP docs.

Conclusion

Morphik closes the gap between “my AI can answer questions about text” and “my AI can answer questions about my actual documents” — the ones with tables, charts, diagrams, and scanned images that traditional RAG pipelines mangle beyond recognition. In this article we covered how to install the SDK and connect using a URI, how to ingest text with ingest_text() and files with ingest_file(), how to run semantic search with retrieve_chunks(), how to use full RAG with query(), how to scope documents to projects with folders, and how to switch to AsyncMorphik for concurrent workloads.

A good next step is to extend the PDF knowledge base CLI from the real-life example: add a web frontend with FastAPI, wire in metadata filtering by date or author, or hook the retrieve_chunks() output into your own custom LLM call so you control the generation step. For teams managing large document corpora, explore Morphik’s nested folders and the folder_depth=-1 parameter to query across an entire project tree in one call.

The official documentation lives at dev.morphik.ai/docs and covers the full REST API, self-hosting setup, integrations with Google Suite and Slack, and the knowledge graph features for visualising relationships between documents. The community Discord is active if you run into ingestion issues with unusual PDF layouts.

How To Enable and Use Python’s Experimental JIT

How To Enable and Use Python’s Experimental JIT

Intermediate

You’ve probably heard that Python is slow — and then maybe heard that CPython 3.13 ships with an experimental JIT compiler that promises to change that. If you’re running a data-processing loop that takes 30 seconds and wondering whether flipping one environment variable can shave time off it, you’re in the right place. Python’s new JIT is not magic, but it is real, measurable, and already available in the standard distribution on some platforms.

The experimental JIT in CPython 3.13 uses a technique called copy-and-patch compilation. It targets the same bytecode that Python’s specializing adaptive interpreter (introduced in PEP 659) already optimizes — but instead of interpreting optimized bytecode, it compiles it to native machine code at runtime. You don’t need Numba, Cython, or any third-party library. The JIT is built into CPython itself, though you do need to ensure it’s enabled at both compile time and runtime.

This article covers: how the CPython JIT works under the hood, how to check whether your Python installation includes it, how to enable it with a single environment variable or flag, how to measure real speedups on CPU-bound code, how it pairs with free-threading (no GIL), and what workloads benefit most. By the end, you’ll be able to test the JIT on your own code and understand what gains to expect.

Enabling the JIT: Quick Example

If your CPython 3.13+ is compiled with JIT support (we’ll verify that below), enabling it takes one environment variable. Here’s a tight CPU-bound loop we can benchmark:

# jit_demo.py
def count_primes(n):
    """Count primes up to n using trial division."""
    primes = 0
    for num in range(2, n):
        is_prime = True
        for divisor in range(2, int(num ** 0.5) + 1):
            if num % divisor == 0:
                is_prime = False
                break
        if is_prime:
            primes += 1
    return primes

if __name__ == "__main__":
    result = count_primes(50_000)
    print(f"Primes found: {result}")

Output:

Primes found: 5133

Benchmark it with and without the JIT from the command line:

# Run without JIT (default)
time python jit_demo.py

# Run with JIT enabled
time PYTHON_JIT=1 python jit_demo.py

Sample output (CPython 3.13, x86-64 Linux):

--- Without JIT ---
Primes found: 5133
real    0m4.823s

--- With JIT ---
Primes found: 5133
real    0m4.312s

A roughly 10-11% speedup on a raw trial-division loop with zero code changes. The gains vary by workload — numeric loops improve more than I/O-bound code. The next sections explain why, and how to confirm the JIT is actually running on your machine.

Cache Katie standing next to glowing CPU chip with speed lines representing Python JIT performance
PYTHON_JIT=1. That’s the whole migration guide.

What Is the CPython JIT and How Does It Work?

A just-in-time (JIT) compiler converts code to native machine instructions during program execution rather than ahead of time. Python has had various JIT experiments over the years — PyPy being the most mature — but CPython’s experimental JIT is different from all of them in one key way: it uses copy-and-patch compilation, designed specifically to integrate cleanly into CPython’s existing interpreter loop.

Here’s the simplified picture. CPython 3.11 introduced the Specializing Adaptive Interpreter, which watches which types flow through your functions and specializes bytecode opcodes for those types at runtime. Instead of a generic BINARY_OP instruction, the interpreter might specialize to BINARY_OP_ADD_INT — eliminating type checks on the hot path. The JIT in 3.13 takes those already-specialized bytecode sequences and converts them to native machine code by copying pre-compiled template machine code and patching in the runtime values (constants, addresses, type guards). No complex compiler passes, no runtime LLVM. This makes the JIT fast to compile and conservative about what it bets on.

ApproachExampleCompile TimeSpeedupCode Changes?
Specializing InterpreterCPython 3.11-3.13None~10-20% over 3.10No
Copy-and-patch JITCPython 3.13 (JIT on)Microseconds per trace~5-15% additionalNo (one env var)
Third-party JITNumba @jitSeconds per function10-100x for numericsYes (decorator)
Alternative runtimePyPyWarmup seconds3-10x overallUsually no

The CPython JIT sits between the pure interpreter and a full JIT engine like Numba. Its design priority is correctness and compatibility over maximum speed — which is why the gains are modest in 3.13 but growing with each release. Unlike Numba, you don’t annotate functions or restrict yourself to a subset of Python: the JIT handles arbitrary Python code.

Step 1 — Check Whether Your Python Has JIT Support

The JIT must be compiled into CPython at build time. Not every binary distribution includes it — particularly on older Linux distros and macOS where the build flags differ. Here’s how to check:

# check_jit.py
import sys

def check_jit_support():
    """Check whether this CPython build includes JIT support."""
    version = sys.version_info
    print(f"Python {version.major}.{version.minor}.{version.micro}")

    if version < (3, 13):
        print("JIT requires Python 3.13 or later.")
        return False

    # sys._jit_active() is available in 3.13+ JIT-enabled builds
    if hasattr(sys, '_jit_active'):
        active = sys._jit_active()
        print(f"JIT currently active: {active}")
        print("JIT support compiled in: True")
        return True
    else:
        print("JIT support compiled in: False")
        print("Tip: build CPython with --enable-experimental-jit")
        return False

check_jit_support()

Output (JIT-enabled build with PYTHON_JIT=1):

Python 3.13.0
JIT currently active: True
JIT support compiled in: True

Output (standard build or JIT=0):

Python 3.13.0
JIT support compiled in: False
Tip: build CPython with --enable-experimental-jit

If JIT is not available, the next section explains how to install a JIT-enabled Python using pyenv. If it is available, skip ahead to "Enabling the JIT at Runtime".

Debug Dee examining Python logo with magnifying glass checking for JIT compiler support
sys.version says 3.13. JIT support is a different question.

Step 2 -- Install a JIT-Enabled CPython via pyenv

pyenv lets you build CPython from source with custom configure flags. Building with JIT support requires LLVM 18 or later installed on your system, because the copy-and-patch templates are generated using LLVM at compile time (not at runtime).

# install_jit_python.sh

# macOS: install LLVM via Homebrew
brew install llvm@18

# Ubuntu/Debian: install LLVM
# sudo apt-get install llvm-18 clang-18

# Install CPython 3.13 with JIT enabled via pyenv
PYTHON_CONFIGURE_OPTS="--enable-experimental-jit" pyenv install 3.13.0

# Set it as the local Python version for this project
pyenv local 3.13.0

# Verify the build succeeded
python --version
python -c "import sys; print(hasattr(sys, '_jit_active'))"

Output:

Python 3.13.0
True

The build takes 2-5 minutes. If it fails with a "LLVM not found" error, ensure llvm-config is on your PATH. On macOS with Homebrew, add $(brew --prefix llvm@18)/bin to your PATH before running pyenv install. On Linux, the clang-18 package installs llvm-config-18 -- you may need to symlink it to llvm-config.

Step 3 -- Enable the JIT at Runtime

Once you have a JIT-capable build, there are two equivalent ways to enable the JIT at runtime. Choose whichever fits your workflow:

# enable_jit.sh

# Option 1: Environment variable (applies to all Python processes in this shell session)
export PYTHON_JIT=1
python my_script.py

# Option 2: Command-line flag (applies to this invocation only)
python -X jit my_script.py

# Option 3: Explicitly disable JIT even when PYTHON_JIT=1 is set globally
python -X jit=0 my_script.py

# Confirm the JIT is actually active inside your script:
python -c "import sys; print('JIT active:', sys._jit_active() if hasattr(sys, '_jit_active') else 'not available')"

Output (JIT active):

JIT active: True

The PYTHON_JIT=0 / -X jit=0 form lets you toggle the JIT off for debugging without rebuilding Python -- useful when you suspect the JIT is producing incorrect output (rare, but possible with experimental features). Always verify with sys._jit_active() before drawing conclusions from benchmark results.

Step 4 -- Benchmark the JIT on CPU-Bound Code

A single time call is noisy. The right approach is to run multiple iterations and measure the median. Python's built-in timeit module handles this cleanly:

# benchmark_jit.py
import timeit
import statistics
import sys

def fibonacci_loop(n):
    """Compute the nth Fibonacci number iteratively."""
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

def mandelbrot_point(c, max_iter=100):
    """Return iteration count for complex number c in Mandelbrot set."""
    z = 0
    for i in range(max_iter):
        if abs(z) > 2:
            return i
        z = z * z + c
    return max_iter

def mandelbrot_grid(width=200, height=150):
    """Sum iterations across a Mandelbrot grid."""
    total = 0
    for row in range(height):
        for col in range(width):
            c = complex(
                -2.5 + col * 3.5 / width,
                -1.25 + row * 2.5 / height
            )
            total += mandelbrot_point(c)
    return total

# Check JIT status
jit_on = sys._jit_active() if hasattr(sys, '_jit_active') else False
print(f"Python {sys.version_info.major}.{sys.version_info.minor} | JIT active: {jit_on}")
print()

benchmarks = [
    ("fibonacci_loop(10_000)", {"fibonacci_loop": fibonacci_loop}),
    ("mandelbrot_grid()", {"mandelbrot_grid": mandelbrot_grid, "mandelbrot_point": mandelbrot_point}),
]

REPEAT = 5
for stmt, globs in benchmarks:
    times = timeit.repeat(stmt, globals=globs, number=10, repeat=REPEAT)
    med_ms = statistics.median(times) * 1000
    print(f"{stmt}: {med_ms:.1f} ms (median of {REPEAT} runs x 10)")

Output without JIT (python benchmark_jit.py):

Python 3.13 | JIT active: False

fibonacci_loop(10_000): 4.2 ms (median of 5 runs x 10)
mandelbrot_grid(): 3891.4 ms (median of 5 runs x 10)

Output with JIT (PYTHON_JIT=1 python benchmark_jit.py):

Python 3.13 | JIT active: True

fibonacci_loop(10_000): 3.8 ms (median of 5 runs x 10)
mandelbrot_grid(): 3423.6 ms (median of 5 runs x 10)

The Mandelbrot benchmark shows ~12% improvement -- a classic JIT win because the inner loop executes the same operations on complex numbers thousands of times, giving the JIT enough warmup to pay off. The JIT is most effective on tight, repeated loops with stable types. Note that first-run times may be slightly slower due to compilation overhead -- the timeit repeat approach captures steady-state performance, which is what matters for long-running processes.

Pyro Pete cheering at turtle race where one turtle with rocket booster is slightly ahead representing JIT speedup
12% faster. Still Python. Still your best option without a full rewrite.

Pairing the JIT with Free-Threading (No GIL)

Python 3.13 also ships an experimental "free-threaded" mode that disables the Global Interpreter Lock (GIL), allowing true CPU-level parallelism across multiple threads. The JIT and free-threading are independent features, but they can be enabled together -- and the combination is particularly interesting for CPU-bound multi-threaded workloads.

Free-threading requires its own special Python build (the 3.13t variant). You can check whether your build supports it and whether the GIL is currently enabled:

# check_freethreading.py
import sys

def check_free_threading():
    """Check if this build supports free-threading."""
    if not hasattr(sys, '_is_gil_enabled'):
        print("Free-threading not available in this build.")
        print("Install a 't' variant: pyenv install 3.13t")
        return False

    gil_on = sys._is_gil_enabled()
    print(f"Free-threading build: True")
    print(f"GIL currently enabled: {gil_on}")

    if not gil_on:
        print("Running in free-threaded mode -- true parallelism available.")
    else:
        print("To disable GIL: set PYTHON_GIL=0 or pass -X gil=0")
    return True

check_free_threading()

Output (free-threaded build with GIL disabled):

Free-threading build: True
GIL currently enabled: False
Running in free-threaded mode -- true parallelism available.

To enable both the JIT and free-threading simultaneously:

# Enable both JIT and free-threading together
PYTHON_GIL=0 PYTHON_JIT=1 python my_script.py

# Or with flags
python -X gil=0 -X jit my_script.py

Keep in mind: both features are experimental in 3.13. Running them together amplifies that caveat. Use this combination for benchmarking and exploration, not production systems handling sensitive data. That said, most pure-Python code runs correctly under both flags. The free-threading + JIT combination is expected to become more stable through CPython 3.14.

What Workloads Benefit Most from the JIT

The JIT is not a universal speedup switch. Understanding what it targets helps you predict where it will help and where it won't change anything.

Workload TypeJIT BenefitWhy
Tight numeric loops (int/float)High (10-20%)Stable types, many iterations, classic JIT target
Mandelbrot / fractal computationHigh (10-15%)Inner loop runs millions of times with same types
String processing loopsModerate (5-10%)Some specialization, but string ops are already fast C
Data parsing (JSON, CSV)Low (0-5%)Dominated by C extensions; JIT doesn't touch C code
Web requests / I/ONoneBlocked on network, not CPU computation
NumPy / Pandas operationsNoneCore ops run in C; Python overhead already minimal
asyncio event loopsLowOverhead is machinery, not tight computation loops

The rule of thumb: if you're already considering Numba or Cython to speed something up, the CPython JIT is worth trying first. If the bottleneck is I/O, network, or C extension calls, the JIT won't help -- profile with cProfile first to locate the actual hot path before reaching for any optimization tool.

Debug Dee holding magnifying glass over flame graph profiling visualization highlighting hot code path
Profile before you optimize. The JIT only helps code that's actually running.

Real-Life Example: JIT-Aware Performance Benchmarking Tool

Let's build a small utility that auto-detects JIT availability and reports speedup across multiple test functions. This is the kind of tool you'd use to evaluate whether the JIT is worth enabling for a specific codebase before committing to it in a deployment environment.

# jit_benchmark_tool.py
import timeit
import statistics
import subprocess
import sys
import os

# Each benchmark is a self-contained Python snippet
BENCHMARK_FUNCTIONS = {
    "prime_count_10k": """
def is_prime(n):
    if n < 2: return False
    for i in range(2, int(n**0.5)+1):
        if n % i == 0: return False
    return True
result = sum(1 for n in range(2, 10_000) if is_prime(n))
""",
    "string_reverse_100k": """
result = 0
for i in range(100_000):
    s = str(i)
    result += len(s[::-1])
""",
    "float_accumulate_1M": """
total = 0.0
for i in range(1_000_000):
    total += i * 1.5 / (i + 1)
""",
}

def run_timed(enabled, stmt, repeat=3, number=5):
    """Run a benchmark snippet in a subprocess with JIT on or off."""
    env = os.environ.copy()
    env["PYTHON_JIT"] = "1" if enabled else "0"
    script = f"""
import timeit, statistics
times = timeit.repeat(stmt='''{stmt}''', repeat={repeat}, number={number})
print(statistics.median(times))
"""
    result = subprocess.run(
        [sys.executable, "-c", script],
        capture_output=True, text=True, env=env
    )
    if result.returncode != 0:
        return None
    try:
        return float(result.stdout.strip())
    except ValueError:
        return None

def main():
    print(f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
    print(f"JIT build: {hasattr(sys, '_jit_active')}")
    print()
    print(f"{'Benchmark':<28} {'No JIT':>10} {'JIT On':>10} {'Speedup':>10}")
    print("-" * 62)

    for name, stmt in BENCHMARK_FUNCTIONS.items():
        t_base = run_timed(False, stmt)
        t_jit = run_timed(True, stmt)

        if t_base is None or t_jit is None:
            print(f"{name:<28} {'error':>10} {'error':>10} {'N/A':>10}")
            continue

        speedup = t_base / t_jit if t_jit > 0 else float('inf')
        print(f"{name:<28} {t_base*1000:>9.1f}ms {t_jit*1000:>9.1f}ms {speedup:>9.2f}x")

    print("-" * 62)
    print("Speedup > 1.0x means JIT is faster.")

if __name__ == "__main__":
    main()

Output:

Python 3.13.0
JIT build: True

Benchmark                      No JIT     JIT On    Speedup
--------------------------------------------------------------
prime_count_10k               245.3ms    216.8ms      1.13x
string_reverse_100k            89.4ms     85.1ms      1.05x
float_accumulate_1M           312.7ms    271.4ms      1.15x
--------------------------------------------------------------
Speedup > 1.0x means JIT is faster.

This tool uses subprocesses to guarantee the JIT environment variable is cleanly applied per run with no cross-contamination. Add your own functions to BENCHMARK_FUNCTIONS -- just keep each statement self-contained with its own imports. The subprocess approach also means you can run this benchmarker from a non-JIT Python binary and still get accurate JIT measurements for the target interpreter.

Sudo Sam relaxing in office chair satisfied with benchmark results showing JIT performance improvements
1.13x faster. No Cython. No Numba. Just an environment variable.

Frequently Asked Questions

Is the CPython JIT safe for production use?

As of Python 3.13, the JIT is marked "experimental" for good reason -- it may produce incorrect results in edge cases, particularly with unusual bytecode patterns or less-tested platform and architecture combinations. For web servers and data pipelines processing real user data, stick with the default interpreter. For internal tools, batch jobs, and scientific computing where you can validate output independently, the JIT is worth testing. Run your full test suite with PYTHON_JIT=1 first and check for any failures before enabling it in a production environment.

Which platforms support the JIT?

As of CPython 3.13, the JIT builds and runs on x86-64 Linux, x86-64 macOS, ARM64 (Apple Silicon) macOS, and ARM64 Linux. Windows support is in progress but not fully stable as of 3.13. The key dependency is LLVM 18+ for generating the copy-and-patch templates at build time. Pre-built binaries from python.org do not include the JIT by default -- you need to build from source using pyenv with --enable-experimental-jit, or use a distribution that explicitly ships JIT-enabled builds.

How is this different from Numba's JIT?

Numba's @jit decorator compiles specific functions to native code using LLVM and requires those functions to use only Numba-supported Python and NumPy constructs. It can deliver 10-100x speedups on numeric code because it eliminates Python overhead entirely for the annotated functions. CPython's JIT is more conservative -- it handles arbitrary Python code, requires no annotations, and gains 5-15%. Think of Numba as a scalpel (surgical, powerful, specific) and the CPython JIT as a general performance floor-raise across your whole codebase. Use Numba where you need maximum numeric throughput; use the CPython JIT when you want an easy, broad improvement without touching your code.

Does the JIT need warmup time?

Yes, but the warmup is shorter than traditional JIT compilers. The copy-and-patch approach compiles traces (sequences of bytecode) on first execution and caches the compiled machine code. Short-running scripts (less than a second of CPU work) may see no improvement or even a slight slowdown on first run due to compilation overhead. The benchmarking tool above uses timeit.repeat with multiple runs to measure steady-state performance rather than cold-start time. For long-running servers and batch jobs, the warmup cost is negligible compared to total runtime.

Does the JIT increase memory usage?

The compiled machine code for each trace takes additional memory. In practice the overhead is modest -- typically a few megabytes for usual Python programs -- because the JIT only compiles hot traces (frequently executed code paths), not every function. Programs with many distinct hot paths (large web applications with thousands of routes) may see more overhead than programs with a small number of tight inner loops. Monitor RSS memory with resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if you're running in a memory-constrained environment.

How do I debug JIT-related errors?

Start by disabling the JIT with PYTHON_JIT=0 and checking whether the error disappears. If it does, the JIT is producing incorrect output for that code path -- this is a CPython bug and should be reported at bugs.python.org with a minimal reproducer. For diagnostic output about what the JIT is compiling, set PYTHON_LLTRACE=1 (very verbose). Most real bugs with the 3.13 JIT involve C extension interaction or unusual bytecode sequences from dynamic code generation such as eval() or exec(). Regular Python code rarely triggers JIT correctness issues.

Conclusion

Python's experimental JIT compiler in CPython 3.13 is the first native JIT built directly into the reference interpreter. It uses copy-and-patch compilation that targets already-specialized bytecode, requires no code changes, and delivers 5-15% speedups on CPU-bound loops. To use it, you need a JIT-enabled CPython build (compiled with --enable-experimental-jit) and a single environment variable: PYTHON_JIT=1. We covered how to verify JIT support, install it via pyenv, enable it at runtime, benchmark it with timeit, pair it with free-threading, and understand which workloads benefit most.

Try extending the benchmarking tool with your own CPU-bound functions -- sorting algorithms, recursive tree traversals, or text processing loops. The results will tell you immediately whether the JIT is worth enabling for your specific use case. For deeper reading, see the Python 3.13 What's New: JIT Compiler and the original design document at PEP 744 -- JIT Compilation.

As CPython 3.14 continues the JIT's development with improved coverage and larger speedups, keeping the JIT in your toolbox today means you'll be positioned to benefit more with each Python release -- without changing a line of your application code.

How To Use Python Daft for Distributed DataFrames

How To Use Python Daft for Distributed DataFrames

Intermediate

You have a pipeline that works perfectly on 10,000 rows. Then your dataset grows to 50 million rows and Pandas starts swapping to disk, Polars runs out of memory, and your laptop fan sounds like a helicopter. You need a DataFrame tool that can seamlessly scale from your laptop to a distributed Ray cluster — without rewriting your entire codebase. That is exactly what Daft is built for.

Daft is an open-source DataFrame library written in Rust with a Python API, developed by Eventual. It uses lazy evaluation, columnar execution, and a query optimizer modelled on database engines. On a single machine it already outperforms Pandas significantly. When you attach a Ray cluster, the same code runs distributed across hundreds of cores with no changes. It also handles multimodal data natively — URLs, images, embeddings, and documents sit alongside numeric columns in the same DataFrame.

In this article you will learn how to install Daft, create DataFrames from Python dicts, CSV, and Parquet files, filter and aggregate data, use the URL and image types for multimodal workflows, and scale a job to a Ray cluster. By the end you will have a complete working example that reads a large synthetic dataset, runs aggregations, and shows how to attach Ray with a single line of code.

How To Use Daft: Quick Example

If you are in a hurry, here is a minimal working example that shows the core Daft workflow — create a DataFrame, filter it, and collect the result:

# quick_daft.py
import daft

# Create a DataFrame from a Python dictionary
df = daft.from_pydict({
    "product": ["widget", "gadget", "gizmo", "widget", "gadget"],
    "region":  ["east",   "west",   "east",  "west",   "east"],
    "sales":   [120,      85,       200,     95,       310],
})

# Filter: only rows where sales > 100
high_sales = df.where(df["sales"] > 100)

# Select specific columns and collect the result
result = high_sales.select("product", "region", "sales").collect()
print(result)

Output:

+---------+--------+-------+
| product | region | sales |
| Utf8    | Utf8   | Int64 |
+=========+========+=======+
| widget  | east   | 120   |
+---------+--------+-------+
| gizmo   | east   | 200   |
+---------+--------+-------+
| gadget  | east   | 310   |
+---------+--------+-------+
(Showing first 3 of 3 rows)

Two things to notice. First, daft.from_pydict() builds a lazy DataFrame — no data is processed yet. Second, .collect() is what actually triggers execution and materializes the result. This lazy-first design means Daft can plan and optimize the whole query before touching a single byte of data.

The sections below go deeper: reading from files, grouping, aggregating, handling URLs as first-class data types, and scaling to a Ray cluster.

Sudo Sam in a server room holding a glowing DataFrame blueprint
Lazy evaluation: the query optimizer does the heavy lifting before a single byte moves.

What Is Daft and Why Use It?

Daft is a distributed query engine that presents itself as a DataFrame API. Under the hood it compiles your Python method calls into a logical query plan, optimizes it (pushing down filters, eliminating unnecessary column reads), and then executes it either locally or on a distributed Ray cluster.

The key design choices that make Daft different from Pandas and Polars:

  • Lazy by default. Calling df.where(...) does not run anything. You chain transformations, and execution only happens when you call .collect(), .show(), or write to a sink. This lets the optimizer reorder and merge operations.
  • Distributed-first. Attach Ray with daft.context.set_runner_ray() and the same code distributes across any cluster. No rewrite needed.
  • Multimodal columns. Daft has built-in URL, Image, and Embedding types. You can store image URLs in a column, call .url.download(), and then .image.decode() — all in a distributed pipeline.
  • Rust core. The query engine is written in Rust, giving it performance close to compiled code even for single-machine workloads.

Here is how Daft compares to other Python DataFrame libraries:

FeaturePandasPolarsDaft
Execution modelEagerLazy + eagerLazy
DistributedNoNoYes (Ray)
Multimodal typesNoNoYes (URL, Image, Embedding)
Core languagePython/CRustRust
Query optimizerNoYesYes
Best forSmall data, legacy codeFast single-node analyticsBig data + multimodal pipelines

If your data fits in RAM and you do not need image or URL handling, Polars is probably a better fit. If you need to scale beyond one machine or handle multimodal payloads alongside tabular data, Daft is the right tool.

Installing Daft

Install the base package with pip. The [all] extra adds integrations for Ray, AWS S3, Delta Lake, and Apache Iceberg:

# install_daft.sh
pip install daft          # base install, local runner only
pip install "daft[all]"   # recommended: includes Ray, S3, Delta Lake, Iceberg

Output:

Successfully installed daft-0.4.x ...

Verify the install by importing and checking the version:

# verify_daft.py
import daft
print(daft.__version__)

Output:

0.4.x

If you see the version string, you are ready. The base install is enough to follow every example in this article. You only need the [all] extra when you connect to Ray or read from cloud storage.

Pyro Pete standing on a CPU chip with data streams around him
pip install daft. The query optimizer shows up for work before you even call .collect().

Creating DataFrames in Daft

Daft can ingest data from Python objects, CSV files, Parquet files, JSON files, and cloud storage. Here are the most common entry points.

From a Python Dictionary

The fastest way to get started is daft.from_pydict(). It converts a plain Python dictionary of lists into a Daft DataFrame, inferring types automatically:

# from_dict.py
import daft

df = daft.from_pydict({
    "city":        ["Sydney",    "Melbourne", "Brisbane",  "Perth"],
    "population":  [5_300_000,   5_150_000,   2_650_000,   2_200_000],
    "avg_temp_c":  [17.7,        15.2,        20.8,        18.1],
    "coastal":     [True,        True,        True,        True],
})

df.show()

Output:

+-----------+------------+-------------+---------+
| city      | population | avg_temp_c  | coastal |
| Utf8      | Int64      | Float64     | Boolean |
+===========+============+=============+=========+
| Sydney    | 5300000    | 17.7        | true    |
+-----------+------------+-------------+---------+
| Melbourne | 5150000    | 15.2        | true    |
+-----------+------------+-------------+---------+
| Brisbane  | 2650000    | 20.8        | true    |
+-----------+------------+-------------+---------+
| Perth     | 2200000    | 18.1        | true    |
+-----------+------------+-------------+---------+
(Showing first 4 of 4 rows)

Notice that .show() triggers execution (like .collect()) but prints a formatted table instead of returning a Python object. Daft inferred Utf8 for strings, Int64 for integers, and Float64 for floats — you rarely need to specify types manually.

From a CSV File

For files on disk, use daft.read_csv(). Daft reads the schema lazily and only parses the rows it needs once execution is triggered:

# from_csv.py
import daft
import csv

# First, create a sample CSV file to read
rows = [
    ["name", "dept", "salary"],
    ["Alice",   "engineering", 95000],
    ["Bob",     "marketing",   72000],
    ["Charlie", "engineering", 110000],
    ["Diana",   "hr",          68000],
    ["Eve",     "engineering", 88000],
]
with open("employees.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(rows)

# Read with Daft
df = daft.read_csv("employees.csv")
df.show()

Output:

+---------+-------------+--------+
| name    | dept        | salary |
| Utf8    | Utf8        | Int64  |
+=========+=============+========+
| Alice   | engineering | 95000  |
+---------+-------------+--------+
| Bob     | marketing   | 72000  |
+---------+-------------+--------+
| Charlie | engineering | 110000 |
+---------+-------------+--------+
| Diana   | hr          | 68000  |
+---------+-------------+--------+
| Eve     | engineering | 88000  |
+---------+-------------+--------+

daft.read_csv() also accepts glob patterns like "data/*.csv" and S3 paths like "s3://my-bucket/data/*.csv" once you have the S3 extra installed. The API is identical regardless of where the data lives.

From a Parquet File

Parquet is Daft’s preferred format because the query optimizer can push down filters directly to the Parquet reader, skipping entire row groups without reading them:

# from_parquet.py
import daft

# Write a sample Parquet file using pyarrow (install: pip install pyarrow)
import pyarrow as pa
import pyarrow.parquet as pq

table = pa.table({
    "sku":      ["A001", "A002", "B001", "B002", "C001"],
    "category": ["shoes", "shoes", "bags",  "bags",  "hats"],
    "price":    [89.99,   120.00, 45.00,   75.50,   29.99],
    "stock":    [150,     82,     200,     55,      310],
})
pq.write_table(table, "inventory.parquet")

# Read back with Daft
df = daft.read_parquet("inventory.parquet")

# Filter pushed down to Parquet reader -- only reads qualifying rows
result = df.where(df["category"] == "shoes").collect()
print(result)

Output:

+------+----------+--------+-------+
| sku  | category | price  | stock |
| Utf8 | Utf8     | Float64| Int64 |
+======+==========+========+=======+
| A001 | shoes    | 89.99  | 150   |
+------+----------+--------+-------+
| A002 | shoes    | 120.0  | 82    |
+------+----------+--------+-------+
(Showing first 2 of 2 rows)

The filter on category is pushed down to the Parquet reader, so Daft never loads the bags and hats rows into memory. For large datasets this optimization makes a significant difference in both memory and runtime.

Loop Larry standing inside a giant file folder icon with data streaming past
Predicate pushdown: Daft skips the rows it does not need before they touch RAM.

Filtering and Selecting Data

Daft’s expression API is how you describe column operations. You access columns with bracket notation, and expressions compose naturally using Python operators:

# filter_select.py
import daft

df = daft.from_pydict({
    "employee": ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank"],
    "dept":     ["eng",   "eng", "sales",   "eng",   "sales", "hr"],
    "years":    [5,       2,     8,          11,      3,       6],
    "salary":   [95000,   72000, 88000,      115000,  68000,   79000],
})

# Filter: engineering employees with 5+ years
senior_eng = df.where(
    (df["dept"] == "eng") & (df["years"] >= 5)
)

# Select and add a derived column (10% raise simulation)
result = senior_eng.select(
    df["employee"],
    df["salary"],
    (df["salary"] * 1.10).alias("salary_with_raise"),
)

result.show()

Output:

+----------+--------+-------------------+
| employee | salary | salary_with_raise |
| Utf8     | Int64  | Float64           |
+==========+========+===================+
| Alice    | 95000  | 104500.0          |
+----------+--------+-------------------+
| Diana    | 115000 | 126500.0          |
+----------+--------+-------------------+

The .alias() method renames an expression result. Combining expressions with & (and) and | (or) is the standard pattern for compound filters. Notice that parentheses around each condition are required because Python’s operator precedence for & is higher than == and >=.

Useful Column Expression Methods

Daft expressions include a range of built-in operations for strings, math, and dates:

# expressions.py
import daft

df = daft.from_pydict({
    "name":    ["alice smith",  "BOB JONES",   "Charlie Brown"],
    "score":   [78.5,           92.1,           55.0],
    "tag":     ["python,rust",  "python",       "go,rust,c"],
})

result = df.select(
    df["name"].str.upper().alias("name_upper"),    # string method
    df["score"].ceil().alias("score_ceil"),        # math: ceiling
    df["tag"].str.contains("rust").alias("uses_rust"),  # substring check
)

result.show()

Output:

+---------------+------------+------------+
| name_upper    | score_ceil | uses_rust  |
| Utf8          | Float64    | Boolean    |
+===============+============+============+
| ALICE SMITH   | 79.0       | true       |
+---------------+------------+------------+
| BOB JONES     | 93.0       | false      |
+---------------+------------+------------+
| CHARLIE BROWN | 55.0       | true       |
+---------------+------------+------------+

The .str accessor exposes string operations (upper, lower, contains, startswith, split, replace), and there are matching accessors for dates (.dt) and lists (.list). These all compose into the lazy query plan and are executed in Rust.

GroupBy and Aggregations

Aggregations in Daft use the .groupby().agg() pattern. You pass a list of expressions to .agg() using built-in aggregation functions:

# groupby_agg.py
import daft
from daft import col

df = daft.from_pydict({
    "dept":   ["eng", "eng", "sales", "sales", "hr",  "eng",  "hr"],
    "salary": [95000, 110000, 72000,   88000,   65000, 98000,  71000],
    "years":  [5,     11,     8,       3,        4,    7,      9],
})

# Group by department, compute multiple aggregations
result = df.groupby("dept").agg(
    col("salary").mean().alias("avg_salary"),
    col("salary").max().alias("max_salary"),
    col("years").mean().alias("avg_years"),
    col("dept").count().alias("headcount"),
)

result.sort("avg_salary", desc=True).show()

Output:

+-------+------------+------------+-----------+-----------+
| dept  | avg_salary | max_salary | avg_years | headcount |
| Utf8  | Float64    | Int64      | Float64   | UInt64    |
+=======+============+============+===========+===========+
| eng   | 101000.0   | 110000     | 7.67      | 3         |
+-------+------------+------------+-----------+-----------+
| sales | 80000.0    | 88000      | 5.5       | 2         |
+-------+------------+------------+-----------+-----------+
| hr    | 68000.0    | 71000      | 6.5       | 2         |
+-------+------------+------------+-----------+-----------+

Note the from daft import col import. col("salary") is functionally equivalent to df["salary"] but does not require a reference to a specific DataFrame object — useful when building reusable expressions. The supported aggregation functions include mean(), sum(), min(), max(), count(), list(), and agg_list().

Cache Katie at a control panel with aggregation results on screens
groupby().agg(): one query plan, one pass over the data, every aggregate computed together.

Multimodal Data: URLs and Images

Daft’s most distinctive feature is its native support for non-tabular data types. A DataType.Url() column stores URL strings and adds a .url.download() method that fetches the content in parallel. A DataType.Image() column stores decoded images as arrays you can resize, crop, or pass to a model.

Downloading URLs in Parallel

The following example builds a DataFrame with a URL column, downloads the content from each URL in parallel, and stores the raw bytes as a new column:

# url_download.py
import daft
from daft import DataType

# httpbin.org returns the request back as JSON -- safe, predictable practice endpoint
df = daft.from_pydict({
    "label": ["get-test", "uuid-test", "ip-test"],
    "url": [
        "https://httpbin.org/get",
        "https://httpbin.org/uuid",
        "https://httpbin.org/ip",
    ],
})

# Cast the url column to the URL type so Daft knows to download it
df = df.with_column(
    "url",
    df["url"].cast(DataType.url()),
)

# Download all URLs in parallel (Daft manages concurrency)
downloaded = df.with_column(
    "response_bytes",
    df["url"].url.download(),
)

result = downloaded.select("label", "response_bytes").collect()

# Decode the bytes for display
for row in result.to_pydict()["response_bytes"]:
    if row:
        print(row[:80].decode("utf-8", errors="replace"))

Output:

{
  "args": {},
  "headers": {
    "Accept": "*/*",
...
{"uuid": "3a7f1c2d-..."}
{"origin": "203.x.x.x"}

The .url.download() call fires all HTTP requests concurrently using Daft’s Rust executor. The result is stored as raw bytes (DataType.Binary()). This same pattern works for downloading images from S3 URLs, product images from e-commerce catalogs, or any other HTTP-accessible resource — without writing a single asyncio coroutine yourself.

Decoding and Resizing Images

Once you have image bytes in a column, you can decode them into Daft’s Image type and apply transformations:

# image_resize.py
import daft
from daft import DataType
import requests

# Download one real image to disk for the demo
img_bytes = requests.get(
    "https://httpbin.org/image/png"
).content
with open("sample.png", "wb") as f:
    f.write(img_bytes)

# Load image bytes from disk
df = daft.from_pydict({
    "filename": ["sample.png"],
    "raw_bytes": [open("sample.png", "rb").read()],
})

# Decode raw bytes into the Image type (PNG auto-detected)
df = df.with_column(
    "image",
    df["raw_bytes"].cast(DataType.image()),
)

# Resize to 64x64 -- returns a new Image column
df = df.with_column(
    "thumbnail",
    df["image"].image.resize(64, 64),
)

result = df.select("filename", "thumbnail").collect()
print("Thumbnail column type:", result.schema()["thumbnail"].dtype)
print("Rows processed:", len(result))

Output:

Thumbnail column type: Image(height=64, width=64, mode=RGB)
Rows processed: 1

The .image.resize() operation runs inside Daft’s Rust executor, applying the resize to every row in parallel. In a real ML pipeline you would follow this with a step that converts the image to a NumPy array or passes it directly to a PyTorch transform — all within the same Daft pipeline, without materializing intermediate results to disk.

API Alice pointing at a glowing image grid showing parallel downloads
.url.download() — because fetching 10,000 images one by one is how you explain async to your manager.

Scaling to a Ray Cluster

The defining feature of Daft is that you can switch from single-machine execution to distributed execution across a Ray cluster with one line of code. Your transformations, filters, and aggregations stay exactly the same.

Running with a Local Ray Cluster

To test distributed mode on your laptop, initialize Ray locally and tell Daft to use it:

# ray_local.py
import ray
import daft

# Start a local Ray cluster (uses all available CPU cores)
ray.init()

# Tell Daft to use the Ray runner instead of the default local runner
daft.context.set_runner_ray()

# Now every Daft operation runs distributed across Ray workers
df = daft.from_pydict({
    "x": list(range(1_000_000)),  # 1 million rows
})

result = df.with_column(
    "x_squared",
    df["x"] * df["x"],
).where(
    df["x"] % 1000 == 0    # keep every 1000th row
).collect()

print(f"Rows returned: {len(result)}")
print("First 5 x values:", result.to_pydict()["x"][:5])

# Shut down Ray when done
ray.shutdown()

Output:

2026-08-09 ... INFO worker.py:... -- Started a local Ray instance.
Rows returned: 1000
First 5 x values: [0, 1000, 2000, 3000, 4000]

ray.init() without arguments starts a local multi-core cluster using all available CPUs. In production you would replace it with ray.init(address="ray://your-cluster-address:10001") to connect to a remote cluster. Everything else stays identical — the same code that runs on your laptop during development distributes across your cloud cluster in production.

Connecting to a Remote Ray Cluster

For production workloads, the only change is the ray.init() call:

# ray_remote.py
import ray
import daft

# Connect to an existing Ray cluster
ray.init(address="ray://my-ray-head-node:10001")

# Daft now distributes across that cluster automatically
daft.context.set_runner_ray()

# Read from S3, distribute processing, write results back
df = daft.read_parquet("s3://my-bucket/events/*.parquet")

summary = df.groupby("event_type").agg(
    col("user_id").count().alias("event_count"),
    col("revenue").sum().alias("total_revenue"),
)

summary.write_parquet("s3://my-bucket/summaries/")
print("Summary written to S3.")

Output:

Summary written to S3.

This is the complete distributed pipeline. Daft reads the Parquet files in parallel across Ray workers, performs the groupby aggregation distributed, and writes the result back to S3. No Spark, no JVM, no separate cluster management tool — just Python, Ray, and Daft.

Real-Life Example: Sales Analysis Pipeline

Here is a complete working example that generates a realistic sales dataset, runs several analysis steps, and produces a report. This ties together the CSV reading, filtering, groupby, and derived columns you have learned:

# sales_analysis.py
import daft
from daft import col
import csv
import random
import os

random.seed(42)

# --- Generate synthetic sales data ---
products  = ["Widget A", "Widget B", "Gadget X", "Gadget Y", "Gizmo Z"]
regions   = ["North", "South", "East", "West"]
quarters  = ["Q1", "Q2", "Q3", "Q4"]

rows = [["product", "region", "quarter", "units", "unit_price"]]
for _ in range(500):
    product    = random.choice(products)
    region     = random.choice(regions)
    quarter    = random.choice(quarters)
    units      = random.randint(10, 500)
    unit_price = round(random.uniform(5.0, 150.0), 2)
    rows.append([product, region, quarter, units, unit_price])

with open("sales.csv", "w", newline="") as f:
    csv.writer(f).writerows(rows)

# --- Load and analyse with Daft ---
df = daft.read_csv("sales.csv")

# Add a revenue column
df = df.with_column(
    "revenue",
    (df["units"] * df["unit_price"]).alias("revenue"),
)

# Top-performing regions by total revenue
regional = (
    df.groupby("region")
    .agg(
        col("revenue").sum().alias("total_revenue"),
        col("units").sum().alias("total_units"),
        col("region").count().alias("num_transactions"),
    )
    .sort("total_revenue", desc=True)
)

print("=== Revenue by Region ===")
regional.show()

# Q4 performance by product (only Q4 rows)
q4 = df.where(df["quarter"] == "Q4")
q4_product = (
    q4.groupby("product")
    .agg(col("revenue").sum().alias("q4_revenue"))
    .sort("q4_revenue", desc=True)
)

print("=== Q4 Revenue by Product ===")
q4_product.show()

# High-value transactions (revenue > 10,000)
high_value = df.where(df["revenue"] > 10_000).select(
    "product", "region", "quarter", "units", "unit_price", "revenue"
)
count = high_value.count_rows()
print(f"\nHigh-value transactions (revenue > $10,000): {count}")

# Cleanup
os.remove("sales.csv")

Output:

=== Revenue by Region ===
+--------+---------------+-------------+------------------+
| region | total_revenue | total_units | num_transactions |
| Utf8   | Float64       | Int64       | UInt64           |
+========+===============+=============+==================+
| West   | 482317.84     | 30142       | 126              |
+--------+---------------+-------------+------------------+
| North  | 471203.61     | 29887       | 124              |
+--------+---------------+-------------+------------------+
| South  | 468912.45     | 29201       | 125              |
+--------+---------------+-------------+------------------+
| East   | 452841.73     | 28963       | 125              |
+--------+---------------+-------------+------------------+

=== Q4 Revenue by Product ===
+----------+------------+
| product  | q4_revenue |
| Utf8     | Float64    |
+==========+============+
| Gadget Y | 138402.11  |
+----------+------------+
| Widget A | 130817.54  |
+----------+------------+
| Gadget X | 129301.22  |
+----------+------------+
| Gizmo Z  | 120944.87  |
+----------+------------+
| Widget B | 117623.04  |
+----------+------------+

High-value transactions (revenue > $10,000): 42

This pipeline reads 500 rows of synthetic data, adds a computed column, runs two separate groupby aggregations, and filters for high-value transactions — all using lazy evaluation. Daft compiled the three terminal .show() and .count_rows() calls into separate query plans and optimized each independently. To scale this to 50 million rows, you would replace daft.read_csv("sales.csv") with daft.read_parquet("s3://your-bucket/sales/*.parquet") and add daft.context.set_runner_ray() at the top.

Debug Dee pointing at a holographic distributed pipeline diagram
500 rows or 500 million — the query plan is the same. Only the cluster size changes.

Frequently Asked Questions

When should I choose Daft over Polars?

Choose Daft when your data does not fit on a single machine, when you need to process multimodal data (images, URLs, embeddings) alongside tabular columns, or when you need to run the same pipeline both locally and distributed without code changes. Polars is the better choice for single-node analytics where pure DataFrame performance is the priority — Polars’ streaming mode handles many large datasets without requiring a distributed cluster. If you are already on a Ray cluster for other workloads, Daft integrates naturally into that ecosystem.

Why does nothing happen until I call .collect()?

Daft uses lazy evaluation, which means every method call (.where(), .select(), .groupby()) adds a node to a logical query plan rather than executing immediately. When you call .collect() or .show(), Daft hands the plan to its query optimizer, which reorders and merges operations for efficiency, then executes the optimized plan. This is the same approach used by SQL databases and Apache Spark — it is what makes filter pushdown, column pruning, and distributed execution possible.

How do I save a Daft DataFrame to a file?

Use .write_parquet(path) for Parquet, .write_csv(path) for CSV, or .write_json(path) for JSON. Each method triggers execution (like .collect()) and writes the output directly to disk. For S3, pass an S3 URI as the path and ensure the daft[aws] extra is installed: df.write_parquet("s3://my-bucket/output/"). For Delta Lake and Apache Iceberg, there are dedicated .write_deltalake() and .write_iceberg() methods.

Can I convert a Daft DataFrame to a Pandas DataFrame?

Yes. Call df.collect().to_pandas() — this materializes the result and converts it to a pandas.DataFrame. You can also go the other direction: daft.from_pandas(pandas_df) wraps an existing Pandas DataFrame in a Daft lazy DataFrame. This interoperability means you can use Daft for the heavy distributed processing and Pandas for final visualization or export steps that expect a Pandas object.

Can I apply custom Python functions to a Daft column?

Yes, using the @daft.udf decorator. You define a Python function, decorate it with @daft.udf(return_dtype=DataType.string()), and then call it like a built-in expression: df.with_column("result", my_udf(df["input_col"])). When running on Ray, Daft distributes the UDF across workers automatically. UDFs are slower than built-in expressions (they leave the Rust executor and enter Python), so use them only for logic that cannot be expressed with native Daft operations.

How does Daft infer the schema of a CSV or Parquet file?

For CSV, Daft reads the first few rows to sample types, then applies those inferred types to the rest of the file. For Parquet, the schema is stored in the file metadata and is read exactly — no sampling needed. If the inferred schema is wrong for a CSV column (for example, a ZIP code column inferred as Int64 instead of Utf8), pass a schema override: daft.read_csv("file.csv", schema_hints={"zip_code": DataType.string()}).

Conclusion

Daft brings distributed DataFrame processing to Python without forcing you to learn Spark or rewrite your code when your dataset outgrows a single machine. In this article you learned how to create DataFrames from Python dicts, CSV, and Parquet files; how to filter with compound expressions; how to group and aggregate with .groupby().agg(); how to use the URL type for parallel downloads; how to decode and resize images within a Daft pipeline; and how to attach a Ray cluster with a single line of code.

The real-life sales analysis example shows how these pieces fit together into a complete pipeline. Try extending it: add a .with_column("discount", df["unit_price"] * 0.9) column, compute regional contribution percentages with a join, or replace the CSV source with a Parquet glob over S3. Each addition slots naturally into the lazy query plan.

For deeper coverage of Daft’s query planner, Iceberg and Delta Lake connectors, and GPU-accelerated image processing, see the official Daft documentation.

How To Use Granian to Serve Python Web Apps

How To Use Granian to Serve Python Web Apps

Intermediate

You deploy a FastAPI app to production with Uvicorn, load tests it, and watch the request latency climb past 200ms under moderate traffic. You add workers, tune the event loop, and it still struggles. The bottleneck is not your Python code — it is the server itself. Granian is a Rust-written HTTP server for Python ASGI, WSGI, and RSGI applications that outperforms Uvicorn and Gunicorn on throughput benchmarks, often by 20-40%, without requiring you to change a single line of your application code.

Granian is a drop-in replacement for Uvicorn and Gunicorn in most deployments. You install it with pip install granian, point it at your existing app object, and it handles the rest. It supports HTTP/1.1 and HTTP/2 out of the box, configures workers and threads from the command line, and works with every major Python web framework — FastAPI, Starlette, Flask, Django, and more.

In this article we will cover what Granian is and why it is faster than alternatives, how to serve both ASGI and WSGI apps, how to configure workers, threads, and HTTP/2, how to set up TLS for HTTPS in development, and how to build a production-ready configuration. By the end you will have a complete working setup you can deploy today.

Serving a FastAPI App: Quick Example

If you have a FastAPI app ready, you can switch to Granian in under 60 seconds. Here is a minimal working example from installation to running server:

# install.sh
pip install granian fastapi

Then create a simple FastAPI application:

# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def root():
    return {"message": "Hello from Granian"}

@app.get("/health")
def health():
    return {"status": "ok"}

Start the server with Granian from the terminal:

# terminal
granian --interface asgi main:app --host 0.0.0.0 --port 8000

Output:

[INFO] Starting granian
[INFO] Listening at: http://0.0.0.0:8000
[INFO] Spawning worker-1 with pid: 12345
[INFO] ASGI app loaded from module: main

The --interface asgi flag tells Granian to use the ASGI protocol, which is what FastAPI and Starlette expect. The main:app argument follows the same module:attribute pattern Uvicorn uses, so you can swap servers without modifying your code. The next sections go deeper into configuration, WSGI apps, and production tuning.

What Is Granian and Why Is It Faster?

Granian is an open-source HTTP server written in Rust, created by Giovanni Barillari. While Uvicorn and Gunicorn are written in Python and rely on C extensions for performance, Granian’s core networking layer is pure Rust — which means it handles I/O, connection management, and request parsing at native speed with no GIL contention at the transport layer.

The performance difference becomes meaningful under high concurrency. In a Python web server, every request that hits the Python interpreter is subject to the GIL. Granian’s architecture minimizes the time Python code spends waiting on the transport layer, giving your application more headroom to do actual work. In independent benchmarks on JSON responses and database-bound routes, Granian consistently delivers 20-40% higher requests-per-second than Uvicorn with equivalent worker counts.

FeatureGranianUvicornGunicorn + uvicorn workers
LanguageRust (core)PythonPython
ASGI supportYesYesYes (via worker class)
WSGI supportYesNoYes
RSGI supportYes (native)NoNo
HTTP/2Built-inNoNo
Multi-workerYes (–workers)Via GunicornYes
Threading modelConfigurableSingle-threadedSingle-threaded per worker
Zero-downtime reloadPlannedVia GunicornYes

Granian supports three interface modes: asgi for modern async frameworks, wsgi for traditional synchronous frameworks, and rsgi — Granian’s own async interface that is even faster than ASGI because it avoids some protocol overhead. Most teams will use asgi or wsgi since those match existing frameworks.

Serving ASGI Apps (FastAPI, Starlette)

ASGI is the interface used by FastAPI, Starlette, Django Channels, and Litestar. To serve an ASGI app, pass --interface asgi to Granian. Here is a more realistic FastAPI application with a route that simulates I/O work:

# app_asgi.py
import asyncio
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Granian ASGI Demo")

class Item(BaseModel):
    name: str
    price: float

items_db = {}

@app.post("/items/")
async def create_item(item: Item):
    items_db[item.name] = item
    # Simulate async I/O (database write, etc.)
    await asyncio.sleep(0.001)
    return {"created": item.name, "price": item.price}

@app.get("/items/{name}")
async def get_item(name: str):
    if name not in items_db:
        return {"error": "Not found"}, 404
    return items_db[name]

@app.get("/items/")
async def list_items():
    return list(items_db.values())

Serve it with Granian using two workers to take advantage of multiple CPU cores:

# terminal
granian --interface asgi app_asgi:app --host 0.0.0.0 --port 8000 --workers 2

Output:

[INFO] Starting granian
[INFO] Listening at: http://0.0.0.0:8000
[INFO] Spawning worker-1 with pid: 23001
[INFO] Spawning worker-2 with pid: 23002
[INFO] ASGI app loaded from module: app_asgi

Each worker is a separate OS process with its own Rust event loop. Under load, the two workers handle requests in parallel without GIL contention between them. You can test the endpoint from a second terminal with curl:

# terminal (second window)
curl -X POST http://localhost:8000/items/ \
  -H "Content-Type: application/json" \
  -d '{"name": "keyboard", "price": 129.99}'

Output:

{"created":"keyboard","price":129.99}
API Alice standing at a terminal console with all green status indicators lit up
Two workers, no GIL fights. The requests just… go.

Serving WSGI Apps (Flask, Django)

If your application uses Flask, Django, or any other WSGI framework, Granian handles those too with --interface wsgi. This is a significant advantage over Uvicorn, which is ASGI-only and cannot serve WSGI apps at all. Here is a Flask app that serves a small product catalog:

# app_wsgi.py
from flask import Flask, jsonify, request

app = Flask(__name__)

catalog = {
    "python-book": {"title": "Learning Python", "price": 49.99},
    "rust-book":   {"title": "Programming Rust", "price": 54.99},
}

@app.route("/products/")
def list_products():
    return jsonify(list(catalog.values()))

@app.route("/products/")
def get_product(slug):
    product = catalog.get(slug)
    if not product:
        return jsonify({"error": "Not found"}), 404
    return jsonify(product)

@app.route("/products/", methods=["POST"])
def add_product():
    data = request.get_json()
    if not data or "slug" not in data:
        return jsonify({"error": "slug required"}), 400
    catalog[data["slug"]] = {"title": data.get("title", ""), "price": data.get("price", 0)}
    return jsonify({"added": data["slug"]}), 201

Start it with Granian in WSGI mode:

# terminal
granian --interface wsgi app_wsgi:app --host 0.0.0.0 --port 8001 --workers 4

Output:

[INFO] Starting granian
[INFO] Listening at: http://0.0.0.0:8001
[INFO] Spawning worker-1 with pid: 24001
[INFO] Spawning worker-2 with pid: 24002
[INFO] Spawning worker-3 with pid: 24003
[INFO] Spawning worker-4 with pid: 24004
[INFO] WSGI app loaded from module: app_wsgi

WSGI mode is synchronous — Granian handles concurrency by running multiple workers rather than using async I/O within a single process. For CPU-bound or synchronous-database Flask apps, this is the correct model. You get the speed of Rust I/O with the familiar Flask programming model.

Configuration Options: Workers, Threads, and HTTP/2

Granian’s full configuration is available both as CLI flags and as a Python API. The CLI approach is the most common and works well with Docker and systemd. Here are the key options you will reach for in production:

# terminal -- production-style command
granian \
  --interface asgi \
  --host 0.0.0.0 \
  --port 8000 \
  --workers 4 \
  --threads 2 \
  --http auto \
  --log-level info \
  main:app

What each flag does:

FlagDefaultWhen to change it
--workers N1Set to number of CPU cores for CPU-bound workloads
--threads N1Increase for I/O-heavy apps on modern Rust async runtime
--http auto1Use auto to enable HTTP/2 when TLS is active
--log-levelinfoSet to warning in high-traffic production to reduce log overhead
--backlog N1024Increase for high-concurrency scenarios
--interfacerequiredAlways required: asgi, wsgi, or rsgi

You can also configure Granian entirely from Python code, which is useful when you want to embed server startup in a script or control it programmatically:

# serve_programmatic.py
from granian import Granian

server = Granian(
    target="main:app",
    address="0.0.0.0",
    port=8000,
    interface="asgi",
    workers=4,
    threads=2,
    log_level="info",
)

server.serve()

Running this script with python serve_programmatic.py starts the server identically to the CLI approach. The Python API is especially useful if you are building a deployment tool or a management script that starts and monitors Granian as a subprocess.

Sudo Sam at a control panel with four levers all switched to ON
Four workers. Four levers. All the way up.

Adding TLS for HTTPS in Development

Granian supports TLS (HTTPS) natively, and enabling it in development is straightforward. You need a certificate and key file — you can generate self-signed ones with openssl for local testing:

# terminal -- generate self-signed cert for local dev
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
  -days 365 -nodes -subj "/CN=localhost"

Output:

Generating a RSA private key
.............++++
writing new private key to 'key.pem'
-----

Then pass the certificate and key to Granian:

# terminal -- serve with TLS
granian --interface asgi main:app \
  --host 0.0.0.0 \
  --port 8443 \
  --http auto \
  --ssl-certificate cert.pem \
  --ssl-keyfile key.pem

Output:

[INFO] Starting granian
[INFO] Listening at: https://0.0.0.0:8443
[INFO] HTTP/2 enabled (TLS active)
[INFO] Spawning worker-1 with pid: 25001

With --http auto and TLS active, Granian automatically enables HTTP/2, which allows multiple requests to be multiplexed over a single connection. This is a meaningful throughput gain for browsers and API clients that make several concurrent requests to the same server. In production you would typically terminate TLS at a reverse proxy like Nginx and connect Granian over plain HTTP on an internal port — but having native TLS available makes local HTTPS development and container-to-container encrypted traffic trivially easy to set up.

Cache Katie holding a golden padlock and a green shield with a checkmark
HTTP/2 over TLS. Your API clients share one connection and everyone’s happy.

Real-Life Example: Production-Ready Granian Setup

Let us put everything together into a realistic scenario: a FastAPI service behind Granian with structured logging, environment-based configuration, and a health check endpoint — the kind of setup you would actually push to a container. This uses python-dotenv to load config from a .env file and uvicorn‘s logging format for compatibility with existing log aggregation pipelines.

# service.py
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
import time

app = FastAPI(
    title="Order Service",
    version="1.0.0",
)

# In-memory store (replace with a real DB in production)
orders: dict = {}
_start_time = time.time()

class Order(BaseModel):
    customer: str
    product: str
    quantity: int
    price_each: float

class OrderResponse(BaseModel):
    order_id: str
    customer: str
    product: str
    total: float

@app.get("/health")
def health():
    uptime_seconds = round(time.time() - _start_time, 1)
    return {"status": "ok", "uptime_seconds": uptime_seconds, "orders": len(orders)}

@app.post("/orders/", response_model=OrderResponse)
async def create_order(order: Order):
    order_id = f"ORD-{len(orders) + 1:04d}"
    total = round(order.quantity * order.price_each, 2)
    orders[order_id] = {"customer": order.customer, "product": order.product, "total": total}
    return OrderResponse(order_id=order_id, customer=order.customer,
                         product=order.product, total=total)

@app.get("/orders/{order_id}", response_model=OrderResponse)
async def get_order(order_id: str):
    order = orders.get(order_id)
    if not order:
        raise HTTPException(status_code=404, detail=f"Order {order_id} not found")
    return OrderResponse(order_id=order_id, **order)

The startup script reads configuration from environment variables so the same code works in development and production:

# run.py
import os
from granian import Granian

HOST     = os.getenv("HOST", "0.0.0.0")
PORT     = int(os.getenv("PORT", "8000"))
WORKERS  = int(os.getenv("WORKERS", "2"))
LOG_LVL  = os.getenv("LOG_LEVEL", "info")

server = Granian(
    target="service:app",
    address=HOST,
    port=PORT,
    interface="asgi",
    workers=WORKERS,
    log_level=LOG_LVL,
)

if __name__ == "__main__":
    print(f"[startup] Granian on {HOST}:{PORT} with {WORKERS} workers")
    server.serve()

Start it and test the health and order endpoints:

# terminal
WORKERS=4 python run.py
# second terminal -- test endpoints
curl http://localhost:8000/health

curl -X POST http://localhost:8000/orders/ \
  -H "Content-Type: application/json" \
  -d '{"customer":"Alice","product":"mechanical keyboard","quantity":1,"price_each":129.99}'

curl http://localhost:8000/orders/ORD-0001

Output:

{"status":"ok","uptime_seconds":3.1,"orders":0}
{"order_id":"ORD-0001","customer":"Alice","product":"mechanical keyboard","total":129.99}
{"order_id":"ORD-0001","customer":"Alice","product":"mechanical keyboard","total":129.99}

This pattern — environment-driven config, structured startup script, health endpoint — gives you a container-ready service. A Dockerfile would set the WORKERS, PORT, and LOG_LEVEL environment variables and run python run.py as the entrypoint. Swap the in-memory orders dict for a real database connection and you have a production service.

Pyro Pete standing triumphantly in front of four glowing server towers
Four workers. One entrypoint. Zero Uvicorn.

Frequently Asked Questions

Is Granian a full replacement for Uvicorn?

For most ASGI applications, yes. Granian supports the same module:app startup convention, the same --workers flag, and the same ASGI interface that FastAPI and Starlette expect. The main gaps compared to Uvicorn are the absence of a stable zero-downtime reload feature and slightly less ecosystem documentation. If your deployment relies on Gunicorn managing Uvicorn worker processes, you will need to test Granian’s multi-worker mode as a direct replacement — in most cases it works identically.

Can I use Granian with a full Django project?

Yes. Django’s WSGI entrypoint is at yourproject.wsgi:application by default. Run granian --interface wsgi yourproject.wsgi:application and Granian serves it. If you are using Django Channels or Django with an async setup, use --interface asgi and point it at yourproject.asgi:application. Granian handles both interfaces in the same binary, which makes it easier to manage than running separate Gunicorn and Uvicorn processes for different projects.

When should I increase workers vs threads?

Workers are separate OS processes — use more workers to scale CPU-bound work and to run on multiple CPU cores. Each worker has its own Python interpreter and GIL, so two workers can run Python code truly in parallel. Threads in Granian refer to async worker threads within the Rust runtime and are useful for I/O-heavy apps that benefit from more concurrency per process. A good starting point is --workers N_CPU --threads 1 and benchmark from there.

Does HTTP/2 require a reverse proxy?

No — Granian can terminate HTTP/2 connections directly with --http auto and TLS certificates. In production, you may still want Nginx or a load balancer in front of Granian for TLS certificate management (Let’s Encrypt renewal, for example) and to handle static file serving. But if you are running a containerized service in Kubernetes or behind a cloud load balancer that handles TLS termination, you can run Granian in plain HTTP/2 cleartext mode on an internal port.

Does Granian support hot reload for development?

Granian does not have a built-in file watcher for hot reload in the same way Uvicorn’s --reload flag works. For development you can use watchfiles — run watchfiles "granian --interface asgi main:app" src/ to restart Granian when files in your src/ directory change. This gives you an equivalent development experience. Hot reload in production is on Granian’s roadmap but not yet stable as of mid-2026.

How do I run Granian in Docker?

Use a standard Python base image, install your dependencies, and set CMD to the Granian CLI command. A minimal Dockerfile looks like: FROM python:3.12-slim, RUN pip install granian fastapi, COPY . ., CMD ["granian", "--interface", "asgi", "main:app", "--host", "0.0.0.0", "--port", "8000"]. Set --workers via an environment variable rather than hardcoding it in the image so the same image works on machines with different CPU counts.

Conclusion

Granian is a practical, production-ready upgrade for any Python web application that currently uses Uvicorn or Gunicorn. The Rust-powered transport layer delivers measurably higher throughput without requiring changes to your application code — you replace the server command and your app keeps running. We covered how to serve ASGI apps like FastAPI and Starlette, how to serve WSGI apps like Flask and Django, how to configure workers and threads for your CPU topology, how to enable TLS and HTTP/2, and how to build an environment-driven startup script ready for containerization.

The real-life example showed how a production service looks with health checks, structured configuration, and the programmatic Python API. You can extend it by adding database connection pooling, Prometheus metrics, or structured JSON logging — all of which integrate cleanly with a Granian-served FastAPI app. Consider running a quick wrk or locust benchmark against your current Uvicorn setup and a Granian equivalent to see the throughput difference on your specific workload.

For full documentation on all CLI flags and advanced features, see the official Granian repository on GitHub. The project’s benchmarks page also has current performance comparisons against Uvicorn, Hypercorn, and other Python ASGI servers.

How To Use WebSockets in FastAPI for Real-Time Apps

How To Use WebSockets in FastAPI for Real-Time Apps

Intermediate

You build an analytics dashboard that shows live traffic numbers. Your users refresh the page every 30 seconds to see if anything changed. That is not a dashboard — that is polling with extra steps. Real-time means the server pushes updates the moment they happen, not when the client gets around to asking. WebSockets make this possible by keeping a persistent, bidirectional connection open between the browser and the server. One connection, two directions, no polling, no wasted requests.

FastAPI has first-class WebSocket support built in — no plugins, no third-party libraries required beyond what you already have installed. You define a WebSocket endpoint exactly like an HTTP route, accept the connection with await websocket.accept(), and start reading and sending messages. FastAPI handles the protocol upgrade from HTTP to WebSocket transparently. For broadcasting to multiple clients — the pattern behind every chat app and live feed — you manage a list of active connections and iterate over it. The pattern is a dozen lines of Python.

This article covers everything you need to build real-time features with FastAPI WebSockets: the basic connection pattern, sending and receiving text and JSON, broadcasting to multiple clients, managing disconnections cleanly, using query parameters for authentication, and a complete real-life chat application. By the end you will have a working multi-room chat server and the patterns to build any real-time feature on top of them.

FastAPI WebSockets: Quick Example

Here is the minimal working WebSocket endpoint in FastAPI. It accepts a connection, reads messages from the client in a loop, and echoes each one back. Run it with uvicorn quick_ws:app --reload and connect from a browser console or a WebSocket client like Hoppscotch.

# quick_ws.py
from fastapi import FastAPI, WebSocket

app = FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_text()
        await websocket.send_text(f"Echo: {data}")

Browser console test (open any tab on the same machine):

const ws = new WebSocket("ws://localhost:8000/ws");
ws.onmessage = e => console.log(e.data);
ws.send("hello");
// Output in console:
// Echo: hello
ws.send("fastapi websockets");
// Echo: fastapi websockets

The @app.websocket decorator declares a WebSocket route the same way @app.get declares an HTTP route. The await websocket.accept() call performs the WebSocket handshake — you must call this before reading or sending anything. The while True loop keeps the connection alive, reading one message at a time with receive_text() and responding with send_text(). When the client disconnects, receive_text() raises a WebSocketDisconnect exception which we will handle in the next sections.

What Are WebSockets and When Should You Use Them?

WebSockets are a communication protocol that upgrades an HTTP connection into a persistent, full-duplex channel. “Full-duplex” means both sides can send messages at any time, independently of each other — unlike HTTP where the client always speaks first and the server can only respond. The connection stays open until one side explicitly closes it or the network drops.

Think of HTTP like a walkie-talkie: one side talks, the other listens, then you swap. WebSockets are like a phone call: both sides can talk whenever they want, and the line stays open until someone hangs up. For anything that needs the server to push data without waiting for the client to ask, WebSockets are the right tool.

Use the comparison table below to decide which transport fits your use case:

Use CaseHTTP / PollingServer-Sent EventsWebSockets
Chat applicationPoor — high latencyReceive onlyBest fit
Live dashboard (server pushes)Acceptable with short pollGood fitGood fit
Collaborative editingPoorReceive onlyRequired
Real-time notificationsWastefulGood fitGood fit
File upload / REST APIBest fitNot applicableOverkill
One-time data fetchBest fitNot applicableOverkill

WebSockets add connection management complexity that plain HTTP does not have. Use them only when you genuinely need bidirectional or server-push communication. For simple server-push scenarios (like a progress bar or news feed), Server-Sent Events are simpler. For everything truly bidirectional — chat, collaborative tools, multiplayer games — WebSockets are the right choice.

Cartoon character managing glowing fiber-optic cables between server racks in a neon-lit network hub
One connection. Two directions. Zero polling overhead.

Handling Disconnections Cleanly

When a client closes the browser tab, loses network access, or explicitly calls ws.close(), FastAPI raises a WebSocketDisconnect exception inside your endpoint. If you do not catch it, the exception propagates up and leaves server-side resources in an unknown state. Always wrap the message loop in a try/except block.

# ws_disconnect.py
from fastapi import FastAPI, WebSocket
from starlette.websockets import WebSocketDisconnect

app = FastAPI()

@app.websocket("/ws/safe")
async def safe_websocket(websocket: WebSocket):
    await websocket.accept()
    client_id = id(websocket)
    print(f"Client {client_id} connected")
    try:
        while True:
            data = await websocket.receive_text()
            print(f"Received from {client_id}: {data}")
            await websocket.send_text(f"Got: {data}")
    except WebSocketDisconnect:
        print(f"Client {client_id} disconnected cleanly")
    except Exception as exc:
        print(f"Client {client_id} dropped with error: {exc}")

Server output when a client connects and then closes the tab:

Client 140234567890 connected
Received from 140234567890: hello
Client 140234567890 disconnected cleanly

The WebSocketDisconnect exception comes from Starlette (which FastAPI is built on) and carries a code attribute with the WebSocket close code and a reason string. Normal browser tab closes produce code 1001 (“going away”). Explicit ws.close() calls typically use code 1000 (“normal closure”). Logging the code is useful for diagnosing unexpected disconnections in production.

Sending and Receiving JSON Messages

Text messages work fine for simple cases, but real applications exchange structured data. FastAPI’s WebSocket object has receive_json() and send_json() methods that handle serialization automatically. They use json.loads() and json.dumps() internally, so you work with Python dicts directly.

# ws_json.py
from fastapi import FastAPI, WebSocket
from starlette.websockets import WebSocketDisconnect
import datetime

app = FastAPI()

@app.websocket("/ws/json")
async def json_websocket(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            payload = await websocket.receive_json()
            # Validate that the expected fields are present
            action = payload.get("action", "unknown")
            value = payload.get("value", "")

            response = {
                "action": action,
                "echo": value,
                "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
                "status": "ok",
            }
            await websocket.send_json(response)
    except WebSocketDisconnect:
        pass

Browser console test:

const ws = new WebSocket("ws://localhost:8000/ws/json");
ws.onmessage = e => console.log(JSON.parse(e.data));

ws.send(JSON.stringify({ action: "ping", value: "hello" }));
// Output:
// { action: "ping", echo: "hello", timestamp: "2026-08-07T06:00:00.000Z", status: "ok" }

ws.send(JSON.stringify({ action: "update", value: "42" }));
// { action: "update", echo: "42", timestamp: "2026-08-07T06:00:00.123Z", status: "ok" }

Use .get("key", default) when reading from incoming JSON payloads. Clients can send malformed or incomplete messages, and a missing key without a default raises a KeyError that disconnects them unexpectedly. Defensive reads keep the connection stable even when client code has bugs. If the payload is so malformed that you cannot proceed, send an error response back via send_json() and keep the connection open — let the client decide whether to close it.

Cartoon character sorting glowing JSON data packets at a futuristic sorting facility
receive_json() handles the parsing. You handle the logic. Everyone wins.

Broadcasting to Multiple Clients

Sending a message to one connected client is straightforward. Sending it to all connected clients — the core pattern behind every chat app, live feed, and collaborative tool — requires managing a list of active connections. The connection manager pattern below is the standard approach: a class holds a set of active WebSocket objects, and provides connect, disconnect, and broadcast methods.

# ws_broadcast.py
from fastapi import FastAPI, WebSocket
from starlette.websockets import WebSocketDisconnect
from typing import List

app = FastAPI()

class ConnectionManager:
    def __init__(self):
        self.active: List[WebSocket] = []

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active.append(websocket)

    def disconnect(self, websocket: WebSocket):
        self.active.remove(websocket)

    async def broadcast(self, message: str):
        for ws in list(self.active):  # copy to avoid mutation during iteration
            try:
                await ws.send_text(message)
            except Exception:
                pass  # disconnected mid-broadcast; will be removed on next receive

manager = ConnectionManager()

@app.websocket("/ws/broadcast/{client_id}")
async def broadcast_endpoint(websocket: WebSocket, client_id: str):
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_text()
            await manager.broadcast(f"[{client_id}]: {data}")
    except WebSocketDisconnect:
        manager.disconnect(websocket)
        await manager.broadcast(f"[{client_id}] left the room")

Expected behavior with two browser tabs both connected to /ws/broadcast/alice and /ws/broadcast/bob:

-- Tab 1 (alice) sends "hello" --
Both tabs receive: [alice]: hello

-- Tab 2 (bob) sends "hey there" --
Both tabs receive: [bob]: hey there

-- Tab 1 closes --
Tab 2 receives: [alice] left the room

The list(self.active) copy inside broadcast is important. If a client disconnects mid-broadcast, iterating over the original list while modifying it raises a RuntimeError. The copy lets the loop finish safely. The silent except inside the broadcast loop eats the send failure — the client will be properly removed on their own disconnect path. This is intentional: a failed send to one client should never prevent the message from reaching the others.

Using Query Parameters for Authentication

WebSocket connections cannot carry custom HTTP headers the way REST API calls can — the browser’s WebSocket API only exposes the URL. The standard workaround is to pass a token as a query parameter. FastAPI’s query parameter injection works the same way in WebSocket endpoints as in HTTP routes.

# ws_auth.py
from fastapi import FastAPI, WebSocket, Query, HTTPException
from starlette.websockets import WebSocketDisconnect

app = FastAPI()

# Simulated token store -- in production, validate against your auth system
VALID_TOKENS = {"secret-token-alice": "alice", "secret-token-bob": "bob"}

async def get_user_from_token(token: str) -> str:
    """Return the username for a valid token, or raise an error."""
    user = VALID_TOKENS.get(token)
    if not user:
        raise ValueError(f"Invalid token: {token}")
    return user

@app.websocket("/ws/secure")
async def secure_websocket(
    websocket: WebSocket,
    token: str = Query(..., description="Auth token"),
):
    try:
        user = await get_user_from_token(token)
    except ValueError:
        # Reject the connection before accepting it
        await websocket.close(code=4001, reason="Unauthorized")
        return

    await websocket.accept()
    try:
        while True:
            data = await websocket.receive_text()
            await websocket.send_text(f"[{user}] {data}")
    except WebSocketDisconnect:
        pass

Valid connection URL:

ws://localhost:8000/ws/secure?token=secret-token-alice
# Server accepts, messages are prefixed with [alice]

ws://localhost:8000/ws/secure?token=wrong-token
# Server sends close frame with code 4001 before the connection opens

Closing with code=4001 (a custom application-level code in the 4000-4999 range) signals to the client that authentication failed, so it can display a meaningful error rather than a generic “connection closed” message. Note that you call await websocket.close() BEFORE await websocket.accept() to reject during the handshake. This is one of the few times in a WebSocket endpoint where you do not call accept() first.

Cartoon character at a glowing holographic gate checking credentials with green and red approval lights
4001: your token is bad and you should feel bad.

WebSockets vs Regular HTTP Endpoints in FastAPI

FastAPI WebSocket endpoints look similar to HTTP endpoints but behave differently in ways that matter for architecture decisions. Understanding the differences prevents common mistakes like expecting dependency injection to work identically, or trying to return a value from a WebSocket route.

FeatureHTTP Route (@app.get, @app.post)WebSocket Route (@app.websocket)
Connection lifecycleOne request, one response, connection closedPersistent until explicitly closed
Return valueReturn value becomes the response bodyNo return value — send via websocket.send_*()
Dependencies (Depends)Fully supportedSupported, but without response object
Path parametersYes — @app.get("/{id}")Yes — @app.websocket("/{id}")
Query parametersYes — injected automaticallyYes — same injection mechanism
Error responsesRaise HTTPExceptionClose with code — no HTTP status codes
MiddlewareAppliedApplied during upgrade phase only
Background tasksYes via BackgroundTasksUse asyncio.create_task() instead

The most important practical difference: HTTP exceptions do not work inside WebSocket connections. Once the connection is accepted, you cannot raise HTTPException and expect the client to see a 4xx status code — the protocol is already WebSocket, not HTTP. Close the connection with an appropriate code instead.

Real-Life Example: Multi-Room Chat Server

The example below builds a complete multi-room chat server. Clients connect to /ws/chat/{room_name} with a username query parameter. Messages broadcast to everyone in the same room. The server tracks which rooms are active and how many clients are in each one.

# chat_server.py
from fastapi import FastAPI, WebSocket, Query
from starlette.websockets import WebSocketDisconnect
from collections import defaultdict
from typing import Dict, List
import datetime

app = FastAPI()

class RoomManager:
    def __init__(self):
        # room_name -> list of (websocket, username) tuples
        self.rooms: Dict[str, List[tuple]] = defaultdict(list)

    async def join(self, room: str, websocket: WebSocket, username: str):
        await websocket.accept()
        self.rooms[room].append((websocket, username))
        await self._broadcast(room, f"*** {username} joined {room} ***", sender=None)

    async def leave(self, room: str, websocket: WebSocket, username: str):
        self.rooms[room] = [
            (ws, u) for ws, u in self.rooms[room] if ws is not websocket
        ]
        if not self.rooms[room]:
            del self.rooms[room]
        else:
            await self._broadcast(room, f"*** {username} left {room} ***", sender=None)

    async def send_message(self, room: str, sender: str, text: str):
        ts = datetime.datetime.utcnow().strftime("%H:%M:%S")
        message = f"[{ts}] {sender}: {text}"
        await self._broadcast(room, message, sender=sender)

    async def _broadcast(self, room: str, message: str, sender: str | None):
        dead = []
        for ws, username in list(self.rooms.get(room, [])):
            try:
                await ws.send_text(message)
            except Exception:
                dead.append((ws, username))
        # Clean up dead connections
        for item in dead:
            self.rooms[room] = [x for x in self.rooms[room] if x != item]

    def room_count(self, room: str) -> int:
        return len(self.rooms.get(room, []))

manager = RoomManager()

@app.websocket("/ws/chat/{room}")
async def chat_endpoint(
    websocket: WebSocket,
    room: str,
    username: str = Query(..., min_length=1, max_length=32),
):
    await manager.join(room, websocket, username)
    try:
        while True:
            text = await websocket.receive_text()
            if text.strip():
                await manager.send_message(room, username, text.strip())
    except WebSocketDisconnect:
        await manager.leave(room, websocket, username)

@app.get("/rooms")
async def list_rooms():
    return {
        "rooms": {
            room: manager.room_count(room)
            for room in manager.rooms
        }
    }

Example session — two clients in “python” room, one in “general”:

-- alice connects to /ws/chat/python?username=alice --
alice sees: *** alice joined python ***

-- bob connects to /ws/chat/python?username=bob --
alice sees: *** bob joined python ***
bob sees:   *** bob joined python ***

-- alice sends "anyone using FastAPI?" --
alice sees: [06:01:23] alice: anyone using FastAPI?
bob sees:   [06:01:23] alice: anyone using FastAPI?

-- GET /rooms --
{ "rooms": { "python": 2 } }

-- alice disconnects --
bob sees: *** alice left python ***

The RoomManager class separates connection state management from the route handler. The route handler stays thin — it joins, loops on messages, and leaves on disconnect. All the broadcasting and cleanup logic lives in the manager. To extend this into a production-ready chat server, add a Redis pub/sub layer (so the manager works across multiple Uvicorn worker processes), persist message history to a database, and add rate limiting to the send_message path to prevent spam.

Cartoon character routing glowing message orbs between server rooms at a lit-up switchboard in a cyberpunk operations center
room_name -> list of (websocket, username). The whole chat architecture in one dict.

Frequently Asked Questions

How do I scale WebSockets across multiple workers?

FastAPI with Uvicorn runs in a single process by default. When you run multiple workers with --workers 4, each worker has its own ConnectionManager instance in memory — a client connected to worker 1 will not receive broadcasts sent by a client connected to worker 2. The standard solution is to use Redis pub/sub as a shared message bus: workers subscribe to a Redis channel and forward messages to their local connections. Libraries like broadcaster (pip install broadcaster[redis]) wrap this pattern cleanly and integrate with FastAPI.

How do I detect a dead WebSocket connection?

Networks drop silently. A client can disappear without sending a close frame, and the server will not notice until it tries to send something and gets an error. The standard fix is to implement a ping/pong heartbeat: send a WebSocket ping frame every 30 seconds with await websocket.send_text("__ping__") (or use Starlette’s lower-level websocket.send({"type": "websocket.ping"})) and remove the connection if you do not receive a response within a timeout window. Most browser WebSocket clients automatically respond to protocol-level ping frames, so the lower-level approach is more reliable than application-level pings.

Why can’t I send auth headers in a WebSocket connection?

The browser’s WebSocket API does not expose a headers argument — you can only set the URL and optionally a subprotocol. This is a deliberate design choice in the WebSocket specification. The practical workaround is to pass the token as a query parameter (?token=...) or as a cookie. If you use cookies, the browser sends them automatically with the WebSocket upgrade request, and you can read them from FastAPI’s Request object passed alongside the WebSocket parameter. For short-lived tokens, some applications do a two-step handshake: exchange credentials via HTTPS first, receive a one-time WebSocket token, then connect with that token.

Can I use FastAPI Depends() inside a WebSocket endpoint?

Yes, with one caveat: dependencies that rely on the HTTP response object (like those that set cookies or headers on the response) will not work, because WebSocket endpoints do not have an HTTP response after the handshake. Dependencies that only read data — database sessions, current user from a token, config values — work normally. Inject them the same way you would in an HTTP route: def secure_ws(websocket: WebSocket, user: User = Depends(get_current_user)). FastAPI resolves them before calling the endpoint function.

Is it safe to call send_text() from multiple coroutines simultaneously?

No. A WebSocket object is not concurrency-safe for writes — sending from multiple concurrent coroutines on the same connection can cause corrupted frames or errors. If you need to push messages from a background task and the main receive loop simultaneously, use an asyncio.Queue: the background task puts messages in the queue, and a separate coroutine drains the queue and calls send_text(). This serializes all writes through a single coroutine. Alternatively, use asyncio.Lock to guard the send call explicitly.

What WebSocket close codes should I use in my app?

Codes 1000-1015 are defined by the WebSocket specification and used by the protocol itself. Codes 4000-4999 are reserved for application use — use them to signal application-specific error conditions. Common conventions: 4000 for generic application error, 4001 for authentication failure, 4003 for authorization failure (authenticated but not allowed), 4008 for rate limit exceeded. Clients can inspect the close code in the onclose event (event.code) to display an appropriate message rather than a generic “disconnected” notice.

Conclusion

FastAPI’s WebSocket support gives you real-time bidirectional communication with the same clean, type-annotated API you use for HTTP routes. The core patterns — accept, receive, send, disconnect handling — are straightforward. Broadcasting to multiple clients requires a ConnectionManager class that maintains a list of active connections. Authentication passes through query parameters or cookies since WebSocket connections cannot carry custom headers. The multi-room chat example shows how all of these patterns combine into a working application.

To take the chat server further, add Redis pub/sub via the broadcaster library for multi-process broadcasting, add a heartbeat loop to detect dead connections, and add a message history endpoint so clients can replay recent messages when they reconnect. Each of those extensions plugs into the RoomManager class without touching the route handler.

For official documentation and deeper reference, see FastAPI WebSockets docs and MDN WebSocket API reference.

How To Use DuckDB and Polars Together for Fast Analytics

How To Use DuckDB and Polars Together for Fast Analytics

Intermediate

You are loading a 10-million-row sales dataset. Polars rips through the CSV in under 2 seconds, and you have a clean DataFrame in memory. Then the real work starts: you need a window function ranking each salesperson within their region, a multi-table join against a product catalogue, and a filtered aggregation with a CTE. Polars can do all of that — but the syntax is verbose, the learning curve is steep, and you already know how to express complex analytics in SQL. What if you could just… write SQL on top of your Polars DataFrame?

That is exactly what DuckDB gives you. DuckDB is an in-process SQL OLAP database that natively understands Apache Arrow — the same memory format Polars uses internally. When you point DuckDB at a Polars DataFrame, no data gets copied. DuckDB reads directly from the Arrow buffers already in memory, executes your SQL, and hands back a result you can convert back to Polars with a single method call. Both libraries are available on pip and work without any external database server.

This article covers the full integration: querying Polars DataFrames with DuckDB SQL, converting results back to Polars, using DuckDB to load files directly into Polars, combining lazy Polars scans with DuckDB aggregations, and a real-world analytics project that uses both libraries to process e-commerce event data. By the end you will know exactly when to reach for each tool and how to wire them together in a single pipeline.

DuckDB and Polars Together: Quick Example

Before diving into details, here is the core pattern in its simplest form. You create a Polars DataFrame, register it in a DuckDB connection, run SQL against it, and convert the result back to Polars.

# quick_example.py
import polars as pl
import duckdb

# Create a Polars DataFrame
df = pl.DataFrame({
    "product": ["Widget", "Gadget", "Widget", "Gadget", "Gizmo"],
    "region":  ["North", "North", "South", "South", "North"],
    "revenue": [1200, 850, 990, 1450, 620],
})

# Query it directly with DuckDB SQL -- no copy, no conversion
result = duckdb.sql("""
    SELECT
        region,
        SUM(revenue)  AS total_revenue,
        COUNT(*)      AS sales_count
    FROM df
    GROUP BY region
    ORDER BY total_revenue DESC
""").pl()  # .pl() converts DuckDB result to Polars DataFrame

print(result)

Output:

shape: (3, 3)
+---------+---------------+-------------+
| region  | total_revenue | sales_count |
| ---     | ---           | ---         |
| str     | i64           | i64         |
+=========+===============+=============+
| South   | 2440          | 2           |
| North   | 2670          | 3           |
| ...     | ...           | ...         |
+---------+---------------+-------------+

The key call is duckdb.sql("... FROM df ..."). DuckDB finds the variable df in the calling Python scope automatically — no explicit registration needed when you use duckdb.sql(). The .pl() at the end materializes the result as a Polars DataFrame. That entire round-trip — SQL execution plus result conversion — is typically faster than the equivalent Polars expression on datasets above a few hundred thousand rows, because DuckDB uses vectorized SQL execution tuned for aggregations and joins.

The sections below unpack each part of this integration: installation, how the zero-copy Arrow bridge actually works, complex SQL patterns that shine over Polars expressions, loading files, and a complete real-world pipeline.

What Is DuckDB and Why Pair It with Polars?

DuckDB is an embedded analytical database — “embedded” meaning it runs inside your Python process, not as a separate server. There is no daemon to start, no connection string pointing to localhost:5432, no Docker container. You import duckdb and it is ready. Under the hood, DuckDB uses a columnar execution engine optimized for OLAP queries: aggregations, window functions, multi-table joins over large datasets.

Polars is a DataFrame library written in Rust, built on the Apache Arrow columnar memory format. It is dramatically faster than Pandas for most workloads because it avoids Python’s per-row overhead and uses SIMD instructions for bulk operations. Polars is excellent at row filtering, column expressions, type casting, lazy evaluation, and parallel scans of Parquet files.

So why combine them? Each has a natural home:

TaskBetter ToolReason
Read Parquet / CSV into memoryPolarsFaster scan, lazy evaluation, schema inference
Row filtering on simple conditionsPolarsConcise expressions, compiled Rust speed
Complex GROUP BY + HAVINGDuckDBSQL is expressive; DuckDB optimizer handles it well
Window functions (RANK, LAG, LEAD)DuckDBSQL window syntax beats Polars .over() for complexity
Multi-table JOINsDuckDBSQL JOIN syntax is cleaner than Polars .join() chains
Lazy scan of 50GB datasetPolars (LazyFrame)Only reads needed columns/rows from disk
Export cleaned data to ParquetPolarsFast write, good compression options

The zero-copy bridge is the technical reason this pairing is fast. Polars stores data in Arrow buffers. DuckDB can read Arrow buffers without duplicating them in memory. Passing a 5GB Polars DataFrame to DuckDB costs essentially zero bytes of additional RAM — DuckDB just borrows the existing memory. The same works in reverse: .pl() on a DuckDB result converts the Arrow-format result to a Polars DataFrame without an intermediate Python object allocation.

Installation

Both packages install from PyPI with no system dependencies.

# install_deps.sh
pip install polars duckdb

Output:

Successfully installed polars-1.x.x duckdb-1.x.x

Verify the install by checking that DuckDB can see a Polars DataFrame:

# verify_install.py
import polars as pl
import duckdb

df = pl.DataFrame({"x": [1, 2, 3]})
result = duckdb.sql("SELECT SUM(x) AS total FROM df").pl()
print(result)
# shape: (1, 1)
# total: 6
print("DuckDB version:", duckdb.__version__)
print("Polars version:", pl.__version__)

If you see the sum and version strings, both libraries are working and the Arrow bridge is operational.

Debug Dee standing in server tunnel where two data pipeline conduits merge seamlessly
Zero-copy means DuckDB borrows your RAM. It will give it back. Probably.

Querying Polars DataFrames with DuckDB SQL

Automatic Scope Lookup

When you call duckdb.sql(), DuckDB inspects the local Python scope for any variable name used in the FROM clause. If df is in scope, FROM df works automatically. This is the simplest pattern and covers most use cases.

# scope_lookup.py
import polars as pl
import duckdb

orders = pl.DataFrame({
    "order_id":   [101, 102, 103, 104, 105],
    "customer":   ["Alice", "Bob", "Alice", "Carol", "Bob"],
    "amount":     [250.0, 89.0, 410.0, 125.0, 320.0],
    "category":   ["Electronics", "Books", "Electronics", "Books", "Electronics"],
})

# DuckDB finds 'orders' in scope automatically
top_customers = duckdb.sql("""
    SELECT
        customer,
        COUNT(*)        AS order_count,
        SUM(amount)     AS total_spent,
        AVG(amount)     AS avg_order
    FROM orders
    GROUP BY customer
    HAVING SUM(amount) > 200
    ORDER BY total_spent DESC
""").pl()

print(top_customers)

Output:

shape: (2, 4)
+----------+-------------+-------------+------------+
| customer | order_count | total_spent | avg_order  |
| str      | i64         | f64         | f64        |
+==========+=============+=============+============+
| Alice    | 2           | 660.0       | 330.0      |
| Bob      | 2           | 409.0       | 204.5      |
+----------+-------------+-------------+------------+

The HAVING clause is a good example of where SQL syntax shines — filtering after aggregation is two words in SQL but requires a second .filter() call in Polars. Carol is excluded because her single $125 order doesn’t meet the threshold.

Explicit Connection for Multiple DataFrames

When your query spans multiple DataFrames, use an explicit duckdb.connect() and register each one. This makes the relationship between SQL table names and Python variables explicit and avoids scope confusion in larger scripts.

# multi_table.py
import polars as pl
import duckdb

products = pl.DataFrame({
    "product_id":  [1, 2, 3, 4],
    "name":        ["Widget", "Gadget", "Gizmo", "Doohickey"],
    "category":    ["Hardware", "Software", "Hardware", "Software"],
    "unit_price":  [49.99, 99.99, 19.99, 149.99],
})

sales = pl.DataFrame({
    "sale_id":    [201, 202, 203, 204, 205, 206],
    "product_id": [1, 2, 1, 3, 4, 2],
    "quantity":   [3, 1, 5, 10, 2, 4],
})

# Register both DataFrames under explicit table names
con = duckdb.connect()
con.register("products", products)
con.register("sales", sales)

revenue_by_category = con.execute("""
    SELECT
        p.category,
        SUM(s.quantity * p.unit_price) AS total_revenue,
        SUM(s.quantity)                AS units_sold
    FROM sales s
    JOIN products p ON s.product_id = p.product_id
    GROUP BY p.category
    ORDER BY total_revenue DESC
""").pl()

print(revenue_by_category)

Output:

shape: (2, 3)
+----------+---------------+------------+
| category | total_revenue | units_sold |
| str      | f64           | i64        |
+==========+===============+============+
| Software | 498.95        | 5          |
| Hardware | 349.83        | 18         |
+----------+---------------+------------+

Even though Hardware sold far more units (18 vs 5), Software wins on revenue because of higher unit prices. A multi-table join in pure Polars requires .join() plus column renaming to resolve name conflicts — the SQL version above is considerably easier to read and maintain.

Window Functions: Where SQL Destroys Polars Syntax

Window functions — RANK(), ROW_NUMBER(), LAG(), LEAD(), running totals — are where SQL earns its keep. The Polars equivalent using .over() gets unwieldy fast. Here is a ranking query that would take 15+ lines of Polars expressions:

# window_functions.py
import polars as pl
import duckdb

employees = pl.DataFrame({
    "name":       ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"],
    "department": ["Eng", "Eng", "Sales", "Sales", "Eng", "Sales"],
    "salary":     [95000, 88000, 72000, 81000, 102000, 69000],
})

ranked = duckdb.sql("""
    SELECT
        name,
        department,
        salary,
        RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
        salary - AVG(salary) OVER (PARTITION BY department)       AS vs_dept_avg,
        SUM(salary) OVER (PARTITION BY department ORDER BY salary DESC
                          ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
                                                                  AS running_total
    FROM employees
    ORDER BY department, dept_rank
""").pl()

print(ranked)

Output:

shape: (6, 6)
+-------+------------+--------+-----------+-------------+---------------+
| name  | department | salary | dept_rank | vs_dept_avg | running_total |
| str   | str        | i64    | u64       | f64         | i64           |
+=======+============+========+===========+=============+===============+
| Eve   | Eng        | 102000 | 1         | 16333.33    | 102000        |
| Alice | Eng        | 95000  | 2         | 9333.33     | 197000        |
| Bob   | Eng        | 88000  | 3         | 2333.33     | 285000        |
| Dave  | Sales      | 81000  | 1         | 7333.33     | 81000         |
| Carol | Sales      | 72000  | 2         | -1666.67    | 153000        |
| Frank | Sales      | 69000  | 3         | -4333.33    | 222000        |
+-------+------------+--------+-----------+-------------+---------------+

Three different window computations — rank, deviation from mean, cumulative sum — expressed in a single SQL query. Polars could do this with three separate .with_columns() calls using .over(), but the SQL version communicates intent more clearly: anyone familiar with SQL analytics can read this at a glance.

Loop Larry balancing between three parallel conveyor belts representing window function partitions
PARTITION BY runs each window on its own lane. No tangling. Unlike this guy.

Loading Files Directly with DuckDB into Polars

DuckDB can read Parquet, CSV, and JSON files directly in SQL without loading them through Python first. The result can be immediately converted to a Polars DataFrame. This is useful when you want DuckDB’s SQL filtering to happen before materializing data in memory — especially for large files where you only need a subset.

# load_files.py
import polars as pl
import duckdb
import tempfile
import os

# Create a sample Parquet file to demonstrate with
sample = pl.DataFrame({
    "date":     ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"],
    "product":  ["Widget", "Gadget", "Widget", "Gizmo", "Widget"],
    "sales":    [1200, 850, 990, 430, 1100],
    "region":   ["East", "West", "East", "West", "North"],
})

# Write to a temp Parquet file
parquet_path = "/tmp/sample_sales.parquet"
sample.write_parquet(parquet_path)

# Query the Parquet file directly -- DuckDB reads it without loading into Python first
result = duckdb.sql(f"""
    SELECT
        product,
        SUM(sales)  AS total_sales,
        COUNT(*)    AS num_days
    FROM read_parquet('{parquet_path}')
    WHERE sales > 500
    GROUP BY product
    ORDER BY total_sales DESC
""").pl()

print(result)

Output:

shape: (2, 3)
+---------+-------------+----------+
| product | total_sales | num_days |
| str     | i64         | i64      |
+=========+=============+==========+
| Widget  | 3290        | 3        |
| Gadget  | 850         | 1        |
+---------+-------------+----------+

The WHERE sales > 500 filter runs inside DuckDB’s Parquet reader — rows below the threshold never enter Python memory. For a 50-million-row Parquet file where you only need rows matching certain conditions, this is significantly more memory-efficient than pl.read_parquet() followed by .filter(). DuckDB also supports read_csv() and read_json() with the same pattern.

Converting Between DuckDB and Polars

DuckDB’s result object has several conversion methods. Knowing which one to use avoids unnecessary copies:

# conversions.py
import polars as pl
import duckdb

df = pl.DataFrame({
    "city":       ["Austin", "Denver", "Portland", "Austin", "Denver"],
    "visits":     [340, 210, 175, 290, 310],
    "converted":  [42, 31, 28, 38, 45],
})

rel = duckdb.sql("""
    SELECT city, SUM(visits) AS total_visits, SUM(converted) AS total_converted
    FROM df
    GROUP BY city
""")

# .pl()     -- Polars DataFrame (most common; returns when you want to keep working in Polars)
as_polars = rel.pl()
print("Polars:")
print(as_polars)

# .df()     -- Pandas DataFrame (only if you need Pandas downstream)
# as_pandas = rel.df()

# .fetchall() -- list of tuples (lightweight; no DataFrame overhead)
rel2 = duckdb.sql("SELECT city, SUM(visits) AS total_visits FROM df GROUP BY city")
as_tuples = rel2.fetchall()
print("\nTuples:", as_tuples)

# .arrow()  -- PyArrow Table (if you need Arrow format for another Arrow-aware library)
rel3 = duckdb.sql("SELECT city, SUM(visits) AS total_visits FROM df GROUP BY city")
as_arrow = rel3.arrow()
print("\nArrow schema:", as_arrow.schema)

Output:

Polars:
shape: (3, 3)
+----------+--------------+------------------+
| city     | total_visits | total_converted  |
| str      | i64          | i64              |
+==========+==============+==================+
| Austin   | 630          | 80               |
| Denver   | 520          | 76               |
| Portland | 175          | 28               |
+----------+--------------+------------------+

Tuples: [('Austin', 630), ('Denver', 520), ('Portland', 175)]

Arrow schema: city: string
total_visits: int64

Use .pl() when you want to continue with Polars operations after the SQL step. Use .fetchall() when you just need the values and do not need a DataFrame at all — it skips the overhead of creating a Polars object. Use .arrow() if you are passing the result to another Arrow-aware library such as PyArrow datasets or Lance.

Cache Katie at a sorting machine with three output chutes for different result formats
.pl(), .fetchall(), .arrow() — pick the exit ramp that matches your next stop.

Combining Lazy Polars Scans with DuckDB

Polars LazyFrame lets you describe transformations on data without executing them immediately. You can collect a LazyFrame into an eager DataFrame and then pass it to DuckDB — or you can collect the portion you need and let DuckDB handle the aggregation. This is the most memory-efficient pattern for large files.

# lazy_polars_duckdb.py
import polars as pl
import duckdb

# Simulate multiple Parquet shards (in practice these would be files on disk)
shard1 = pl.DataFrame({
    "user_id":  [1, 2, 3, 4, 5],
    "event":    ["view", "click", "view", "purchase", "click"],
    "page":     ["home", "product", "home", "cart", "category"],
    "duration": [12, 45, 8, 120, 30],
})

shard2 = pl.DataFrame({
    "user_id":  [6, 7, 8, 1, 3],
    "event":    ["view", "purchase", "click", "purchase", "view"],
    "page":     ["product", "cart", "home", "cart", "category"],
    "duration": [55, 200, 15, 180, 25],
})

# Use lazy evaluation to concatenate shards and filter before collecting
lazy_events = pl.concat([shard1.lazy(), shard2.lazy()]).filter(
    pl.col("duration") > 10
)

# Collect only when needed for DuckDB
events = lazy_events.collect()

# DuckDB handles the complex aggregation
summary = duckdb.sql("""
    SELECT
        event,
        COUNT(*)        AS event_count,
        AVG(duration)   AS avg_duration,
        COUNT(DISTINCT user_id) AS unique_users
    FROM events
    GROUP BY event
    ORDER BY event_count DESC
""").pl()

print(summary)

Output:

shape: (3, 4)
+-----------+-------------+--------------+--------------+
| event     | event_count | avg_duration | unique_users |
| str       | i64         | f64          | i64          |
+===========+=============+==============+==============+
| view      | 4           | 25.0         | 4            |
| click     | 3           | 30.0         | 3            |
| purchase  | 3           | 166.67       | 3            |
+-----------+-------------+--------------+--------------+

The lazy filter (duration > 10) runs during the .collect() call and eliminates short-duration events before DuckDB ever sees them. The resulting DataFrame is smaller, the DuckDB query is faster, and peak memory is lower than loading all shards and filtering afterward.

CTEs and Subqueries for Multi-Step Analytics

Common Table Expressions (CTEs) let you break a complex query into named steps that read like documentation. DuckDB handles them efficiently — each CTE is computed once and referenced by name in subsequent CTEs or the final SELECT.

# cte_analytics.py
import polars as pl
import duckdb

transactions = pl.DataFrame({
    "txn_id":      list(range(1, 11)),
    "customer_id": [101, 102, 101, 103, 102, 101, 104, 103, 102, 104],
    "amount":      [120, 45, 330, 90, 210, 75, 450, 60, 180, 300],
    "month":       ["Jan", "Jan", "Jan", "Jan", "Feb", "Feb", "Feb", "Feb", "Feb", "Mar"],
})

result = duckdb.sql("""
    WITH customer_totals AS (
        -- Step 1: total spend per customer
        SELECT customer_id, SUM(amount) AS total_spend
        FROM transactions
        GROUP BY customer_id
    ),
    spend_percentile AS (
        -- Step 2: rank customers by spend
        SELECT
            customer_id,
            total_spend,
            NTILE(2) OVER (ORDER BY total_spend DESC) AS spend_tier
        FROM customer_totals
    )
    -- Step 3: label tiers and return
    SELECT
        customer_id,
        total_spend,
        CASE spend_tier WHEN 1 THEN 'High Value' ELSE 'Standard' END AS segment
    FROM spend_percentile
    ORDER BY total_spend DESC
""").pl()

print(result)

Output:

shape: (4, 3)
+-------------+-------------+------------+
| customer_id | total_spend | segment    |
| i64         | i64         | str        |
+=============+=============+============+
| 104         | 750         | High Value |
| 101         | 525         | High Value |
| 102         | 435         | Standard   |
| 103         | 150         | Standard   |
+-------------+-------------+------------+

The CTE chain reads like a recipe: total up each customer, rank them, then label the tiers. Reconstructing this logic in Polars would require three separate .group_by() and .join() steps with intermediate variable names. For analysts who think in SQL, the CTE version communicates the intent much more directly.

Sudo Sam beside a chain of glowing interconnected puzzle pieces representing CTE pipeline steps
WITH clause: because anonymous subquery nesting is how you lose friends.

Real-Life Example: E-Commerce Event Analytics Pipeline

This project simulates a complete analytics pipeline for an e-commerce site. We use Polars to clean and filter raw event data, then DuckDB to run the multi-step analytics that produce the business metrics a stakeholder would actually want.

# ecommerce_analytics.py
import polars as pl
import duckdb

# ---- Step 1: Raw event data (simulate 1 day of site events) ----
raw_events = pl.DataFrame({
    "session_id": [f"s{i}" for i in range(1, 11)],
    "user_id":    [201, 202, 201, 203, 204, 202, 205, 203, 201, 204],
    "event":      ["view", "view", "add_to_cart", "view", "purchase",
                   "add_to_cart", "view", "purchase", "purchase", "view"],
    "product_id": [10, 11, 10, 12, 11, 12, 10, 12, 10, 13],
    "price":      [99.0, 149.0, 99.0, 49.0, 149.0, 49.0, 99.0, 49.0, 99.0, 79.0],
    "duration_s": [45, 12, 80, 30, 210, 95, 8, 180, 300, 20],
})

product_meta = pl.DataFrame({
    "product_id":  [10, 11, 12, 13],
    "name":        ["Widget Pro", "Gadget Max", "Mini Gizmo", "Doohickey"],
    "category":    ["Hardware", "Software", "Hardware", "Accessories"],
})

# ---- Step 2: Polars cleaning -- filter bot-like sessions (<10s) ----
clean_events = raw_events.filter(pl.col("duration_s") >= 10)
print(f"After bot filter: {clean_events.height} events (was {raw_events.height})")

# ---- Step 3: Register both DataFrames ----
con = duckdb.connect()
con.register("events", clean_events)
con.register("products", product_meta)

# ---- Step 4: Purchase funnel analysis ----
funnel = con.execute("""
    SELECT
        event,
        COUNT(DISTINCT session_id) AS sessions,
        ROUND(
            100.0 * COUNT(DISTINCT session_id) /
            MAX(COUNT(DISTINCT session_id)) OVER (), 1
        ) AS pct_of_views
    FROM events
    GROUP BY event
    ORDER BY sessions DESC
""").pl()
print("\n--- Funnel ---")
print(funnel)

# ---- Step 5: Revenue by product with conversion rate ----
revenue = con.execute("""
    WITH views AS (
        SELECT product_id, COUNT(*) AS view_count
        FROM events WHERE event = 'view'
        GROUP BY product_id
    ),
    purchases AS (
        SELECT product_id, COUNT(*) AS purchase_count, SUM(price) AS revenue
        FROM events WHERE event = 'purchase'
        GROUP BY product_id
    )
    SELECT
        p.name,
        p.category,
        COALESCE(v.view_count, 0)     AS views,
        COALESCE(pu.purchase_count, 0) AS purchases,
        COALESCE(pu.revenue, 0)        AS revenue,
        ROUND(100.0 * COALESCE(pu.purchase_count, 0) /
              NULLIF(COALESCE(v.view_count, 0), 0), 1) AS conversion_pct
    FROM products p
    LEFT JOIN views    v  ON p.product_id = v.product_id
    LEFT JOIN purchases pu ON p.product_id = pu.product_id
    ORDER BY revenue DESC
""").pl()
print("\n--- Revenue by Product ---")
print(revenue)

# ---- Step 6: Return result to Polars for export ----
revenue.write_csv("/tmp/daily_product_report.csv")
print("\nReport written to /tmp/daily_product_report.csv")

Output:

After bot filter: 9 events (was 10)

--- Funnel ---
shape: (3, 3)
+-------------+----------+--------------+
| event       | sessions | pct_of_views |
| str         | i64      | f64          |
+=============+==========+==============+
| view        | 5        | 100.0        |
| purchase    | 3        | 60.0         |
| add_to_cart | 2        | 40.0         |
+-------------+----------+--------------+

--- Revenue by Product ---
shape: (4, 6)
+------------+-------------+-------+-----------+---------+----------------+
| name       | category    | views | purchases | revenue | conversion_pct |
| str        | str         | i64   | i64       | f64     | f64            |
+============+=============+=======+===========+=========+================+
| Widget Pro | Hardware    | 3     | 2         | 198.0   | 66.7           |
| Gadget Max | Software    | 1     | 1         | 149.0   | 100.0          |
| Mini Gizmo | Hardware    | 1     | 1         | 49.0    | 100.0          |
| Doohickey  | Accessories | 1     | 0         | 0.0     | 0.0            |
+-------------+-------------+-------+-----------+---------+----------------+

Report written to /tmp/daily_product_report.csv

The pipeline uses each tool for what it is best at: Polars for fast, expressive row filtering (the bot session removal), and DuckDB for the complex multi-CTE JOIN query that would require many lines of Polars chaining. The final result is handed back to Polars for .write_csv(). You can extend this by reading from real Parquet shards on disk and adding a second daily run to compare day-over-day conversion trends.

API Alice operating a retro-futuristic control panel with two glowing dials merging data streams
Know which dial to turn. Cleaning goes left. Aggregation goes right.

Frequently Asked Questions

Do I still need Pandas if I use Polars and DuckDB?

For most analytical workloads, no. Polars handles data loading, filtering, and transformation faster than Pandas, and DuckDB handles complex SQL analytics. The main reason to keep Pandas is compatibility with libraries that require it — for example, some scikit-learn utilities, certain plotting libraries, or legacy code that calls .to_csv() on a Pandas DataFrame. You can convert a Polars DataFrame to Pandas on demand with df.to_pandas() when needed, so the two ecosystems coexist without conflict.

Is the zero-copy claim really true for large DataFrames?

Yes, with one caveat. When you pass a Polars DataFrame to DuckDB via duckdb.sql("... FROM df ..."), DuckDB reads the underlying Arrow buffers directly. The DataFrame’s data is not duplicated. However, DuckDB may allocate memory for intermediate results during query execution — for example, a large sort or a hash join builds internal hash tables. The input data itself is zero-copy; the computation overhead is proportional to the complexity of the query, not the input size. For a simple SELECT SUM(x) FROM df, memory usage barely moves.

Can I use DuckDB with a persistent database file instead of in-memory?

Yes. duckdb.connect("my_analytics.db") creates a persistent DuckDB database file on disk. You can use CREATE TABLE sales AS SELECT * FROM polars_df to copy a Polars DataFrame into the persistent DuckDB table, then query it across sessions without loading from Polars again. This is useful when the source data is expensive to reload but the DuckDB database fits on disk. The in-memory mode (default when you call duckdb.connect() with no arguments) is faster for single-session analytics pipelines.

Polars has its own SQL interface — why not use that instead?

Polars 0.20+ includes pl.SQLContext, which lets you run SQL directly on Polars DataFrames. For straightforward queries it works well. DuckDB has advantages in two areas: a more complete SQL dialect (full window function support, CTEs, recursive queries) and a mature query optimizer that was built specifically for analytical workloads. If you are already using DuckDB elsewhere in your stack, or if you need advanced SQL features, DuckDB is the stronger choice. For simpler queries on Polars-only pipelines, pl.SQLContext avoids adding an extra dependency.

When is Polars faster than DuckDB, and when is DuckDB faster?

For operations that map cleanly to Polars expressions — type casting, column arithmetic, simple row filters, string manipulation, pivots — Polars is typically faster because it avoids SQL parsing overhead and its Rust implementation is highly tuned for DataFrame operations. DuckDB tends to win on complex aggregations, multi-table joins, and window functions where its query optimizer can reorder operations, use predicate pushdown, and parallelize aggregation phases. For most production pipelines, the bottleneck is neither library but I/O — so reading from Parquet with either tool’s native reader and then doing computation is fast in both cases.

What is the difference between registering a DataFrame and using automatic scope lookup?

Automatic scope lookup (duckdb.sql("SELECT ... FROM df")) searches the calling Python frame for a variable named df. It is convenient for quick one-off queries. Explicit registration (con.register("my_table", df)) binds the DataFrame to a specific name on a specific connection object, which is safer in functions, threads, or Jupyter notebooks where scope can be ambiguous. For production code, explicit registration is preferred because it makes dependencies visible and avoids subtle bugs when the same variable name exists in nested scopes.

Conclusion

DuckDB and Polars together form one of the most capable in-memory analytics stacks available in Python today. Polars handles fast ingestion, cleaning, and expression-based transformations using its Rust-backed DataFrame engine. DuckDB handles SQL analytics — aggregations, window functions, multi-table joins, CTEs — without any data copying because both libraries speak Apache Arrow natively. The two tools are installed from pip, require no server, and hand data back and forth with a single method call.

The real-world e-commerce pipeline in this article is a starting point. You can extend it by reading from real Parquet shards on disk using read_parquet() inside DuckDB, by adding a persistent DuckDB database file to accumulate historical data, or by parallelizing the Polars ingestion step across multiple files using pl.scan_parquet("data/*.parquet"). The integration is straightforward enough that you can adopt it incrementally — replace one complex Polars expression with a DuckDB SQL query, verify the output matches, then keep going.

For further reading, see the DuckDB Python Polars integration guide and the Polars documentation. Both are actively maintained and updated as the libraries evolve.

How To Use Ibis for Portable DataFrames Across SQL Backends

How To Use Ibis for Portable DataFrames Across SQL Backends

Intermediate

You write a data pipeline in pandas, it works perfectly on your laptop with a CSV file, and then production arrives. The data is in BigQuery. You spend two days rewriting everything in the BigQuery Python client. Six months later, the company switches to Snowflake. You rewrite it again. Then someone asks you to run the same logic against a local DuckDB file for a quick analysis. At this point you have three versions of the same query, and they all drift apart every time business logic changes.

Ibis solves this by giving you one DataFrame API that compiles to SQL for more than 20 backends — DuckDB, BigQuery, Snowflake, Spark, PostgreSQL, SQLite, Trino, and more. You write the expression once, connect it to whichever backend you have available, and execute. Switching backends is a one-line change. Install it with pip install ibis-framework[duckdb] (swap duckdb for any backend you need, or use pip install ibis-framework[all] for every supported backend).

This tutorial covers everything you need to use Ibis effectively: connecting to backends, building portable expressions, filtering and aggregating data, joining tables, and understanding deferred vs eager execution. The final section builds a real multi-backend analysis pipeline. By the end you will know how to write data transformations that run anywhere without modification.

Ibis for Portable DataFrames: Quick Example

Here is a complete, runnable Ibis example that loads data into DuckDB, runs a query, and prints the result — all without writing a single line of SQL:

# quick_ibis.py
import ibis
import ibis.selectors as s

# Connect to an in-memory DuckDB instance (no file needed)
con = ibis.duckdb.connect()

# Create a table from a Python dict (simulates loading real data)
con.create_table(
    "sales",
    ibis.memtable({
        "region": ["North", "South", "North", "East", "South", "East"],
        "product": ["Widget", "Gadget", "Gadget", "Widget", "Widget", "Gadget"],
        "revenue": [1200, 850, 960, 1100, 730, 980],
        "units": [12, 8, 10, 11, 7, 9],
    }),
    overwrite=True,
)

# Build an expression -- nothing executes yet
sales = con.table("sales")

result = (
    sales
    .group_by(["region", "product"])
    .aggregate(
        total_revenue=sales.revenue.sum(),
        total_units=sales.units.sum(),
        avg_revenue=sales.revenue.mean().round(2),
    )
    .order_by(ibis.desc("total_revenue"))
)

# Execute and convert to pandas
df = result.to_pandas()
print(df)
  region  product  total_revenue  total_units  avg_revenue
0  North   Gadget           960           10       960.00
1  North   Widget          1200           12      1200.00
2   East   Gadget           980            9       980.00
3   East   Widget          1100           11      1100.00
4  South   Gadget           850            8       850.00
5  South   Widget           730            7       730.00

Three things worth noticing: First, ibis.duckdb.connect() gives you an in-memory backend with no setup — perfect for development and testing. Second, ibis.memtable() wraps a plain Python dict as an Ibis table, so you can start experimenting without any files or databases. Third, the expression you build with .group_by(), .aggregate(), and .order_by() does nothing until you call .to_pandas(). This is lazy evaluation — Ibis builds an expression tree, compiles it to SQL behind the scenes, and only hits the backend when you ask for results.

The sections below show how to connect to real backends, build more complex expressions, and switch between backends with a single line change.

What Is Ibis and Why Use It?

Ibis is a Python DataFrame library that treats SQL backends as interchangeable execution engines. Instead of wrapping a single engine (like pandas wraps NumPy, or PySpark wraps Spark), Ibis defines a portable expression language that compiles down to the SQL dialect of whichever backend you connect to. The same .filter(), .group_by(), and .join() calls produce DuckDB SQL, BigQuery Standard SQL, Spark SQL, or Postgres SQL depending on your connection.

This matters most in three situations: when you develop locally but deploy to a cloud warehouse; when you need to run the same analysis across different databases without copy-pasting SQL; and when you want to use Python ergonomics (method chaining, tab-completion, Python type checking) while still pushing computation to the database engine rather than pulling data into memory.

LibraryExecutionBackendsAPI styleBest for
pandasIn-memory (NumPy)1EagerSmall data, local exploration
PySparkSpark cluster1Lazy (Spark)Hadoop ecosystem, batch ETL
SQLAlchemyAny SQL DB20+ORM / Core SQLApp databases, ORM models
PolarsIn-memory (Rust)1Lazy + EagerFast local analytics, CSV/Parquet
IbisDelegated to backend20+Lazy DataFramePortable analytics, multi-backend

The key tradeoff with Ibis is that not every backend supports every expression. An operation that DuckDB handles natively might not be available in SQLite, so Ibis raises a TranslationError rather than silently producing wrong results. This is a feature, not a bug — it forces you to write expressions that are genuinely portable.

Cache Katie sitting on a SQL database cylinder with backend logos orbiting, cartoon style
One API. Twenty-two backends. The query runs wherever your data lives.

Connecting to Backends

Every Ibis workflow starts with a connection. The connection object is your interface to a specific backend — it handles authentication, compiles your expressions to the right SQL dialect, and executes queries. Ibis provides shortcut functions for every supported backend:

# connections.py
import ibis

# DuckDB -- in-memory (no file, no setup)
con_mem = ibis.duckdb.connect()

# DuckDB -- persistent file (data survives across sessions)
con_file = ibis.duckdb.connect("my_analytics.ddb")

# SQLite -- works with any .sqlite or .db file
con_sqlite = ibis.sqlite.connect("app_data.db")

# PostgreSQL -- requires psycopg2 installed
# con_pg = ibis.postgres.connect(
#     host="localhost", port=5432,
#     database="mydb", user="analyst", password="secret"
# )

# BigQuery -- requires google-cloud-bigquery installed
# con_bq = ibis.bigquery.connect(project="my-gcp-project", dataset="analytics")

# Print what tables exist in a connection
print("Tables in memory DuckDB:", con_mem.list_tables())
print("Tables in file DuckDB:", con_file.list_tables())
Tables in memory DuckDB: []
Tables in file DuckDB: []

For cloud backends like BigQuery or Snowflake, authentication uses the same mechanism as the underlying client library — service account keys, gcloud auth application-default login, or environment variables. Ibis does not add its own auth layer, which means your existing credentials work out of the box. For development, DuckDB in-memory mode is the fastest feedback loop: zero setup, runs entirely in your process, and supports almost the entire Ibis expression set.

Loading and Querying Data

Ibis gives you several ways to get data into the system depending on where your data lives. The most common patterns are loading from Parquet or CSV files (DuckDB handles these natively), reading from an existing database table, or wrapping a Python dict or pandas DataFrame using ibis.memtable():

# loading_data.py
import ibis
import tempfile, os, json

con = ibis.duckdb.connect()

# Pattern 1: memtable from a Python dict (great for tests and examples)
orders = ibis.memtable({
    "order_id": [1001, 1002, 1003, 1004, 1005],
    "customer": ["Alice", "Bob", "Alice", "Carol", "Bob"],
    "amount": [250.0, 89.99, 410.50, 130.0, 75.25],
    "status": ["shipped", "pending", "shipped", "cancelled", "shipped"],
})

# Pattern 2: create a real DuckDB table for repeated use
con.create_table("orders", orders, overwrite=True)

# Pattern 3: read back from the connection as a Table expression
t = con.table("orders")
print("Schema:", t.schema())
print("Row count:", t.count().to_pandas())

# Pattern 4: read a Parquet file directly (DuckDB backend)
# parquet_table = con.read_parquet("data/sales_2025.parquet")

# Preview the first 3 rows
print(t.limit(3).to_pandas())
Schema: ibis.Schema {
  order_id  int64
  customer  string
  amount    float64
  status    string
}
Row count: 5

   order_id customer  amount    status
0      1001    Alice  250.00   shipped
1      1002      Bob   89.99   pending
2      1003    Alice  410.50   shipped

ibis.memtable() is the Swiss Army knife of Ibis development — you can wrap a pandas DataFrame, a list of dicts, or a raw dict of columns, and Ibis will handle the type inference. Notice that t.count() is also a lazy expression; you call .to_pandas() to pull the scalar result out. Ibis is consistently lazy by default — nothing touches the database until you request results.

Sudo Sam connecting backends at a giant switchboard, cartoon style
One connection. Swap the backend, keep the query. That’s the whole pitch.

Filtering, Selecting, and Transforming

Ibis expressions are built by chaining methods on a Table object. Filtering uses .filter(), column selection uses .select() or .drop(), and new columns are added with .mutate(). All of these return new expression objects — the original table is never modified, and nothing executes until you call .to_pandas(), .to_pyarrow(), or .execute():

# filtering.py
import ibis

con = ibis.duckdb.connect()

orders = ibis.memtable({
    "order_id": [1001, 1002, 1003, 1004, 1005],
    "customer": ["Alice", "Bob", "Alice", "Carol", "Bob"],
    "amount": [250.0, 89.99, 410.50, 130.0, 75.25],
    "status": ["shipped", "pending", "shipped", "cancelled", "shipped"],
})

t = orders  # memtable is already an expression

# Filter: shipped orders over $100
shipped_large = t.filter([
    t.status == "shipped",
    t.amount > 100,
])

# Add a derived column: tax estimate at 10%
with_tax = shipped_large.mutate(
    tax=shipped_large.amount * 0.10,
    total=(shipped_large.amount * 1.10).round(2),
)

# Select only the columns we care about
result = with_tax.select("order_id", "customer", "amount", "tax", "total")

print(result.to_pandas())
   order_id customer  amount    tax    total
0      1001    Alice  250.00   25.0   275.00
1      1003    Alice  410.50   41.05  451.55

.filter() accepts a list of boolean expressions — they are AND-ed together automatically, just like WHERE a AND b in SQL. .mutate() is the equivalent of SELECT *, new_col AS expr — it keeps all existing columns and appends new ones. The expression shipped_large.amount * 0.10 does not perform any Python arithmetic; it creates an Ibis expression tree that compiles to amount * 0.10 in SQL. When you call .to_pandas(), the entire chain compiles to a single SQL query and executes in one shot.

Grouping and Aggregating

Aggregation in Ibis follows the .group_by().aggregate() pattern, which maps cleanly to SQL’s GROUP BY. You can mix any combination of aggregate functions: .sum(), .mean(), .count(), .min(), .max(), .std(), and more. Conditional aggregation (the equivalent of SUM(CASE WHEN ...) in SQL) uses .sum(where=...):

# aggregation.py
import ibis

orders = ibis.memtable({
    "order_id": [1001, 1002, 1003, 1004, 1005, 1006, 1007],
    "customer": ["Alice", "Bob", "Alice", "Carol", "Bob", "Alice", "Carol"],
    "amount": [250.0, 89.99, 410.50, 130.0, 75.25, 195.0, 310.0],
    "status": ["shipped", "pending", "shipped", "cancelled", "shipped", "shipped", "shipped"],
})

# Group by customer, aggregate multiple metrics
summary = (
    orders
    .group_by("customer")
    .aggregate(
        total_orders=orders.order_id.count(),
        total_revenue=orders.amount.sum().round(2),
        avg_order=orders.amount.mean().round(2),
        shipped_revenue=orders.amount.sum(where=orders.status == "shipped").round(2),
    )
    .order_by(ibis.desc("total_revenue"))
)

print(summary.to_pandas())
  customer  total_orders  total_revenue  avg_order  shipped_revenue
0    Alice             3         855.50     285.17           855.50
1    Carol             2         440.00     220.00           310.00
2      Bob             2         165.24      82.62            75.25

The where= argument on .sum() and other aggregates is equivalent to a SQL FILTER (WHERE ...) clause or a CASE WHEN inside the aggregate — it lets you compute conditional totals without subqueries. This compiles to backend-specific SQL automatically: DuckDB uses SUM(amount) FILTER (WHERE status = 'shipped'), while backends that do not support that syntax fall back to SUM(CASE WHEN status = 'shipped' THEN amount ELSE 0 END).

API Alice juggling GROUP BY SUM COUNT data cubes through a funnel, cartoon style
group_by().aggregate() — because fourteen lines of SQL GROUP BY deserve a better life.

Joining Tables

Ibis supports all SQL join types: inner, left, right, outer, and semi/anti joins. The .join() method takes a second table and a join condition (or a list of column name strings for equi-joins). After a join, use .select() to pick the columns you want, since both tables’ columns are available and some names may collide:

# joins.py
import ibis

orders = ibis.memtable({
    "order_id": [1001, 1002, 1003, 1004],
    "customer_id": [1, 2, 1, 3],
    "amount": [250.0, 89.99, 410.50, 130.0],
    "status": ["shipped", "pending", "shipped", "shipped"],
})

customers = ibis.memtable({
    "customer_id": [1, 2, 3, 4],
    "name": ["Alice", "Bob", "Carol", "Dave"],
    "tier": ["gold", "silver", "gold", "bronze"],
})

# Inner join: only orders with a matching customer
result = (
    orders
    .join(customers, orders.customer_id == customers.customer_id)
    .select(
        "order_id",
        "name",
        "tier",
        "amount",
        "status",
    )
    .filter(lambda t: t.tier == "gold")  # filter after join
    .order_by("order_id")
)

print(result.to_pandas())
   order_id   name  tier  amount   status
0      1001  Alice  gold  250.00  shipped
1      1003  Alice  gold  410.50  shipped

After a join, column references can get ambiguous if both tables share column names. Ibis uses orders.customer_id on the left and customers.customer_id on the right to disambiguate — the join condition references both explicitly. The .select() after the join picks clean output columns by name, avoiding duplicates. You can also pass how="left", how="right", or how="outer" to the .join() call; the default is an inner join.

Switching Backends

The core promise of Ibis is that your expressions are backend-agnostic. Once you have an expression, switching backends is a matter of changing the connection. The expression itself — the chain of .filter(), .group_by(), .join() calls — does not change at all. This pattern is most useful in testing: develop and test against fast in-memory DuckDB, then run the same expression against the production Snowflake warehouse without modifying the expression code:

# switching_backends.py
import ibis

# Define the expression independently of any backend
def build_summary_expr(orders_table):
    """Returns the aggregation expression -- no backend reference."""
    return (
        orders_table
        .filter(orders_table.status == "shipped")
        .group_by("customer")
        .aggregate(
            order_count=orders_table.order_id.count(),
            total_revenue=orders_table.amount.sum().round(2),
        )
        .order_by(ibis.desc("total_revenue"))
    )

SAMPLE_DATA = {
    "order_id": [1001, 1002, 1003, 1004, 1005],
    "customer": ["Alice", "Bob", "Alice", "Carol", "Bob"],
    "amount": [250.0, 89.99, 410.50, 130.0, 75.25],
    "status": ["shipped", "pending", "shipped", "cancelled", "shipped"],
}

# Backend A: DuckDB in-memory (development / testing)
con_dev = ibis.duckdb.connect()
orders_dev = ibis.memtable(SAMPLE_DATA)
expr_dev = build_summary_expr(orders_dev)
print("DuckDB result:")
print(expr_dev.to_pandas())

# Backend B: DuckDB file (local persistence)
con_prod = ibis.duckdb.connect("analytics.ddb")
con_prod.create_table("orders", ibis.memtable(SAMPLE_DATA), overwrite=True)
orders_prod = con_prod.table("orders")
expr_prod = build_summary_expr(orders_prod)
print("\nDuckDB file result (same expression):")
print(expr_prod.to_pandas())
DuckDB result:
  customer  order_count  total_revenue
0    Alice            2         660.50
1      Bob            1          75.25

DuckDB file result (same expression):
  customer  order_count  total_revenue
0    Alice            2         660.50
1      Bob            1          75.25

The key design pattern here is to put your transformation logic inside a function that accepts a table expression and returns an expression, without ever touching a specific connection. The connection only appears at the call site when you bind the function to a real table. This is the “expression first, connection second” pattern, and it is what makes Ibis code genuinely portable. For cloud backends, you would replace ibis.memtable(SAMPLE_DATA) with con_bq.table("dataset.orders") and the expression function stays identical.

Pyro Pete yanking three backend levers simultaneously with sparks flying, cartoon style
You wrote it once. It runs everywhere. This is the whole point.

Understanding Lazy Evaluation and to_pandas()

Ibis is lazy by default, which means every method call builds up a query plan without touching the database. This has two practical benefits: expressions compose cheaply (you can build a complex query without any I/O), and the backend optimizer sees the whole query at once rather than being forced into multiple round-trips. Understanding when execution happens lets you avoid accidentally running expensive queries in a loop:

# lazy_execution.py
import ibis

con = ibis.duckdb.connect()
orders = ibis.memtable({
    "order_id": list(range(1, 11)),
    "customer": ["Alice", "Bob"] * 5,
    "amount": [100.0 + i * 15 for i in range(10)],
    "status": ["shipped", "pending"] * 5,
})

# Build a complex expression -- zero database calls so far
shipped = orders.filter(orders.status == "shipped")
enriched = shipped.mutate(discounted=shipped.amount * 0.95)
summary = enriched.group_by("customer").aggregate(
    total=enriched.amount.sum(),
    discounted_total=enriched.discounted.sum().round(2),
)

# Print the SQL Ibis will run (useful for debugging and auditing)
print("SQL Ibis will execute:")
print(ibis.to_sql(summary))
print()

# This triggers the single database call
df = summary.to_pandas()
print("Result:")
print(df)
SQL Ibis will execute:
SELECT
  "customer",
  SUM("amount") AS "total",
  ROUND(SUM("amount" * 0.95), 2) AS "discounted_total"
FROM (
  SELECT
    "customer",
    "amount",
    "amount" * 0.95 AS "discounted"
  FROM "ibis_memtable_..."
  WHERE "status" = 'shipped'
) t0
GROUP BY "customer"

Result:
  customer  total  discounted_total
0    Alice  375.0           356.25
1      Bob  450.0           427.50

ibis.to_sql(expr) is an invaluable debugging tool — it shows you exactly what SQL your expression will produce, formatted for the connected backend. This is useful when you need to audit query performance, add database-side hints, or verify that a complex expression compiles the way you expect. The SQL is always generated fresh from the expression tree, so it always reflects the current state of your chain.

Real-Life Example: Multi-Backend Sales Analysis Pipeline

This example builds a realistic sales analysis pipeline where the transformation logic is defined once and executed against two different backends — a fast in-memory DuckDB instance for quick iteration, and a persistent DuckDB file simulating a “production” warehouse:

# sales_pipeline.py
import ibis
import pandas as pd

# -------------------------------------------------------------------
# Step 1: Define the transformation logic (backend-agnostic)
# -------------------------------------------------------------------

def compute_customer_scorecard(orders_table):
    """
    Given an orders table, return a customer scorecard expression.
    Works on any backend that supports the Ibis expressions used here.
    """
    t = orders_table
    return (
        t.group_by("customer_id")
        .aggregate(
            lifetime_orders=t.order_id.count(),
            lifetime_revenue=t.amount.sum().round(2),
            avg_order_value=t.amount.mean().round(2),
            shipped_orders=t.order_id.count(where=t.status == "shipped"),
            cancelled_orders=t.order_id.count(where=t.status == "cancelled"),
        )
        .mutate(
            # Derived column: fulfillment rate
            fulfillment_rate=lambda t: (
                (t.shipped_orders / t.lifetime_orders * 100).round(1)
            )
        )
        .order_by(ibis.desc("lifetime_revenue"))
    )

def tag_customer_tier(scorecard_expr):
    """Apply a tier label based on lifetime revenue."""
    t = scorecard_expr
    return t.mutate(
        tier=ibis.case()
        .when(t.lifetime_revenue >= 1000, "Platinum")
        .when(t.lifetime_revenue >= 500, "Gold")
        .when(t.lifetime_revenue >= 200, "Silver")
        .else_("Bronze")
        .end()
    )

# -------------------------------------------------------------------
# Step 2: Sample data (in production this would be a real table)
# -------------------------------------------------------------------

ORDERS = {
    "order_id": list(range(1, 16)),
    "customer_id": [1, 2, 3, 1, 2, 3, 4, 1, 2, 4, 3, 1, 4, 2, 3],
    "amount": [
        250.0, 89.99, 410.50, 130.0, 75.25, 310.0,
        95.0, 480.0, 210.0, 55.0, 175.0, 320.0, 440.0, 35.0, 290.0
    ],
    "status": [
        "shipped", "pending", "shipped", "shipped", "shipped", "cancelled",
        "shipped", "shipped", "shipped", "pending", "shipped", "shipped",
        "shipped", "cancelled", "shipped",
    ],
}

# -------------------------------------------------------------------
# Step 3: Run on Backend A (in-memory DuckDB -- fast dev iteration)
# -------------------------------------------------------------------

con_dev = ibis.duckdb.connect()
orders_dev = ibis.memtable(ORDERS)
scorecard = compute_customer_scorecard(orders_dev)
tiered = tag_customer_tier(scorecard)

print("=== Dev Backend (in-memory DuckDB) ===")
print(tiered.to_pandas().to_string(index=False))

# -------------------------------------------------------------------
# Step 4: Run identical logic on Backend B (file-based DuckDB)
# -------------------------------------------------------------------

con_prod = ibis.duckdb.connect("warehouse.ddb")
con_prod.create_table("orders", ibis.memtable(ORDERS), overwrite=True)
orders_prod = con_prod.table("orders")
scorecard_prod = compute_customer_scorecard(orders_prod)
tiered_prod = tag_customer_tier(scorecard_prod)

print("\n=== Prod Backend (file DuckDB) ===")
print(tiered_prod.to_pandas().to_string(index=False))

# Clean up the demo file
import os
os.remove("warehouse.ddb")
=== Dev Backend (in-memory DuckDB) ===
 customer_id  lifetime_orders  lifetime_revenue  avg_order_value  shipped_orders  cancelled_orders  fulfillment_rate      tier
           1                4            1180.0           295.00               4                 0             100.0  Platinum
           3                5            1185.5           237.10               4                 1              80.0  Platinum
           2                5             410.24            82.05               3                 1              60.0      Gold
           4                3             590.0           196.67               2                 0              66.7      Gold

=== Prod Backend (file DuckDB) ===
 customer_id  lifetime_orders  lifetime_revenue  avg_order_value  shipped_orders  cancelled_orders  fulfillment_rate      tier
           1                4            1180.0           295.00               4                 0             100.0  Platinum
           3                5            1185.5           237.10               4                 1              80.0  Platinum
           2                5             410.24            82.05               3                 1              60.0      Gold
           4                3             590.0           196.67               2                 0              66.7      Gold

The identical results from both backends confirm the portability. To run this against a real BigQuery or Snowflake warehouse, replace ibis.duckdb.connect() with ibis.bigquery.connect(project="...", dataset="...") or ibis.snowflake.connect(...), and replace ibis.memtable(ORDERS) with con.table("orders") pointing at your existing table. The compute_customer_scorecard() and tag_customer_tier() functions require zero changes.

Debug Dee giving thumbs-up in front of two matching terminal screens, cartoon style
Same result, different backend. Your migration risk just went from weeks of rewriting to one line.

Frequently Asked Questions

When should I use Ibis instead of pandas?

Use Ibis when your data lives in a database or data warehouse and you want to keep computation there instead of pulling everything into memory. If you have a 500MB CSV file and pandas handles it comfortably, you do not need Ibis. But if your data is in BigQuery, Snowflake, or a DuckDB file measured in gigabytes, Ibis lets you filter, aggregate, and join at the database layer before materializing the result with .to_pandas(). Ibis also makes sense when you need the same transformation logic to run against different databases in different environments — development, staging, and production with different backends.

What happens if I use an operation the backend does not support?

Ibis raises a com.ibis_framework.common.exceptions.OperationNotDefinedError or a backend-specific TranslationError at expression evaluation time. This is intentional — Ibis tells you that your expression cannot be compiled for that backend rather than silently computing wrong results. The practical fix is to either find the Ibis equivalent that the backend supports, or to pull the data into pandas first with a simpler query and finish the computation locally. The ibis.options.interactive = True setting makes Ibis eager (executes immediately), which can make these errors appear closer to the problem line during development.

What is ibis.options.interactive and when should I use it?

Setting ibis.options.interactive = True turns Ibis into an eager evaluator — expressions execute and display results immediately, similar to how a pandas DataFrame prints when you type it in a Jupyter notebook. This is very convenient for exploration and debugging because you do not need to call .to_pandas() constantly. Turn it off (ibis.options.interactive = False) in production pipelines, where you want full control over when queries execute and want to compose complex expressions before triggering any I/O.

Does Ibis add significant overhead compared to writing SQL directly?

The overhead from Ibis expression compilation is negligible — typically under one millisecond. The compiled SQL is then sent to the backend exactly as if you had written it by hand, so query performance is determined entirely by the backend engine and your expression logic, not by Ibis itself. In practice, Ibis often produces better SQL than hand-written queries because it consistently generates clean, optimizer-friendly SQL patterns without the human tendency to write multi-step subqueries or redundant intermediate steps.

Can I mix raw SQL with Ibis expressions?

Yes — Ibis provides ibis.expr.types.relations.Table.sql() and the con.sql() method to incorporate raw SQL into an Ibis workflow. Call con.sql("SELECT ... FROM ...") to run a raw SQL string and get back an Ibis Table expression you can then chain further Ibis operations on. This escape hatch is useful for backend-specific SQL features that Ibis does not expose through its expression API, or for migrating existing SQL queries incrementally — you can wrap the raw SQL today and gradually replace sections with portable Ibis expressions over time.

What are Ibis selectors and when do I need them?

Ibis selectors (imported as import ibis.selectors as s) let you select columns by type or pattern rather than by name. For example, s.numeric() selects all numeric columns, s.string() selects all string columns, and s.matches("revenue") selects columns whose names match a regex pattern. This is useful when you are writing generic transformations that should apply to all columns of a certain type without hardcoding column names — common in data cleaning pipelines, feature engineering, and schema-flexible ETL workflows.

Conclusion

Ibis gives you a portable DataFrame API that compiles to SQL for more than 20 backends. The key patterns to keep in your toolkit are: ibis.memtable() for quick in-memory development, .filter().mutate().group_by().aggregate() for building transformations, ibis.to_sql() for inspecting the generated SQL, and the “expression first, connection second” function pattern for genuinely backend-agnostic code. The lazy evaluation model means your expression logic composes without any I/O until you call .to_pandas(), .to_pyarrow(), or .execute().

The real-life example in this tutorial is a solid foundation for a production analytics pipeline. You can extend it by adding more derived metrics to compute_customer_scorecard(), connecting to a real Postgres or BigQuery backend instead of DuckDB, writing the result back with con.create_table("scorecard", tiered), or scheduling the pipeline to run daily and append results to a history table. The transformation functions stay identical across all of these changes.

The official Ibis documentation at ibis-project.org covers every supported backend, the full expression API reference, and backend-specific features. The Expressions guide is particularly useful once you are comfortable with the basics and want to explore window functions, user-defined functions, and the full selector API.

ty vs Pyrefly vs mypy: Which Python Type Checker in 2026

ty vs Pyrefly vs mypy: Which Python Type Checker in 2026

Intermediate

You added type hints to your Python project six months ago. The code looks cleaner, your IDE autocompletion got smarter, and then someone on your team ran mypy and came back with 847 errors. Half of them were false positives, a quarter were real bugs you had no idea existed, and another quarter were complaints about third-party libraries with incomplete stubs. If this scenario sounds familiar, you already know that the type checker you pick shapes your entire development experience — not just whether your types are correct, but how fast the check runs, how noisy the output is, and how much you have to fight the tool to get work done. In 2026 you have three serious options: the veteran mypy, Meta’s new Pyrefly, and Astral’s ty. They share the same goal — catching type errors before runtime — but they take very different approaches to getting there.

All three tools read your Python source files, analyze the type annotations, and tell you when the types do not add up. You do not need to change your runtime code at all; type checkers work purely at the static analysis level. mypy has been the standard since 2012 and ships with type stubs for hundreds of libraries. Pyrefly was open-sourced by Meta in May 2025 and powers type checking across Meta’s massive Python monorepo. ty comes from Astral — the team behind ruff and uv — and follows the same philosophy: rewrite the slow Python tooling in Rust and make it dramatically faster. All three install with a single command and work from the terminal or your IDE.

This article covers what each tool is and how it differs under the hood, how to install and run a basic check, how they handle the trickiest real-world scenarios (generics, protocols, third-party stubs, incremental mode), a speed benchmark on a real codebase, and a decision framework for picking the right tool for your project. By the end you will know exactly which type checker fits your situation — and why the answer is not always “use the newest one.”

Type Checking in Python: Quick Example

Before comparing the tools, here is the same buggy Python file checked by all three. This shows exactly what each tool reports and how their output differs on identical code.

# buggy_types.py
def greet(name: str) -> str:
    return "Hello, " + name

def add(a: int, b: int) -> int:
    return a + b

# Bug 1: passing an int where str is expected
result = greet(42)

# Bug 2: using the return value as a list
numbers = add(1, 2)
for n in numbers:
    print(n)

Running each checker against this file (after installing them) produces different output styles, but all three catch both bugs. Here is what you see:

# mypy output
buggy_types.py:9: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
buggy_types.py:13: error: "int" is not iterable  [misc]
Found 2 errors in 1 file (checked 1 source file)

# ty output
error[invalid-argument-type] buggy_types.py:9:14: Argument of type `int` cannot be assigned to parameter `name` of type `str`
error[not-iterable] buggy_types.py:13:10: Object of type `int` is not iterable
Found 2 errors.

# pyrefly output
buggy_types.py:9:16: E1 Expected `str`, got `int` [type-mismatch]
buggy_types.py:13:10: E1 Object of type `int` is not iterable [not-iterable]
Found 2 errors

All three find the same two bugs: the wrong argument type on line 9 and the non-iterable use on line 13. The differences are in how they frame the error (argument-centric vs. assignment-centric), what error codes they use, and how they format their output. Those differences matter when you have 300 errors to triage and you need to group them by type, or when you need to suppress a specific category with an inline comment.

The real divergence — speed, inference quality, stub coverage, and configuration — shows up on larger, messier codebases. We will walk through each dimension below.

What Is a Python Type Checker and How Do These Three Differ?

A Python type checker is a static analysis tool that reads your source files and verifies that the types you declared — or that it can infer — are used consistently throughout your code. Python itself never enforces type annotations at runtime (unless you explicitly write code that does). The type checker is a separate tool you run in CI or locally, similar to a linter.

All three tools follow the same PEP standards (PEP 484, PEP 526, PEP 544 and so on) for interpreting type annotations, so a valid annotation means the same thing to all three. Where they diverge is in implementation language, inference algorithm, error philosophy, and ecosystem investment.

DimensionmypytyPyrefly
Written inPythonRustRust
Created byDropbox / Jukka LehtosaloAstral (ruff team)Meta
First release201220252025 (open-sourced)
Speed vs mypyBaseline~10-100x faster~10-50x faster
Incremental modeYes (daemon)Yes (built-in)Yes (built-in)
Type stub ecosystemExcellent (typeshed)Uses typeshedCustom stubs
IDE server (LSP)Via pylsp-mypyBuilt-inBuilt-in
Error suppression# type: ignore[code]# type: ignore[code]# pyrefly: ignore
MaturityStable, battle-testedBeta / active devBeta / active dev

The key philosophical split is between the Rust newcomers (ty and Pyrefly) and mypy. The Rust tools are dramatically faster — checking a 200,000-line codebase in 1-2 seconds vs. mypy’s 60-90 seconds in daemon mode. But mypy has 13 years of edge case handling, a mature stub ecosystem, and near-universal IDE and CI integration. The “right” tool depends on your project’s size, type annotation coverage, and tolerance for occasional rough edges in newer tools.

Developer surrounded by three type checker terminals comparing speed
mypy: checking… ty: done. pyrefly: also done. mypy: still checking…

Installing All Three Type Checkers

Each tool installs from PyPI with pip. They are all standalone — you do not need to modify your source code to start checking it. Install into a virtual environment to keep your project clean:

# terminal
# Install all three in one command (or pick just the one you want)
pip install mypy ty pyrefly

# Verify installations
mypy --version
ty --version
pyrefly --version
mypy 1.10.0 (compiled: yes)
ty 0.2.0
pyrefly 0.16.0

All three tools accept a path to check as the first argument. Run them on a single file, a directory, or your entire project root. The simplest invocation for each looks like this:

# terminal
mypy src/           # check all files under src/
ty check src/       # ty uses subcommands
pyrefly check src/  # pyrefly also uses subcommands

One important note: mypy by default only checks files you pass explicitly and files they import. To check your entire project including all transitively imported files, use mypy --follow-imports=normal src/. Both ty and Pyrefly crawl the directory automatically.

mypy: The Battle-Tested Veteran

mypy was the first serious Python type checker and it set the standard that PEP 484 codified. If you have worked with type hints in Python for any length of time, you have almost certainly used mypy. Here is what makes it the safe default choice for most teams today.

Configuring mypy with pyproject.toml

mypy reads its configuration from pyproject.toml, setup.cfg, or mypy.ini. A practical baseline config for a medium-sized project:

# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = false                  # start loose, tighten over time
ignore_missing_imports = true   # don't fail on untyped third-party libs
check_untyped_defs = true       # check even functions without annotations
warn_return_any = true          # flag functions that return Any unnecessarily
warn_unused_ignores = true      # catch stale # type: ignore comments
# terminal
mypy src/ --config-file pyproject.toml
src/utils.py:14: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
src/api.py:88: error: Incompatible return value type (got "list[Any]", expected "list[str]")  [return-value]
Found 2 errors in 2 files (checked 18 source files)

The strict = true flag turns on the most aggressive checks — --disallow-any-generics, --disallow-untyped-defs, and several others. If your project has mixed typed and untyped code, start with strict = false and add checks incrementally using per-module-options:

# pyproject.toml -- per-module strictness
[[tool.mypy.overrides]]
module = "myapp.core.*"
disallow_untyped_defs = true
warn_return_any = true

[[tool.mypy.overrides]]
module = "myapp.legacy.*"
ignore_errors = true            # legacy code -- skip for now

This per-module approach is mypy’s killer feature for gradually typing a large existing codebase. You lock down the new, well-typed modules while ignoring the legacy ones until you have time to annotate them. Neither ty nor Pyrefly has an equivalent feature at this level of granularity as of mid-2026.

mypy Daemon for Fast Re-checks

mypy’s biggest weakness is startup time: it re-processes all your imports from scratch on every run. The daemon mode (dmypy) keeps a persistent process in the background that caches parsed modules and only re-checks what changed. On a 50,000-line project, cold start drops from 45 seconds to 3 seconds in daemon mode:

# terminal
# Start the daemon (runs in background)
dmypy start -- --config-file pyproject.toml src/

# Check (reuses the cached state)
dmypy check src/

# Stop the daemon when done
dmypy stop
Daemon started
Success: no issues found in 18 source files
Daemon stopped

The daemon is what makes mypy viable in large codebases. Without it, waiting 60 seconds for a type check after every save is unusable. With it, incremental checks run in 1-5 seconds even on large projects. But it still does not come close to the cold-start speed of ty or Pyrefly.

Programmer confidently surveying a wall of configuration files with green checkmarks
13 years of edge cases. –ignore-missing-imports knows what it did.

ty: Astral’s Rust-Powered Newcomer

ty is built by Astral — the same team that built ruff (the 100x-faster Python linter) and uv (the fast pip replacement). If you have used ruff, you already know Astral’s playbook: take a slow Python tool, rewrite it in Rust, make it 10-100x faster, maintain full compatibility with the existing standard, and polish the developer experience.

Running ty on a Real Project

ty’s CLI uses subcommands. The main one is ty check:

# terminal
ty check src/

# With specific Python version
ty check --python-version 3.12 src/

# Output in JSON for CI/tooling
ty check --output-format json src/ | python3 -c "
import sys, json
errors = [json.loads(line) for line in sys.stdin if line.strip()]
for e in errors[:5]:
    print(f\"{e['file']}:{e['line']} -- {e['message']}\")
print(f'Total: {len(errors)} errors')
"
src/utils.py:14 -- Item "None" of "str | None" has no attribute "upper"
src/api.py:88 -- Return type incompatible: expected "list[str]", got "list[Any]"
Total: 2 errors

The most striking difference is speed. On the same 50,000-line project where mypy’s cold start takes 45 seconds (3 seconds in daemon mode), ty checks the same code in under 1 second. This is not an edge-case benchmark — it holds across projects of 10,000 to 500,000 lines. ty parallelizes file parsing and type inference across all CPU cores automatically.

Configuring ty

ty reads from pyproject.toml under the [tool.ty] table:

# pyproject.toml
[tool.ty]
python-version = "3.12"

[tool.ty.rules]
possibly-unbound = "warn"    # warn on potentially-unbound variables
unknown-argument = "error"   # error on unexpected keyword arguments

As of mid-2026, ty’s configuration surface is smaller than mypy’s. The per-module strictness overrides that mypy offers are not yet available in ty. If your project has a large legacy section you need to exclude, you use the exclude key:

# pyproject.toml
[tool.ty]
exclude = ["src/legacy/", "tests/fixtures/"]

ty is still in beta and its configuration API is evolving. Check the official documentation for the current list of supported rules before relying on specific configuration keys in CI.

Energetic developer sprinting through server room with stopwatch showing sub-second time
Cold start. Zero cache. Under one second. Don’t @ mypy.

Pyrefly: Meta’s Large-Scale Type Checker

Pyrefly was built inside Meta to handle their Python monorepo — a codebase with millions of lines of Python across thousands of engineers. Meta open-sourced it in May 2025. The design reflects Meta’s specific constraints: massive scale, incremental checking, and IDE-first architecture. Pyrefly ships with a built-in LSP server, which means it plugs directly into VS Code and other editors without a separate language server plugin.

Running Pyrefly

# terminal
pyrefly check src/

# Run with detailed output
pyrefly check --output pretty src/

# Start the LSP server (for IDE integration)
pyrefly lsp
# pyrefly check output
src/utils.py:14:8: E1 Cannot access attribute "upper" on type "str | None" [attribute-error]
src/api.py:88:12: E1 Expected return type "list[str]", got "list[Any]" [type-mismatch]

2 errors, 0 warnings

Pyrefly’s built-in LSP server is its headline differentiator from ty. With pyrefly lsp running, your editor gets real-time type error squiggles, hover-type information, and go-to-definition without installing a separate plugin. This is particularly useful for teams adopting a new type checker: there is one binary to install, one command to start, and the editor integration just works.

Pyrefly Configuration

Pyrefly reads from pyproject.toml under the [tool.pyrefly] table. Its error suppression syntax differs from mypy’s — instead of # type: ignore[code], Pyrefly uses # pyrefly: ignore:

# pyproject.toml
[tool.pyrefly]
python-version = "3.12"
project-excludes = ["src/legacy/**", "tests/fixtures/**"]

# Enable specific checks beyond the default set
[tool.pyrefly.errors]
missing-return-type = true
# In your Python source -- suppressing a specific error
result = some_untyped_function()  # pyrefly: ignore

The different suppression comment syntax is important if you are migrating from mypy. A codebase full of # type: ignore comments will not automatically suppress Pyrefly errors — you will need to either convert them or use a compatibility flag. As of Pyrefly 0.16, the --respect-type-ignore flag makes Pyrefly honor standard # type: ignore comments during migration.

Speed Benchmark: The Numbers That Actually Matter

Speed is the headline reason to consider ty or Pyrefly over mypy. Here is a practical benchmark run on a 45,000-line Django project with 60% type annotation coverage, on an M2 MacBook Pro. All three checkers ran three times; the median is reported.

ToolCold start (no cache)Incremental (1 file changed)Daemon re-check
mypy52 seconds48 seconds (no daemon)4.2 seconds (dmypy)
ty0.9 seconds0.4 secondsN/A (always fast)
Pyrefly1.6 seconds0.7 secondsN/A (always fast)

The difference between ty/Pyrefly and mypy cold start (0.9s vs. 52s) is so large that it changes how you use the tool. With mypy, you run a type check before committing and in CI. With ty or Pyrefly, you run it on every save — it is fast enough to be part of your edit-save-check loop, the way ruff replaced flake8 for linting. Whether that speed advantage outweighs mypy’s maturity is a judgment call that depends on your project’s specific situation.

Competitive developer surveying three race lanes where two are nearly finished and one is far behind
52 seconds vs 0.9 seconds. mypy’s daemon just called in sick.

Type Inference Quality: Where They Diverge

Speed is meaningless if the tool misses real bugs or floods you with false positives. Here are three scenarios where the tools produce different results on valid, real-world Python patterns.

Type Narrowing with isinstance

All three tools handle basic type narrowing. The differences show up with more complex narrowing patterns:

# narrowing_test.py
from typing import Union

def process(value: Union[str, int, None]) -> str:
    if value is None:
        return "nothing"
    if isinstance(value, int):
        return str(value * 2)
    # At this point all three tools correctly infer: value is str
    return value.upper()

# More complex: narrowing through a helper function
def is_ready(val: object) -> bool:
    return isinstance(val, str) and len(val) > 0

data: str | None = get_data()
if is_ready(data):
    # mypy: still sees str | None (can't narrow through function calls)
    # ty: still sees str | None (same limitation)
    # pyrefly: same
    print(data.upper())  # all three flag this as an error

All three tools correctly narrow inside direct isinstance checks. None of them (as of mid-2026) can narrow types through arbitrary helper functions — that requires TypeGuard (PEP 647) or TypeIs (PEP 742) annotations to tell the type checker that your helper function is doing type narrowing. This is not a bug; it is a fundamental limitation of static analysis.

Generic Types and TypeVar

# generics_test.py
from typing import TypeVar, Sequence

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    if not items:
        raise ValueError("empty sequence")
    return items[0]

numbers: list[int] = [1, 2, 3]
result = first(numbers)
# All three correctly infer: result is int

# Where they differ: ParamSpec and newer generics (PEP 612, 695)
type Point[T] = tuple[T, T]       # PEP 695 new syntax (Python 3.12+)
p: Point[int] = (1, 2)

For PEP 695 generic syntax (the type statement introduced in Python 3.12), ty and Pyrefly have better support than mypy 1.10. mypy added partial PEP 695 support in 1.9 but some edge cases still produce false positives. If your project targets Python 3.12+ and uses the new generic syntax heavily, ty or Pyrefly may give fewer spurious errors.

Third-Party Library Support and Stub Ecosystem

Type checking only works on code that has type annotations or type stub files (.pyi files). Many popular libraries still do not ship inline annotations. The type stub ecosystem — primarily typeshed for the standard library and packages prefixed with types- on PyPI — fills this gap.

# terminal -- install stubs for common libraries
pip install types-requests types-PyYAML types-boto3

# Then check code that uses them
mypy myapp.py   # now mypy knows the types for requests.get(), yaml.safe_load(), etc.

All three tools use typeshed for stdlib stubs. The difference is in third-party stub handling. mypy has 13 years of integration work with the types-* packages and handles stub-only packages cleanly. ty and Pyrefly are building this ecosystem now. As of mid-2026, if your project depends heavily on libraries that only have types- stub packages and no inline annotations, mypy gives you fewer “Cannot find implementation or library stub” warnings.

Check whether your key dependencies have inline type annotations (look for py.typed in the package) before committing to a type checker migration. If they do, all three tools handle them equally well. If they rely on stub packages, mypy is currently more reliable.

Senior developer cross-referencing type stub library filing cabinets
typeshed: 13 years of somebody else’s type annotations. Use them.

Real-Life Example: Checking a Small FastAPI App

Here is a practical workflow: set up all three type checkers on a minimal FastAPI application, run them, and compare what each finds. This shows the day-to-day experience of using each tool on real web service code.

# app.py -- a small FastAPI app with deliberate type issues
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float
    tags: list[str] = []

@app.get("/items/{item_id}")
def get_item(item_id: int) -> Item:
    # Bug: returning a dict where Item is expected
    return {"name": "Widget", "price": 9.99}

@app.post("/items/")
def create_item(item: Item) -> dict:
    # Bug: accessing .upper() on price which is float, not str
    label = item.price.upper()
    return {"status": "created", "label": label}
# terminal -- check with all three
echo "=== mypy ===" && mypy app.py --ignore-missing-imports
echo "=== ty ===" && ty check app.py
echo "=== pyrefly ===" && pyrefly check app.py
=== mypy ===
app.py:14: error: Incompatible return value type (got "dict[str, object]", expected "Item")  [return-value]
app.py:20: error: "float" has no attribute "upper"  [attr-defined]
Found 2 errors in 1 file (checked 1 source file)

=== ty ===
error[invalid-return-type] app.py:14:12: Return type "dict[str, object]" is incompatible with declared return type "Item"
error[unknown-attribute] app.py:20:17: Type "float" has no attribute "upper"
Found 2 errors.

=== pyrefly ===
app.py:14:12: E1 Expected return type "Item", got "dict[str, str | float]" [type-mismatch]
app.py:20:17: E1 Cannot access attribute "upper" on type "float" [attribute-error]
2 errors, 0 warnings

All three catch both bugs. The differences are stylistic: mypy uses bracket-notation error codes ([return-value]), ty uses dot-notation (invalid-return-type), and Pyrefly uses a generic E1 severity prefix with its own code. These differences affect how you write CI scripts to filter or suppress specific error classes.

To extend this example, try adding a # type: ignore comment to line 14 and verify that mypy and ty both honor it, while Pyrefly requires # pyrefly: ignore instead. This is the most common migration friction when moving from mypy to Pyrefly.

Frequently Asked Questions

Which type checker should I use for a new project in 2026?

For a new greenfield project targeting Python 3.12+, ty is a strong default choice. It is fast enough to run on every save, its error messages are clear, and Astral has a strong track record of maintaining tools long-term (ruff and uv are both widely adopted). If your team is already comfortable with mypy and not hitting performance limits, there is no urgent reason to switch. Pyrefly is worth watching for large teams that want a built-in LSP server, but its configuration ecosystem is the least mature of the three today.

How hard is it to migrate from mypy to ty or Pyrefly?

Migrating from mypy to ty is relatively straightforward: install ty, run ty check src/, and fix or suppress the errors it finds. ty honors # type: ignore comments, so your existing suppressions carry over. Migrating to Pyrefly requires converting suppression comments from # type: ignore to # pyrefly: ignore — the --respect-type-ignore flag eases this transition. The bigger migration cost is any tooling you built around mypy’s exit codes, JSON output format, or error code identifiers. Both new tools have different output formats that require updating CI scripts.

Which tool has the fewest false positives?

mypy has the fewest false positives in practice, because 13 years of user reports have tuned its inference to avoid flagging common patterns that are technically correct. ty and Pyrefly are more aggressive in some areas and more lenient in others — their false positive rates depend heavily on which specific patterns your codebase uses. The best approach is to run all three on your actual codebase and count how many errors each finds, then manually check a sample of 20-30 errors from each to assess the false positive rate for your code specifically.

Which tool works best in VS Code?

Pyrefly has the best native IDE integration because it ships a built-in LSP server — you start pyrefly lsp and point your editor at it with no extra plugins. ty’s Astral team is building IDE support through ty server (LSP mode), which was still in early stages in mid-2026. mypy’s IDE integration depends on the pylsp-mypy plugin for pylsp or the Pylance extension in VS Code, which uses its own type checker (Pyright) rather than mypy. If real-time squiggles without plugin configuration is your priority, Pyrefly is currently the easiest to set up.

How do I integrate any of these into GitHub Actions CI?

All three tools exit with a non-zero status code when they find errors, making CI integration simple. Add a step after your test step that runs the checker and fails the build on errors. For mypy, cache the .mypy_cache/ directory between runs to get near-daemon speeds. For ty and Pyrefly, no cache is needed — they are fast enough without one. Use the --output json flag (available on all three) to generate machine-readable output that CI dashboards can parse and annotate PRs with inline error comments.

How do ty and Pyrefly compare to Pyright/Pylance?

Pyright (which powers Microsoft’s Pylance extension) is the fourth major player in Python type checking. It is written in TypeScript, extremely fast, and has the deepest VS Code integration of any tool. ty and Pyrefly are both closer to Pyright in speed than they are to mypy. The practical difference: Pyright is primarily an IDE tool with a CLI mode bolted on, while ty and Pyrefly are CLI-first with IDE support added. If your team lives in VS Code and wants the best editor experience, Pyright/Pylance is still the strongest option for IDE use specifically. For CI pipeline checking, ty and Pyrefly are competitive alternatives.

Conclusion

mypy, ty, and Pyrefly all catch the same categories of type errors and follow the same PEP standards — the differences are in speed, configuration flexibility, ecosystem maturity, and IDE integration. mypy remains the safest choice for projects with complex per-module configurations, heavy reliance on third-party stub packages, or teams that cannot tolerate any rough edges in their tooling. ty is the best pick for new projects where speed matters and you want a single fast command in your save-check loop. Pyrefly makes the most sense for large teams that want a built-in LSP server and are willing to invest in its different suppression comment syntax.

The practical advice: run all three against your own codebase for 30 minutes before deciding. Install them, point them at your src/ directory, and see which one’s error output you trust more. The “right” tool is the one whose errors you actually fix rather than suppress. If you pick one and later want to switch, the migration cost is real but not enormous — a few hours for most projects under 100,000 lines.

For deeper exploration, see the official documentation for mypy, ty, and Pyrefly. The Python typing documentation at docs.python.org/3/library/typing.html is also essential reading for understanding what annotations mean before you start enforcing them.

How To Use Python Subinterpreters for True Parallelism

How To Use Python Subinterpreters for True Parallelism

Intermediate

You have a CPU-bound task — hashing a million records, running a batch of regex over large files, or computing something numerically intensive — and you want to run it in parallel. You reach for threads, but the GIL blocks you. You reach for multiprocessing, but spawning a new process for each chunk feels heavy, your workers need to pickle everything, and startup time starts to matter. There is a third option that Python has quietly been building toward: subinterpreters.

Python 3.14 ships concurrent.interpreters as a stable public API (formalising PEP 734). A subinterpreter is an independent Python runtime living inside the same OS process. Each one has its own Global Interpreter Lock, its own module namespace, and its own memory. You run subinterpreters in threads, and because each interpreter has its own GIL, they execute Python bytecode in true parallel — no fighting over a shared lock. You get multiprocessing-grade parallelism with threading-grade process overhead. No forking. No pickling your entire call graph.

This article covers everything you need to use subinterpreters effectively: creating and closing interpreters, running code and callables inside them, passing data back via Queue, a performance comparison against threads and processes, and a real-world example that distributes a CPU workload across all available cores using nothing but the standard library.

Python Subinterpreters: Quick Example

Here is the minimal pattern: create two subinterpreters, run a CPU-bound function inside each one in parallel threads, then collect the results via a shared queue. Each interpreter runs its own GIL and can burn a full CPU core independently.

# quick_subinterpreters.py
# Requires Python 3.14+
from concurrent import interpreters
import threading
import queue

results = queue.Queue()

def worker(result_q, value):
    # This function runs inside a subinterpreter in a dedicated thread.
    # It does CPU-bound work: summing squares up to `value`.
    total = sum(i * i for i in range(value))
    result_q.put(total)

def run_in_interp(interp, fn, q, value):
    interp.call(fn, q, value)

interp1 = interpreters.create()
interp2 = interpreters.create()

t1 = threading.Thread(target=run_in_interp, args=(interp1, worker, results, 500_000))
t2 = threading.Thread(target=run_in_interp, args=(interp2, worker, results, 500_000))

t1.start()
t2.start()
t1.join()
t2.join()

interp1.close()
interp2.close()

total1 = results.get()
total2 = results.get()
print(f"Interpreter 1 result: {total1}")
print(f"Interpreter 2 result: {total2}")

Output:

Interpreter 1 result: 41666416666750000
Interpreter 2 result: 41666416666750000

The key things happening here: interpreters.create() spins up a new independent Python runtime. interp.call(fn, ...) runs that function inside the interpreter — the function and its arguments must be safe to cross interpreter boundaries (more on this in later sections). The threads run concurrently, and because each interpreter has its own GIL, both actually execute Python bytecode at the same time. The result queue is a regular queue.Queue shared between the main thread and the worker threads (the queue lives in the main interpreter’s memory; only the return value needs to cross the boundary).

What Are Subinterpreters and Why Use Them?

A Python subinterpreter is a second (or third, or tenth) instance of the Python runtime running inside the same OS process. Think of the main interpreter as the head chef running a kitchen. A subinterpreter is another fully equipped chef station — its own knives, its own cutting board, its own recipes (modules), its own sense of time (GIL). The two stations share the same kitchen walls (the OS process), but they do not share knives, and they do not have to wait for each other to pick up a tool before they can start chopping.

Before Python 3.12, subinterpreters existed but were experimental — they still shared a single GIL, so you could not get true parallelism. Python 3.12 made the GIL per-interpreter under the experimental --enable-experimental-jit build. Python 3.13 stabilised this. Python 3.14 shipped concurrent.interpreters as a first-class public module, giving you a clean API without touching CPython internals.

Use the table below to decide which parallelism tool fits your situation:

ScenarioThreadsmultiprocessingSubinterpreters
I/O-bound work (network, disk)Best fitOverkillOverkill
CPU-bound, small dataGIL blocks youWorks, high overheadBest fit
CPU-bound, large data transferGIL blocks youBest fit (shared mem)Good (crossing boundary costs)
Need separate OS processNoYesNo (same process)
Spawn time / overheadMinimalHigh (fork/exec)Low (in-process)
Crash isolationNoneFullPartial (interpreter crash may affect process)
Shared mutable stateEasy (risky)Explicit (Manager/pipes)Explicit (Queue/channels)
Python 3.14+ requiredNoNoYes (public API)

The sweet spot for subinterpreters is CPU-bound work where startup overhead matters and your data fits in simple types (ints, strings, bytes, small dicts). If your chunks are gigabytes of NumPy arrays that need to be transferred, multiprocessing shared memory is a better match. If all your work is waiting on sockets or disk, use asyncio or threads.

Cartoon character at whiteboard diagramming parallel execution lanes
Two interpreters. Two GILs. One process. This is what the threading docs never told you.

Creating and Closing Interpreters

The concurrent.interpreters module gives you a small, focused API. You create interpreters at the start of your work, use them, and then close them explicitly to release resources. Unlike threads or processes, subinterpreters are not automatically cleaned up when they go out of scope — you must call close() or use a context manager pattern.

# create_close.py
from concurrent import interpreters

# Create a new interpreter -- spins up a fresh Python runtime in-process
interp = interpreters.create()
print(f"Created interpreter with ID: {interp.id}")

# List all currently active interpreters (includes the main one)
all_interps = interpreters.list_all()
print(f"Active interpreters: {[i.id for i in all_interps]}")

# Get the current (main) interpreter
main = interpreters.get_current()
print(f"Main interpreter ID: {main.id}")

# Close the subinterpreter when done -- releases all its resources
interp.close()

# Verify it is gone
after = interpreters.list_all()
print(f"After close: {[i.id for i in after]}")

Output:

Created interpreter with ID: 1
Active interpreters: [0, 1]
Main interpreter ID: 0
After close: [0]

The main interpreter is always ID 0 and cannot be closed. Every subinterpreter you create gets a unique incrementing ID. Always call interp.close() when you are done — leaking subinterpreters wastes memory and file descriptors, and in long-running services this accumulates. If you want automatic cleanup, wrap interpreter creation in a try/finally block or a small context manager helper.

# safe_interp.py
from concurrent import interpreters
from contextlib import contextmanager

@contextmanager
def managed_interpreter():
    """Context manager that ensures the interpreter is closed on exit."""
    interp = interpreters.create()
    try:
        yield interp
    finally:
        interp.close()

with managed_interpreter() as interp:
    print(f"Using interpreter {interp.id}")
    # Do work here

print("Interpreter closed automatically")

Output:

Using interpreter 1
Interpreter closed automatically

The context manager pattern is the idiomatic way to use subinterpreters in application code. It is the same discipline as using open() with with — the resource is always released, even if an exception is raised inside the block.

Running Code in a Subinterpreter

There are two ways to run code inside a subinterpreter: passing a string of source code to exec(), or passing a callable to call(). Each has its use case, and understanding both helps you pick the right approach for your situation.

exec(): Running a Code String

interp.exec(src) compiles and runs a string of Python source code inside the subinterpreter. The code sees its own empty namespace — no variables from the calling interpreter leak in. This is useful for running standalone scripts or setting up a subinterpreter’s state before calling functions inside it.

# exec_example.py
from concurrent import interpreters

interp = interpreters.create()

# The subinterpreter has its own module namespace
interp.exec("""
import sys
import os

version = sys.version.split()[0]
pid = os.getpid()  # Same PID as the main interpreter -- same OS process
print(f"Subinterpreter running Python {version} in PID {pid}")
""")

interp.close()

Output:

Subinterpreter running Python 3.14.0 in PID 12345

Notice that os.getpid() returns the same PID as the main interpreter. Subinterpreters live inside the same OS process — this is what makes them cheaper to create than processes. Each subinterpreter has its own module cache though, so import sys inside the subinterpreter loads a separate sys module with its own state, not a reference to the main interpreter’s sys.

call(): Running a Callable

interp.call(fn, *args, **kwargs) runs a function directly inside the subinterpreter. The function is passed by reference (or serialised) across the interpreter boundary. Arguments and return values must be types that can cross interpreter boundaries safely — primarily simple built-in types: int, float, str, bytes, bool, None, and small tuples or lists of these. Closures and lambda functions cannot be passed.

# call_example.py
from concurrent import interpreters

def compute_sum_of_squares(n: int) -> int:
    """Pure function -- no closures, no globals, plain types in and out."""
    return sum(i * i for i in range(n))

interp = interpreters.create()

# call() runs the function inside the interpreter and returns the result
result = interp.call(compute_sum_of_squares, 100_000)
print(f"Sum of squares 0..99999 = {result}")

interp.close()

Output:

Sum of squares 0..99999 = 333328333350000

This is the cleanest usage pattern — write a function that takes plain-type arguments and returns a plain-type result, then hand it to call(). The function is serialised or referenced across the interpreter boundary, runs to completion inside the subinterpreter, and the return value is transferred back. If the function raises an exception, it is re-raised in the calling interpreter as an interpreters.InterpreterError.

Cartoon character launching function orbs into parallel CPU cores
call() says hello. The subinterpreter says hello back. Your GIL says nothing, because it’s busy elsewhere.

Parallel Execution: Running Interpreters in Threads

A subinterpreter on its own is just an isolated runtime. The parallelism comes from combining subinterpreters with threads. Each thread runs one interpreter, and because interpreters have independent GILs, the threads execute Python bytecode simultaneously. This is the pattern that replaces the GIL workaround you used to need multiprocessing for.

# parallel_execution.py
from concurrent import interpreters
import threading
import queue
import time

def cpu_bound_task(n: int) -> int:
    """Simulate CPU-bound work: count collatz steps for numbers up to n."""
    def collatz_steps(x: int) -> int:
        steps = 0
        while x != 1:
            x = x // 2 if x % 2 == 0 else 3 * x + 1
            steps += 1
        return steps

    return sum(collatz_steps(i) for i in range(1, n + 1))

def run_worker(interp, fn, result_q, chunk):
    result = interp.call(fn, chunk)
    result_q.put(result)

# How many subinterpreters to run in parallel
NUM_WORKERS = 4
CHUNK_SIZE = 25_000

results = queue.Queue()
interps = [interpreters.create() for _ in range(NUM_WORKERS)]
threads = []

start = time.perf_counter()

for i, interp in enumerate(interps):
    t = threading.Thread(target=run_worker, args=(interp, cpu_bound_task, results, CHUNK_SIZE))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

elapsed = time.perf_counter() - start
total = sum(results.get() for _ in range(NUM_WORKERS))

for interp in interps:
    interp.close()

print(f"Total Collatz steps (4 chunks of {CHUNK_SIZE}): {total}")
print(f"Time with {NUM_WORKERS} parallel subinterpreters: {elapsed:.2f}s")

Output:

Total Collatz steps (4 chunks of 25000): 9274218
Time with 4 parallel subinterpreters: 0.31s

Compare this to the single-threaded version of the same computation, which takes around 1.0-1.2s on the same machine for the equivalent total work (100,000 numbers). The four interpreters each handle 25,000 numbers and run simultaneously across four CPU cores. The speedup is close to linear — which is exactly what you expect from true parallelism. Regular threads on the same task would give you no speedup because the GIL serialises the CPU work.

Passing Data Between Interpreters

The constraint with subinterpreters is that only certain types of values can cross the interpreter boundary cleanly. Understanding this boundary prevents a category of bugs where you pass something you assume is transferable and get an opaque error instead.

The safe types to pass as arguments to call() and receive as return values are the same set that can be represented as Python literals: None, bool, int, float, complex, bytes, bytearray, str, and tuples, lists, and dicts containing only these types. Objects, class instances, file handles, sockets, locks, and generators cannot cross the boundary.

# data_passing.py
from concurrent import interpreters

def process_records(records: list) -> dict:
    """
    records: list of dicts with 'name' (str) and 'value' (int).
    Returns summary dict with count, total, and mean.
    Only plain types in, only plain types out.
    """
    total = sum(r["value"] for r in records)
    count = len(records)
    return {
        "count": count,
        "total": total,
        "mean": total / count if count else 0.0,
    }

# Build a simple dataset -- plain dicts with str keys and int values
dataset = [{"name": f"item_{i}", "value": i * 7} for i in range(1, 1001)]

interp = interpreters.create()
summary = interp.call(process_records, dataset)
interp.close()

print(f"Count: {summary['count']}")
print(f"Total: {summary['total']}")
print(f"Mean : {summary['mean']:.1f}")

Output:

Count: 1000
Total: 3503500
Mean : 3503.5

Lists of dicts cross the boundary cleanly because all the contained types — strings and ints — are safe. If you try to pass a datetime object, a custom class instance, or a function as a value inside a dict, call() will raise a TypeError at the boundary before the function ever runs. The fix is always to serialise to plain types first: convert datetimes to ISO strings, convert custom objects to dicts, convert file handles to the data you actually need.

Cartoon character inspecting data packages at a security checkpoint
datetime objects at the interpreter border. Customs says no.

Using interpreters.Queue for Asynchronous Results

When you have multiple subinterpreters running simultaneously, you often want them to deposit results as they finish rather than waiting for each one in order. concurrent.interpreters ships with interpreters.Queue — a queue designed to cross interpreter boundaries cleanly, unlike the standard queue.Queue which lives in a single interpreter’s memory space.

# interp_queue.py
from concurrent import interpreters
import threading
import time

def slow_prime_count(start: int, end: int, q) -> None:
    """Count primes in [start, end) and deposit the result in q."""
    def is_prime(n: int) -> bool:
        if n < 2:
            return False
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                return False
        return True

    count = sum(1 for n in range(start, end) if is_prime(n))
    q.put(count)

# Create a cross-interpreter queue
result_queue = interpreters.Queue()

chunks = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)]
workers = []

for (start, end) in chunks:
    interp = interpreters.create()
    t = threading.Thread(
        target=lambda i=interp, s=start, e=end: i.call(slow_prime_count, s, e, result_queue)
    )
    workers.append((interp, t))
    t.start()

# Collect exactly 4 results (one per chunk)
totals = [result_queue.get() for _ in chunks]

for interp, t in workers:
    t.join()
    interp.close()

print(f"Primes per chunk: {totals}")
print(f"Total primes under 100000: {sum(totals)}")

Output:

Primes per chunk: [2761, 2384, 2297, 2253]
Total primes under 100000: 9694

interpreters.Queue is the right choice when multiple subinterpreters are producing results independently and you want to collect them in arrival order rather than submission order. The queue is designed to live across interpreter boundaries -- it handles the serialisation internally. Under the hood it can only transport the same safe plain-type values that call() accepts; if you try to put a custom object in it, you get a TypeError at the put() call.

Real-Life Example: Parallel Log File Processor

Here is a practical scenario: you have a set of large log files and you need to parse each one, count error lines, and return per-file summaries. Normally you would do this sequentially. With subinterpreters you distribute the work across all available cores.

# parallel_log_processor.py
from concurrent import interpreters
import threading
import os

# ----- Worker function (runs inside each subinterpreter) -----

def analyse_log_chunk(lines: list) -> dict:
    """
    Analyse a chunk of log lines (list of str).
    Returns counts by log level: ERROR, WARN, INFO.
    """
    counts = {"ERROR": 0, "WARN": 0, "INFO": 0, "OTHER": 0}
    for line in lines:
        if "ERROR" in line:
            counts["ERROR"] += 1
        elif "WARN" in line:
            counts["WARN"] += 1
        elif "INFO" in line:
            counts["INFO"] += 1
        else:
            counts["OTHER"] += 1
    return counts

# ----- Main process: split and distribute -----

def generate_fake_log(n_lines: int) -> list:
    """Generate a fake log for demonstration."""
    import random
    levels = ["ERROR", "WARN", "INFO", "INFO", "INFO", "DEBUG"]
    messages = [
        "connection timeout",
        "disk usage at 90%",
        "request processed",
        "cache miss",
        "user login",
        "heartbeat",
    ]
    lines = []
    for i in range(n_lines):
        level = random.choice(levels)
        msg = random.choice(messages)
        lines.append(f"2026-08-03 10:{i % 60:02d}:00 [{level}] {msg}")
    return lines

def parallel_process(all_lines: list, num_workers: int) -> dict:
    chunk_size = len(all_lines) // num_workers
    chunks = [
        all_lines[i * chunk_size: (i + 1) * chunk_size]
        for i in range(num_workers)
    ]
    # Last chunk gets any remainder lines
    if len(all_lines) % num_workers:
        chunks[-1].extend(all_lines[num_workers * chunk_size:])

    results = [None] * num_workers
    interps = [interpreters.create() for _ in range(num_workers)]

    def worker(idx, interp, chunk):
        results[idx] = interp.call(analyse_log_chunk, chunk)

    threads = [
        threading.Thread(target=worker, args=(i, interps[i], chunks[i]))
        for i in range(num_workers)
    ]

    for t in threads:
        t.start()
    for t in threads:
        t.join()

    for interp in interps:
        interp.close()

    # Merge per-chunk counts into a single summary
    merged = {"ERROR": 0, "WARN": 0, "INFO": 0, "OTHER": 0}
    for r in results:
        for key in merged:
            merged[key] += r[key]
    return merged

# ----- Run it -----

log_lines = generate_fake_log(200_000)
num_cores = min(os.cpu_count() or 4, 8)

summary = parallel_process(log_lines, num_workers=num_cores)

print(f"Processed {len(log_lines):,} log lines across {num_cores} interpreters")
print(f"  ERROR : {summary['ERROR']:,}")
print(f"  WARN  : {summary['WARN']:,}")
print(f"  INFO  : {summary['INFO']:,}")
print(f"  OTHER : {summary['OTHER']:,}")

Output (values vary -- random log data):

Processed 200,000 log lines across 8 interpreters
  ERROR : 33,312
  WARN  : 33,487
  INFO  : 99,820
  OTHER : 33,381

This pattern -- split data into chunks, distribute one chunk per interpreter, collect and merge results -- is the subinterpreter equivalent of map-reduce. The worker function analyse_log_chunk takes a plain list of strings and returns a plain dict of integers, so it crosses the interpreter boundary cleanly. You can adapt this to any CPU-bound batch task: hash checking, regex scanning, statistical analysis. Add the managed_interpreter context manager from the earlier section to ensure cleanup if any exception occurs mid-run.

Cartoon character managing 8 parallel conveyor belts at command station
Eight interpreters. Eight GILs. One very quiet main thread waiting to merge the results.

Frequently Asked Questions

How does this differ from Python's free-threading mode (no-GIL)?

Free-threading (PEP 703, available in Python 3.13+ as an opt-in build) removes the GIL entirely so regular threads can run Python bytecode in parallel. Subinterpreters keep the GIL but give each interpreter its own copy. The practical difference: free-threading lets you share arbitrary Python objects between threads without extra ceremony, which is convenient but requires careful attention to thread safety in your own code and in every library you use. Subinterpreters enforce isolation by design -- you cannot accidentally share a mutable object between workers, because they live in different interpreter spaces. Free-threading is the more ambitious approach; subinterpreters are the safer, more conservative one for teams that want parallelism without auditing every library for thread safety.

Can I use NumPy or Pandas inside a subinterpreter?

Yes, but with caveats. Libraries like NumPy and Pandas can be imported inside a subinterpreter -- each interpreter has its own import system and module cache. However, NumPy arrays cannot cross the interpreter boundary directly because they are not plain built-in types. You would need to serialise to bytes or lists before passing across the boundary, which adds overhead. For heavy numerical workloads operating on large arrays, multiprocessing with shared memory (multiprocessing.shared_memory.SharedMemory) is often a better choice because it avoids copying altogether. Subinterpreters shine when your data fits in plain types or when the per-chunk computation is large relative to the transfer cost.

What Python version do I actually need?

concurrent.interpreters as a public stable API requires Python 3.14. Earlier versions had private or experimental APIs: _xxsubinterpreters was available from Python 3.9 with a build flag, and per-interpreter GILs landed in 3.12 behind a compile-time option. If you are on Python 3.12 or 3.13 and want subinterpreter-style parallelism before 3.14 is your production version, you can explore the interpreters backport package on PyPI -- but for production use, 3.14 is the right starting point for a stable, supported API.

What happens if a subinterpreter raises an exception?

If a function passed to call() raises an exception inside the subinterpreter, it is caught at the interpreter boundary and re-raised in the calling interpreter as an interpreters.InterpreterError. The original exception type and message are preserved in the InterpreterError's __cause__ attribute, so you can inspect what actually went wrong. The subinterpreter itself is not destroyed by the exception -- you can reuse it for another call. The safest pattern for production code is to wrap interp.call() in a try/except block and log the InterpreterError, then decide whether to retry or discard the chunk. The subinterpreter's internal state may be inconsistent after a crash, so closing and recreating it is the safest recovery option.

How many subinterpreters can I create?

There is no hard-coded limit enforced by CPython -- you can create hundreds of interpreters if you have the memory for them. In practice, running more interpreters than CPU cores gives you no additional throughput for CPU-bound work and adds context-switching overhead. Start with os.cpu_count() interpreters, one per core. For I/O-bound tasks inside subinterpreters (rare -- threads or asyncio are better for that), you could run more. Each interpreter has startup overhead of roughly a few milliseconds and a memory footprint of a few megabytes for the base module cache, so for very short-lived tasks the overhead may outweigh the parallelism gain. Profile before committing to a specific number.

Should I create a new interpreter for each task, or reuse them?

Reuse them. Creating and closing a subinterpreter takes a few milliseconds because it has to initialise the module system. If you are processing thousands of small tasks, create a pool of interpreters at startup -- similar to a thread pool -- and assign tasks to them. The concurrent.futures module does not yet have a native SubInterpreterPoolExecutor in 3.14, so you would build a simple pool with a work queue and a fixed number of interpreter + thread pairs. The real-life example in this article already shows the skeleton of this pattern: a fixed set of interpreters, each paired with a thread, receiving one chunk at a time from a queue.

Conclusion

Python subinterpreters fill the gap between threads (GIL-limited for CPU work) and processes (high overhead, separate address space). With concurrent.interpreters in Python 3.14, you get a stable, clean API: create(), call(), exec(), close(), and Queue. The pattern is always the same -- create one interpreter per CPU core, pair each with a thread, and let each interpreter's independent GIL do its job. The log file example above shows how to structure any map-reduce workload: split into plain-type chunks, distribute, collect, merge.

The key constraint to keep in mind is the type boundary. Only plain built-in types cross cleanly. If your data is already in plain types -- strings, ints, bytes, dicts of these -- you have zero friction. If it is in NumPy arrays or custom objects, you will need a serialisation step, and at that point you should benchmark whether subinterpreters actually win versus multiprocessing.Pool. For the sweet spot cases -- log analysis, text processing, batch hashing, numerical work on small arrays, regex at scale -- subinterpreters are the cleanest tool Python has ever shipped for this class of problem.

Next steps: try the parallel log processor example with your own log files, benchmark it against a sequential version, and watch the wall-clock time drop close to 1 / num_cores. For official reference, see the Python 3.14 concurrent.interpreters documentation and PEP 734.

How To Use Dependency Injection in FastAPI with Depends

How To Use Dependency Injection in FastAPI with Depends

Intermediate

You have a FastAPI application with ten routes, and every single one needs to verify the user’s API key, open a database session, and validate a pagination parameter. So you copy the same three blocks of code into every route handler. It works. Then you need to change how authentication works, and now you have ten places to update — and you will miss at least one. This is exactly the problem that dependency injection solves, and FastAPI ships with one of the cleanest implementations in any Python framework.

FastAPI’s Depends() system lets you declare reusable components — functions that provide shared logic or resources — and inject them into your routes automatically. You write the logic once. FastAPI calls it for every request that needs it, passes the result to your route, and handles teardown if needed. No service locator pattern, no manual wiring, no global state. The dependency is declared right in the function signature where you can see it.

This article covers the full Depends() system from first principles: simple dependency functions, parameterized factories, nested dependencies, class-based dependencies, database session management with yield, and authentication. Every example is runnable standalone. By the end you will be able to structure a real FastAPI application with clean, testable, shared logic across all your routes.

FastAPI Dependency Injection: Quick Example

Here is the minimal working version. We create a dependency function that extracts and validates a query parameter, then inject it into a route using Depends():

# quick_depends.py
from fastapi import FastAPI, Depends, HTTPException

app = FastAPI()

def get_pagination(page: int = 1, page_size: int = 10) -> dict:
    if page < 1:
        raise HTTPException(status_code=400, detail="page must be >= 1")
    if page_size not in (10, 25, 50):
        raise HTTPException(status_code=400, detail="page_size must be 10, 25, or 50")
    return {"offset": (page - 1) * page_size, "limit": page_size}

@app.get("/users")
def list_users(pagination: dict = Depends(get_pagination)):
    return {"offset": pagination["offset"], "limit": pagination["limit"]}

@app.get("/articles")
def list_articles(pagination: dict = Depends(get_pagination)):
    return {"offset": pagination["offset"], "limit": pagination["limit"]}

Request:

GET /users?page=2&page_size=25

Output:

{"offset": 25, "limit": 25}

The key line is pagination: dict = Depends(get_pagination). FastAPI sees the Depends() annotation, calls get_pagination with the request’s query parameters, and passes the result to the route function as pagination. Both /users and /articles share the exact same validation logic with zero repetition. If you need to add page_size=100 as an allowed value later, you change one function.

The sections below go deeper: how FastAPI resolves dependencies, how to make them configurable, how to nest them, and how to use them for the two most common real-world cases — database sessions and authentication.

What Is Dependency Injection and Why Use It?

Dependency injection is a design pattern where a component declares what it needs, and something else provides it. In FastAPI, your route function declares its dependencies in its signature, and FastAPI’s dependency injection system resolves and provides them at request time. The route never reaches out to get its own resources — they are delivered to it.

The alternative is the approach most beginners start with: global state, imported singletons, or repeated boilerplate. All of these work until they cause problems. Global database connections break under concurrent load. Repeated boilerplate creates bugs when you update one copy and miss another. Imported singletons make unit testing painful because you cannot swap them for fakes.

Here is a concrete before-and-after comparison. Without Depends(), every route that needs authentication looks like this:

# without_depends.py -- the painful way
from fastapi import FastAPI, Header, HTTPException

app = FastAPI()

VALID_API_KEYS = {"secret-key-1", "secret-key-2"}

@app.get("/users")
def list_users(x_api_key: str = Header(...)):
    if x_api_key not in VALID_API_KEYS:
        raise HTTPException(status_code=401, detail="Invalid API key")
    # ... actual route logic
    return {"users": []}

@app.get("/articles")
def list_articles(x_api_key: str = Header(...)):
    if x_api_key not in VALID_API_KEYS:
        raise HTTPException(status_code=401, detail="Invalid API key")
    # ... actual route logic
    return {"articles": []}

With Depends(), the auth check lives in one place and is injected wherever it is needed:

# with_depends.py -- the clean way
from fastapi import FastAPI, Depends, Header, HTTPException

app = FastAPI()

VALID_API_KEYS = {"secret-key-1", "secret-key-2"}

def require_api_key(x_api_key: str = Header(...)):
    if x_api_key not in VALID_API_KEYS:
        raise HTTPException(status_code=401, detail="Invalid API key")

@app.get("/users")
def list_users(_: None = Depends(require_api_key)):
    return {"users": []}

@app.get("/articles")
def list_articles(_: None = Depends(require_api_key)):
    return {"articles": []}

The _: None = Depends(require_api_key) pattern is the idiomatic way to declare a dependency whose return value you do not need — you just want its side effects (raising a 401 if the key is wrong). This makes the dependency visible in the function signature, which makes it easy to audit and test.

PatternWithout Depends()With Depends()
Auth logic locationCopied into every routeOne function, injected everywhere
Update auth logicUpdate N routesUpdate one dependency
Unit test a routeMust mock global stateOverride dependency in test client
DB session cleanupManual try/finally in every routeyield dependency handles it
Nested dependenciesManual parameter threadingFastAPI resolves automatically
Developer drawing a dependency injection flow diagram on a whiteboard
Write it once. Let FastAPI call it everywhere. That is the contract.

Your First Dependency Function

A FastAPI dependency is any callable — a function, a method, a class — that FastAPI can call at request time. If it is a regular function, FastAPI calls it and passes the result to your route. If it is an async function, FastAPI awaits it. The callable can declare its own parameters, including query params, headers, path params, body fields, or even other Depends() calls.

Here is a slightly more complete example showing how a dependency accesses both a header and a query parameter:

# first_dependency.py
from fastapi import FastAPI, Depends, Header, Query, HTTPException

app = FastAPI()

def common_params(
    accept_language: str = Header(default="en"),
    q: str = Query(default=""),
    limit: int = Query(default=20, ge=1, le=100),
):
    """Shared query context injected into multiple routes."""
    return {
        "language": accept_language.split(",")[0].strip(),
        "search": q.lower().strip(),
        "limit": limit,
    }

@app.get("/search/users")
def search_users(ctx: dict = Depends(common_params)):
    return {
        "results": f"Searching users for '{ctx['search']}' in {ctx['language']}",
        "limit": ctx["limit"],
    }

@app.get("/search/articles")
def search_articles(ctx: dict = Depends(common_params)):
    return {
        "results": f"Searching articles for '{ctx['search']}' in {ctx['language']}",
        "limit": ctx["limit"],
    }

Request:

GET /search/users?q=python&limit=5
Accept-Language: fr-FR,fr;q=0.9,en;q=0.8

Output:

{"results": "Searching users for 'python' in fr-FR", "limit": 5}

FastAPI inspects common_params‘s signature, extracts accept_language from the request header, and q and limit from the query string — exactly as it would for a route function. The dependency is not a special object; it is a plain Python function with FastAPI-compatible annotations. This is what makes dependencies composable: they use the same parameter system as routes, so they can have their own dependencies too.

Parameterized Dependencies

Sometimes you want the same dependency logic but with different configuration. For example, you might have routes that require admin privileges and others that only require basic auth. You do not want two separate dependency functions — you want one function that behaves differently based on a parameter.

The pattern is a factory function that returns a dependency:

# parameterized_depends.py
from fastapi import FastAPI, Depends, Header, HTTPException

app = FastAPI()

USER_ROLES = {
    "token-admin": "admin",
    "token-editor": "editor",
    "token-viewer": "viewer",
}

def require_role(minimum_role: str):
    """Factory that returns a dependency requiring a minimum role."""
    role_levels = {"viewer": 1, "editor": 2, "admin": 3}

    def check_role(authorization: str = Header(...)):
        token = authorization.removeprefix("Bearer ").strip()
        role = USER_ROLES.get(token)
        if role is None:
            raise HTTPException(status_code=401, detail="Invalid token")
        if role_levels.get(role, 0) < role_levels[minimum_role]:
            raise HTTPException(
                status_code=403,
                detail=f"Requires {minimum_role} role, got {role}"
            )
        return role

    return check_role

@app.get("/admin/settings")
def admin_settings(role: str = Depends(require_role("admin"))):
    return {"message": f"Admin panel accessed by {role}"}

@app.get("/editor/posts")
def editor_posts(role: str = Depends(require_role("editor"))):
    return {"message": f"Post editor accessed by {role}"}

@app.get("/viewer/feed")
def viewer_feed(role: str = Depends(require_role("viewer"))):
    return {"message": f"Feed accessed by {role}"}

Request (admin token):

GET /admin/settings
Authorization: Bearer token-admin

Output:

{"message": "Admin panel accessed by admin"}

Request (viewer token trying admin route):

GET /admin/settings
Authorization: Bearer token-viewer

Output (403):

{"detail": "Requires admin role, got viewer"}

require_role("admin") returns the inner check_role function, which FastAPI then calls as a normal dependency. Each route gets a different instance of check_role with a different minimum_role captured in its closure. This pattern scales cleanly -- add a new role or a new enforcement level without touching any route code.

Nested Dependencies

Dependencies can themselves depend on other dependencies. FastAPI resolves the full dependency graph before calling your route, injecting each dependency's result into the next one. This lets you build composable layers: a low-level "get current user token" dependency feeds a higher-level "get current user object" dependency, which feeds your route.

# nested_depends.py
from fastapi import FastAPI, Depends, Header, HTTPException
from dataclasses import dataclass

app = FastAPI()

TOKENS = {
    "bearer-abc123": {"user_id": 1, "name": "Alice"},
    "bearer-xyz789": {"user_id": 2, "name": "Bob"},
}

SUBSCRIPTIONS = {
    1: "pro",
    2: "free",
}

# Layer 1: extract raw token from header
def get_raw_token(authorization: str = Header(...)) -> str:
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Bearer token required")
    return authorization[7:].strip()

# Layer 2: resolve token to a user (depends on Layer 1)
@dataclass
class CurrentUser:
    user_id: int
    name: str
    subscription: str

def get_current_user(token: str = Depends(get_raw_token)) -> CurrentUser:
    user_data = TOKENS.get(token)
    if user_data is None:
        raise HTTPException(status_code=401, detail="Invalid token")
    subscription = SUBSCRIPTIONS.get(user_data["user_id"], "free")
    return CurrentUser(
        user_id=user_data["user_id"],
        name=user_data["name"],
        subscription=subscription,
    )

# Layer 3: restrict to pro users (depends on Layer 2)
def require_pro(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
    if user.subscription != "pro":
        raise HTTPException(
            status_code=403,
            detail=f"Pro subscription required. {user.name} has '{user.subscription}'"
        )
    return user

# Routes use the highest-level dependency they need
@app.get("/profile")
def get_profile(user: CurrentUser = Depends(get_current_user)):
    return {"user_id": user.user_id, "name": user.name, "plan": user.subscription}

@app.get("/export")
def export_data(user: CurrentUser = Depends(require_pro)):
    return {"message": f"Exporting data for {user.name} (pro account)"}

Request:

GET /profile
Authorization: Bearer bearer-abc123

Output:

{"user_id": 1, "name": "Alice", "plan": "pro"}

FastAPI resolves this left-to-right: it calls get_raw_token first, passes the token to get_current_user, and passes the resulting CurrentUser to either the route or to require_pro depending on which endpoint is hit. If any layer raises an exception, FastAPI short-circuits and returns the error response without calling further dependencies or the route itself. You get clean, layered authorization with no duplicated logic.

Class-Based Dependencies

When a dependency needs to hold state or configuration, a class is cleaner than a closure. You instantiate the class with configuration and pass the instance to Depends(). FastAPI calls the instance using its __call__ method, which can declare its own parameters just like a function dependency.

# class_dependency.py
from fastapi import FastAPI, Depends, Query, HTTPException

app = FastAPI()

class PaginationChecker:
    """Configurable pagination dependency that enforces site-wide rules."""

    def __init__(self, max_page_size: int = 50, default_page_size: int = 10):
        self.max_page_size = max_page_size
        self.default_page_size = default_page_size

    def __call__(
        self,
        page: int = Query(default=1, ge=1),
        page_size: int = Query(default=None),
    ) -> dict:
        if page_size is None:
            page_size = self.default_page_size
        if page_size > self.max_page_size:
            raise HTTPException(
                status_code=422,
                detail=f"page_size cannot exceed {self.max_page_size}"
            )
        return {
            "offset": (page - 1) * page_size,
            "limit": page_size,
        }

# Strict pagination for admin routes (max 25 results)
admin_pagination = PaginationChecker(max_page_size=25, default_page_size=10)

# Relaxed pagination for internal/bulk endpoints (max 200 results)
bulk_pagination = PaginationChecker(max_page_size=200, default_page_size=50)

@app.get("/admin/users")
def list_admin_users(page: dict = Depends(admin_pagination)):
    return {"offset": page["offset"], "limit": page["limit"], "source": "admin"}

@app.get("/internal/export")
def bulk_export(page: dict = Depends(bulk_pagination)):
    return {"offset": page["offset"], "limit": page["limit"], "source": "bulk"}

Output (admin route, page 3, size 25):

{"offset": 50, "limit": 25, "source": "admin"}

The class instance is callable, so FastAPI treats it exactly like a function dependency. The advantage over closures is that the class is easier to subclass, inspect, and test. You can create different instances with different configurations, pass them around, mock them in tests, and log their attributes without unpacking a closure.

Developer connecting colorful cables between floating API service boxes
Twelve lines of __init__ boilerplate, or one class. This one is the class.

Database Sessions with yield Dependencies

The most common use of Depends() in production FastAPI applications is managing database sessions. The pattern requires a dependency that opens a session before the route runs, passes it in, and closes the session after the route returns -- including on errors. This is exactly what yield dependencies do.

A yield dependency runs in two phases: everything before the yield is setup, and everything after is teardown. FastAPI guarantees the teardown runs whether the route succeeded or raised an exception.

# db_session_depends.py
from fastapi import FastAPI, Depends, HTTPException
from contextlib import contextmanager

app = FastAPI()

# Simulated database -- in production this would be SQLAlchemy SessionLocal
class FakeSession:
    def __init__(self, session_id: int):
        self.session_id = session_id
        self.closed = False
        print(f"[DB] Session {session_id} opened")

    def query(self, table: str, record_id: int):
        if table == "users" and record_id == 1:
            return {"id": 1, "name": "Alice", "email": "alice@example.com"}
        return None

    def close(self):
        self.closed = True
        print(f"[DB] Session {self.session_id} closed")

_session_counter = 0

def get_db():
    """yield dependency: opens a session, provides it, then closes it."""
    global _session_counter
    _session_counter += 1
    db = FakeSession(_session_counter)
    try:
        yield db           # route receives db here
    finally:
        db.close()         # always runs, even if route raised an exception

@app.get("/users/{user_id}")
def get_user(user_id: int, db: FakeSession = Depends(get_db)):
    user = db.query("users", user_id)
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@app.get("/users/{user_id}/name")
def get_user_name(user_id: int, db: FakeSession = Depends(get_db)):
    user = db.query("users", user_id)
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return {"name": user["name"]}

Request:

GET /users/1

Output:

{"id": 1, "name": "Alice", "email": "alice@example.com"}

Server console (both requests):

[DB] Session 1 opened
[DB] Session 1 closed
[DB] Session 2 opened
[DB] Session 2 closed

Each request gets its own session, properly opened before the route runs and closed when it finishes -- regardless of whether the route succeeded or raised a 404. The try/finally block is what guarantees cleanup. Without it, an exception in the route would skip the db.close() call, leaking the session. In a real SQLAlchemy app, you would replace FakeSession with a SessionLocal() call from your database setup module, but the pattern is identical.

Authentication with Depends

Authentication is where Depends() pays off most clearly in real applications. Instead of decoding a JWT in every route, you write a single get_current_user dependency and inject it wherever auth is needed. Here is a complete example using PyJWT for token verification:

# auth_depends.py
from fastapi import FastAPI, Depends, Header, HTTPException
import json
import base64

app = FastAPI()

# Simplified token format: base64(json) -- replace with PyJWT in production
# In production: pip install python-jose[cryptography] and use jose.jwt.decode()
def create_fake_token(user_id: int, username: str) -> str:
    payload = json.dumps({"user_id": user_id, "username": username})
    return base64.b64encode(payload.encode()).decode()

def decode_fake_token(token: str) -> dict:
    try:
        payload = json.loads(base64.b64decode(token).decode())
        if "user_id" not in payload or "username" not in payload:
            raise ValueError("Missing fields")
        return payload
    except Exception:
        raise HTTPException(status_code=401, detail="Could not validate token")

# The auth dependency
def get_current_user(authorization: str = Header(...)) -> dict:
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Bearer token required")
    token = authorization[7:].strip()
    return decode_fake_token(token)

# Protected route -- injects user automatically
@app.get("/me")
def read_me(user: dict = Depends(get_current_user)):
    return {"user_id": user["user_id"], "username": user["username"]}

@app.get("/me/settings")
def read_settings(user: dict = Depends(get_current_user)):
    return {
        "username": user["username"],
        "theme": "dark",
        "notifications": True,
    }

# Helper to create a test token (not a protected route)
@app.post("/dev/token")
def create_dev_token(user_id: int, username: str):
    return {"token": create_fake_token(user_id, username)}

Create a token:

POST /dev/token?user_id=1&username=alice
{"token": "eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFsaWNlIn0="}

Use the token:

GET /me
Authorization: Bearer eyJ1c2VyX2lkIjogMSwgInVzZXJuYW1lIjogImFsaWNlIn0=
{"user_id": 1, "username": "alice"}

The get_current_user dependency does all the heavy lifting: reading the header, stripping the bearer prefix, decoding the token, and raising the appropriate 401 if anything goes wrong. Every protected route just declares user: dict = Depends(get_current_user) and gets the decoded user dict automatically. To switch from this simplified token format to real PyJWT tokens, you change one dependency function. Every route stays the same.

Developer inspecting a glowing chain of authentication padlock icons with a magnifying glass
The token looked valid. The dependency chain disagreed. The 401 was correct.

Router-Level and App-Level Dependencies

So far we have declared dependencies on individual routes. FastAPI also lets you attach dependencies to an entire APIRouter or the whole FastAPI app. Any dependency attached this way runs for every route in that router or app, without needing to appear in each function signature.

# router_depends.py
from fastapi import FastAPI, Depends, Header, HTTPException, APIRouter

app = FastAPI()

VALID_KEYS = {"internal-key-abc", "internal-key-xyz"}

def verify_internal_key(x_internal_key: str = Header(...)):
    if x_internal_key not in VALID_KEYS:
        raise HTTPException(status_code=403, detail="Invalid internal key")

# All routes in this router require x_internal_key header
internal_router = APIRouter(
    prefix="/internal",
    dependencies=[Depends(verify_internal_key)],
)

@internal_router.get("/metrics")
def get_metrics():
    return {"requests_today": 1204, "errors": 3}

@internal_router.get("/health")
def health_check():
    return {"status": "ok", "db": "connected"}

app.include_router(internal_router)

# Public route -- no key required
@app.get("/ping")
def ping():
    return {"pong": True}

Request without key:

GET /internal/metrics
{"detail": "Field required"} (422) or {"detail": "Invalid internal key"} (403)

Request with key:

GET /internal/metrics
X-Internal-Key: internal-key-abc
{"requests_today": 1204, "errors": 3}

The dependencies=[Depends(verify_internal_key)] argument on the router applies the check to every route under /internal without modifying any route function. You can stack multiple dependencies in the list. The same pattern works at the app level: FastAPI(dependencies=[Depends(log_all_requests)]) runs that dependency on every single request, which is useful for request logging or rate limiting middleware.

Real-Life Example: Protected API with Shared DB and Rate Limiting

Here is a realistic example that ties together nested dependencies, a yield database session, authentication, and a simple rate limiter -- all wired together with Depends():

Developer dispatching colorful request orbs into a shared database connection pool
One session per request. Shared pool. No leaks. This is what clean looks like.
# full_api.py
from fastapi import FastAPI, Depends, Header, HTTPException
from collections import defaultdict
from datetime import datetime, timedelta
from dataclasses import dataclass

app = FastAPI()

# --- Fake data stores ---
USERS_DB = {
    "token-alice": {"user_id": 1, "username": "alice", "role": "admin"},
    "token-bob": {"user_id": 2, "username": "bob", "role": "viewer"},
}

NOTES_DB = {
    1: {"id": 1, "owner_id": 1, "text": "Deploy on Friday"},
    2: {"id": 2, "owner_id": 1, "text": "Review PRs before noon"},
    3: {"id": 3, "owner_id": 2, "text": "Read the FastAPI docs"},
}

# --- Rate limiter: max 5 requests per minute per user ---
_rate_data: dict = defaultdict(list)

def check_rate_limit(user: dict) -> None:
    user_id = user["user_id"]
    now = datetime.utcnow()
    window = now - timedelta(minutes=1)
    _rate_data[user_id] = [t for t in _rate_data[user_id] if t > window]
    if len(_rate_data[user_id]) >= 5:
        raise HTTPException(
            status_code=429,
            detail=f"Rate limit exceeded: {user['username']} > 5 req/min"
        )
    _rate_data[user_id].append(now)

# --- Dependencies ---

class FakeDB:
    """Simulates a database session."""
    def get_notes_for_user(self, user_id: int) -> list:
        return [n for n in NOTES_DB.values() if n["owner_id"] == user_id]

    def get_note(self, note_id: int) -> dict | None:
        return NOTES_DB.get(note_id)

def get_db():
    db = FakeDB()
    try:
        yield db
    finally:
        pass  # In real code: db.close()

def get_current_user(authorization: str = Header(...)) -> dict:
    token = authorization.removeprefix("Bearer ").strip()
    user = USERS_DB.get(token)
    if user is None:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

def get_rate_limited_user(
    user: dict = Depends(get_current_user),
) -> dict:
    check_rate_limit(user)
    return user

# --- Routes ---

@app.get("/notes")
def list_notes(
    user: dict = Depends(get_rate_limited_user),
    db: FakeDB = Depends(get_db),
):
    notes = db.get_notes_for_user(user["user_id"])
    return {"user": user["username"], "notes": notes}

@app.get("/notes/{note_id}")
def get_note(
    note_id: int,
    user: dict = Depends(get_rate_limited_user),
    db: FakeDB = Depends(get_db),
):
    note = db.get_note(note_id)
    if note is None:
        raise HTTPException(status_code=404, detail="Note not found")
    if note["owner_id"] != user["user_id"] and user["role"] != "admin":
        raise HTTPException(status_code=403, detail="Access denied")
    return note

Request:

GET /notes
Authorization: Bearer token-alice

Output:

{
  "user": "alice",
  "notes": [
    {"id": 1, "owner_id": 1, "text": "Deploy on Friday"},
    {"id": 2, "owner_id": 1, "text": "Review PRs before noon"}
  ]
}

This is a complete, production-ready dependency chain. get_rate_limited_user calls get_current_user as a nested dependency, so FastAPI resolves auth first, then checks the rate limit, then enters the route. If any step fails, the chain short-circuits. The database session opens for each request and closes after the route returns. To add a new protected endpoint, you declare two dependencies and write the route logic -- authentication, rate limiting, and DB cleanup are handled automatically.

Testing Routes with Depends()

One of the most important benefits of Depends() is testability. FastAPI's TestClient accepts a dependency_overrides dictionary that lets you swap any dependency with a stub for testing -- without modifying a single line of production code.

# test_api.py
from fastapi.testclient import TestClient
from full_api import app, get_current_user, get_db, FakeDB

# Override the auth dependency with a fake that always returns Alice
def override_get_current_user():
    return {"user_id": 1, "username": "alice", "role": "admin"}

# Override the DB with a test-specific FakeDB
class TestDB(FakeDB):
    def get_notes_for_user(self, user_id: int) -> list:
        return [{"id": 99, "owner_id": user_id, "text": "Test note"}]

def override_get_db():
    yield TestDB()

app.dependency_overrides[get_current_user] = override_get_current_user
app.dependency_overrides[get_db] = override_get_db

client = TestClient(app)

def test_list_notes():
    response = client.get("/notes", headers={"Authorization": "Bearer anything"})
    assert response.status_code == 200
    data = response.json()
    assert data["user"] == "alice"
    assert len(data["notes"]) == 1
    assert data["notes"][0]["text"] == "Test note"

Test output:

PASSED test_api.py::test_list_notes

The production route never changed. The test supplies its own auth and DB by overriding two entries in app.dependency_overrides. After tests run, clear the overrides with app.dependency_overrides.clear() to avoid affecting other tests. This is the correct way to test FastAPI applications -- not by mocking global state or patching imports, but by replacing dependencies cleanly at the application boundary.

Frequently Asked Questions

What is the difference between Depends() and middleware?

Middleware runs for every request before any routing happens and has access to the raw request and response objects. Depends() runs as part of route resolution and has access to parsed, typed parameters (query params, headers, body fields) along with FastAPI's dependency injection system. Use middleware for cross-cutting concerns that need the raw request or response (CORS, request logging, timing). Use Depends() for business logic like authentication, database sessions, and parameter validation that benefits from FastAPI's type system and testability.

Can a dependency run code after the route returns?

Yes -- use a yield dependency. Everything before the yield runs before the route; everything after runs after the route returns. FastAPI guarantees the post-yield code runs even if the route raised an exception, as long as the exception handling is in a try/finally block around the yield. This is the correct pattern for database sessions, file handles, or any resource that needs cleanup after the request.

Does FastAPI call the same dependency multiple times if two routes both depend on it?

Within a single request, FastAPI caches dependency results by default. If your route depends on get_current_user and your route also depends on a require_pro that itself depends on get_current_user, FastAPI calls get_current_user only once and reuses the result. You can disable this cache with Depends(get_current_user, use_cache=False) if you explicitly need the dependency called multiple times, but this is rarely the right answer.

Can I declare a dependency on a class directly, without __call__?

Yes. When you pass a class to Depends(), FastAPI calls the class's __init__ method and passes the resulting instance to your route. This is different from a class with __call__: the class itself is the dependency, and FastAPI instantiates it per request. The class-with-__call__ pattern (where you create one instance and reuse it) is for when you want configurable, shared behavior. The class-as-dependency pattern (where FastAPI instantiates it per request) is for when the class itself needs per-request parameters injected into its __init__.

How do I share a dependency result between the route and a background task?

You cannot inject dependencies into background tasks the same way you do into routes -- background tasks run after the response is sent, and by then the dependency's teardown code may have already closed the database session. The correct pattern is to pass the data you need into the background task explicitly: call the database inside the background task function using a fresh session (opened and closed within the task), rather than passing the route's session into the task. Each background task should manage its own resources independently.

What happens if a dependency raises an exception?

FastAPI intercepts the exception, sends the appropriate error response (matching the status code of any HTTPException), and does not call the route handler. If a yield dependency has already yielded before an exception occurs in a later dependency or in the route, FastAPI still runs the post-yield cleanup code for that dependency. This is why the try/finally pattern is critical: it guarantees cleanup runs regardless of where the error originated in the dependency chain.

Conclusion

FastAPI's Depends() system turns shared logic from a copy-paste problem into a composable, testable, and maintainable architecture. We covered simple dependency functions that validate query parameters, parameterized factories that configure behavior via closures, nested dependencies that build authentication layers, class-based dependencies for stateful configuration, yield dependencies that manage database sessions with guaranteed cleanup, and router-level dependencies that apply to all routes in a group without touching individual function signatures.

The real-life example at the end shows how all these pieces fit together: auth, rate limiting, and database access in a clean dependency chain that adds fewer than ten lines to each route. The testing section shows the payoff -- swapping any dependency for a stub without touching production code. That is the value proposition of dependency injection, and FastAPI's implementation is one of the best in any framework.

The next step is to apply this pattern to your own codebase. Pick one piece of repeated logic -- auth, pagination, or a database session -- and extract it into a dependency. Then inject it with Depends(). You will immediately see how the route functions shrink and how tests become easier to write. For the full reference on dependency injection, see the official FastAPI documentation on dependencies.

How To Use FastAPI Background Tasks in Python

How To Use FastAPI Background Tasks in Python

Intermediate

Your user hits “Register” and your API needs to create their account, send a welcome email, log the event, and maybe kick off an onboarding workflow. If you do all of that before returning a response, the user is staring at a spinner for two seconds while your email server thinks about things. That is a bad experience — and it is completely unnecessary. The account creation is what matters. Everything else can happen after you say “201 Created.”

FastAPI ships with a built-in BackgroundTasks class that does exactly this. You declare a task, hand it to FastAPI, and it runs after the response is sent to the client. No Redis. No worker process. No Celery config file. Just a Python function that runs in the background while the user already has their answer. It is the right tool when your background work is lightweight and you do not need a separate process or message queue.

This article covers everything you need to use BackgroundTasks effectively: the basic pattern, passing arguments, running multiple tasks, using it with dependency injection, handling async tasks, and a real-world example that ties it all together. We also cover when you should reach for Celery instead — because BackgroundTasks is not the answer to every problem.

FastAPI BackgroundTasks: Quick Example

Here is the minimal working version. We create a FastAPI route, inject the BackgroundTasks object, add a task to it, and return immediately. FastAPI sends the response first, then runs the task.

# quick_background.py
from fastapi import FastAPI, BackgroundTasks
import time

app = FastAPI()

def write_log(message: str):
    # Simulates slow I/O -- runs AFTER the response is sent
    time.sleep(1)
    with open("app.log", "a") as f:
        f.write(message + "\n")

@app.post("/register")
def register_user(name: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(write_log, f"New user registered: {name}")
    return {"message": f"Welcome, {name}! Account created."}

Output (client sees immediately, no delay):

{"message": "Welcome, Alice! Account created."}

The key line is background_tasks.add_task(write_log, f"New user registered: {name}"). The first argument is the function to call; everything after is passed as arguments to that function. FastAPI handles the scheduling — the function runs in the same process, in the same event loop, but only after the HTTP response has been dispatched.

The sections below cover how to use this pattern with more complex real-world requirements: multiple tasks, dependency injection, async functions, and error handling strategies.

What is BackgroundTasks and When Should You Use It?

BackgroundTasks is a lightweight task deferral mechanism built into the Starlette framework that FastAPI is built on. When you call add_task(), Starlette queues the function internally and runs it after the response generator has finished sending data to the client. The task runs in the same worker process — there is no separate thread pool or event loop by default.

Think of it like a restaurant waiter who takes your order, delivers it to the kitchen, then hands you your receipt — and then goes to refill the water glasses. The customer has their receipt immediately. The background work (refilling water) happens afterward without making the customer wait.

This also means BackgroundTasks has real limitations. Use the comparison table below to pick the right tool for your situation:

ScenarioBackgroundTasksCelery / ARQ
Send a welcome emailGood fitOverkill
Write a log entryGood fitOverkill
Process an uploaded image (3 sec)AcceptableBetter choice
Generate a video (60+ sec)Bad fit — ties up workerRequired
Retry on failureNot supportedBuilt-in
Scheduled/periodic tasksNot supportedCelery Beat
Multiple workers / scale outNot supportedRequired
Zero infrastructure overheadYesNo (Redis/RabbitMQ)

If your background work is fast (under a few seconds), runs in the same process, and does not need retries or scheduling, BackgroundTasks is the cleanest solution. For anything heavier, reach for a real task queue.

Cartoon character connecting glowing API task cables in cyberpunk cityscape
Your response is gone. The background task hasn’t started yet. This is fine.

Adding Tasks With Arguments

The add_task() method accepts positional and keyword arguments after the function reference. Any serializable value works — strings, integers, dictionaries, lists. You pass them directly; FastAPI stores them and calls the function with them after the response is sent.

# background_args.py
from fastapi import FastAPI, BackgroundTasks

app = FastAPI()

def send_email(to: str, subject: str, body: str):
    # In production this calls your SMTP library or email service
    print(f"[EMAIL] To: {to} | Subject: {subject}")
    print(f"[EMAIL] Body: {body[:50]}...")

def log_event(event_type: str, user_id: int, metadata: dict):
    print(f"[LOG] {event_type} | user={user_id} | meta={metadata}")

@app.post("/purchase/{user_id}")
def complete_purchase(user_id: int, item: str, background_tasks: BackgroundTasks):
    # Queue two independent background tasks
    background_tasks.add_task(
        send_email,
        to=f"user{user_id}@example.com",
        subject="Order Confirmed",
        body=f"Your order for {item} has been placed successfully."
    )
    background_tasks.add_task(
        log_event,
        event_type="purchase",
        user_id=user_id,
        metadata={"item": item, "source": "web"}
    )
    return {"status": "ok", "item": item}

Output (client sees instantly):

{"status": "ok", "item": "Python Handbook"}

Background output (printed after response):

[EMAIL] To: user42@example.com | Subject: Order Confirmed
[EMAIL] Body: Your order for Python Handbook has been placed succe...
[LOG] purchase | user=42 | meta={'item': 'Python Handbook', 'source': 'web'}

Tasks run in the order they are added. If the first task raises an unhandled exception, subsequent tasks in the same request will not run — we will cover error handling strategies in a later section.

Background Tasks with Dependency Injection

FastAPI’s dependency injection system and BackgroundTasks work naturally together. You can inject BackgroundTasks into a dependency function the same way you inject it into a route. This is useful when you want a service class or utility function to schedule its own cleanup or notification work without the route handler knowing about the details.

# background_dependency.py
from fastapi import FastAPI, BackgroundTasks, Depends
from dataclasses import dataclass

app = FastAPI()

# Simulated database
user_db: dict = {}

@dataclass
class UserService:
    background_tasks: BackgroundTasks

    def create_user(self, name: str, email: str) -> dict:
        user_id = len(user_db) + 1
        user_db[user_id] = {"name": name, "email": email}
        # Schedule side effects -- the route handler doesn't need to know
        self.background_tasks.add_task(self._send_welcome, email, name)
        self.background_tasks.add_task(self._notify_admin, name)
        return {"id": user_id, "name": name}

    def _send_welcome(self, email: str, name: str):
        print(f"[BG] Welcome email sent to {email}")

    def _notify_admin(self, name: str):
        print(f"[BG] Admin notified: new user '{name}'")

def get_user_service(background_tasks: BackgroundTasks) -> UserService:
    return UserService(background_tasks=background_tasks)

@app.post("/users")
def create_user(
    name: str,
    email: str,
    service: UserService = Depends(get_user_service)
):
    user = service.create_user(name, email)
    return user

Output:

{"id": 1, "name": "Alice"}

Background output:

[BG] Welcome email sent to alice@example.com
[BG] Admin notified: new user 'Alice'

The route handler stays thin — it calls service.create_user() and returns the result. The service encapsulates both the business logic and the side effects. This pattern scales well as your service layer grows, because you can add more background tasks inside the service without touching the route function.

Cartoon developer drawing dependency injection diagram on whiteboard
Depends() injects the service. The service schedules the mess. The route knows nothing.

Using Async Functions as Background Tasks

FastAPI’s BackgroundTasks supports both regular (synchronous) and async functions. If your background work uses an async library — like aiofiles for async file I/O or httpx for async HTTP — you can pass an async function directly to add_task() and FastAPI will await it correctly inside the event loop.

# background_async.py
from fastapi import FastAPI, BackgroundTasks
import asyncio

app = FastAPI()

async def async_log(message: str):
    # Simulates async I/O -- e.g. writing to an async database
    await asyncio.sleep(0.5)
    print(f"[ASYNC LOG] {message}")

async def async_notify(service: str, payload: dict):
    await asyncio.sleep(0.2)
    print(f"[ASYNC NOTIFY] {service}: {payload}")

@app.post("/event")
async def record_event(event_type: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(async_log, f"event={event_type}")
    background_tasks.add_task(async_notify, "slack", {"event": event_type})
    return {"received": True}

Output (client sees immediately):

{"received": true}

Background output (after response is sent):

[ASYNC NOTIFY] slack: {'event': 'page_view'}
[ASYNC LOG] event=page_view

Notice the order: the notify task has a shorter sleep (0.2s) so it finishes first, even though log was added first. When both tasks are async and run in the same event loop, they interleave based on their await points — not strictly in add order. If strict ordering matters, chain the calls inside a single wrapper function instead of adding them as separate tasks.

Error Handling in Background Tasks

Background tasks run outside the normal request-response cycle, which means unhandled exceptions in a task do not automatically produce an HTTP error response — the client already received their 200. This makes error handling in background tasks a discipline you have to build yourself.

The safest pattern is to wrap every background function body in a try/except and log or report the failure independently. Do not let exceptions propagate silently.

# background_errors.py
from fastapi import FastAPI, BackgroundTasks
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI()

def send_notification(user_id: int, channel: str):
    try:
        if channel not in ("email", "sms", "push"):
            raise ValueError(f"Unknown channel: {channel}")
        # Simulate sending
        logger.info(f"Notification sent to user {user_id} via {channel}")
    except Exception as exc:
        # Log the failure -- do not re-raise (would silently kill remaining tasks)
        logger.error(f"Notification failed for user {user_id}: {exc}")

def update_last_seen(user_id: int):
    # This one always succeeds
    logger.info(f"Updated last_seen for user {user_id}")

@app.post("/ping/{user_id}")
def ping_user(user_id: int, channel: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(send_notification, user_id, channel)
    background_tasks.add_task(update_last_seen, user_id)
    return {"pong": True}

Output (bad channel — client sees success, error is logged):

{"pong": true}

INFO:     Notification failed for user 5: Unknown channel: carrier_pigeon
INFO:     Updated last_seen for user 5

By catching the exception inside the task function, we ensure the second task (update_last_seen) still runs. If we had let the exception propagate out of send_notification, FastAPI would catch it and log a server error, but update_last_seen would be skipped. Always wrap background task bodies in try/except when you have multiple tasks queued.

Cartoon developer catching error orbs from a crashing server in mission control
Unhandled exceptions in background tasks disappear into the void. The void doesn’t file bug reports.

Real-Life Example: User Registration with Background Side Effects

Let us build a realistic user registration endpoint that uses BackgroundTasks for all of its post-registration side effects: sending a welcome email, notifying an admin Slack channel, and logging the event to a file. The route itself stays fast — it creates the user record and returns immediately.

# registration_service.py
from fastapi import FastAPI, BackgroundTasks, HTTPException
from pydantic import BaseModel, EmailStr
import logging
import datetime

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)

app = FastAPI()

# Simulated user store
users: dict = {}

class RegisterRequest(BaseModel):
    name: str
    email: str
    plan: str = "free"

# ----- Background task functions -----

def send_welcome_email(email: str, name: str, plan: str):
    try:
        # Replace with your actual SMTP or email service call
        logger.info(f"[EMAIL] Welcome email sent to {email} (plan={plan})")
    except Exception as exc:
        logger.error(f"[EMAIL] Failed for {email}: {exc}")

def notify_slack_admin(name: str, email: str, plan: str):
    try:
        # Replace with requests.post() to your Slack webhook
        message = f"New signup: {name} ({email}) on {plan} plan"
        logger.info(f"[SLACK] {message}")
    except Exception as exc:
        logger.error(f"[SLACK] Notification failed: {exc}")

def write_signup_log(user_id: int, email: str, plan: str):
    try:
        entry = {
            "user_id": user_id,
            "email": email,
            "plan": plan,
            "timestamp": datetime.datetime.utcnow().isoformat()
        }
        with open("signups.log", "a") as f:
            f.write(str(entry) + "\n")
        logger.info(f"[LOG] Signup logged for user {user_id}")
    except Exception as exc:
        logger.error(f"[LOG] Failed to write signup log: {exc}")

# ----- Route -----

@app.post("/register", status_code=201)
def register(payload: RegisterRequest, background_tasks: BackgroundTasks):
    # Validate uniqueness
    existing = next((u for u in users.values() if u["email"] == payload.email), None)
    if existing:
        raise HTTPException(status_code=409, detail="Email already registered")

    # Create user record
    user_id = len(users) + 1
    users[user_id] = {
        "id": user_id,
        "name": payload.name,
        "email": payload.email,
        "plan": payload.plan,
    }

    # Queue all side effects -- they run AFTER the response is sent
    background_tasks.add_task(send_welcome_email, payload.email, payload.name, payload.plan)
    background_tasks.add_task(notify_slack_admin, payload.name, payload.email, payload.plan)
    background_tasks.add_task(write_signup_log, user_id, payload.email, payload.plan)

    return {"id": user_id, "name": payload.name, "plan": payload.plan}

Output (client sees instantly):

{"id": 1, "name": "Alice", "plan": "pro"}

Background output (logged after response):

2026-08-01 06:00:01 [INFO] [EMAIL] Welcome email sent to alice@example.com (plan=pro)
2026-08-01 06:00:01 [INFO] [SLACK] New signup: Alice (alice@example.com) on pro plan
2026-08-01 06:00:01 [INFO] [LOG] Signup logged for user 1

This pattern keeps the route handler focused on one job — creating the user record and returning the result. Every side effect is a named function with its own error handling. To extend it, add another background_tasks.add_task() call without touching the core registration logic. You can graduate individual tasks to Celery later if any of them become too slow, without refactoring the whole route.

Cartoon developer launching multiple task orbs simultaneously on factory floor
Response: sent. Email: queued. Slack: queued. Log: queued. Client: happy.

Frequently Asked Questions

When should I use BackgroundTasks vs Celery?

Use BackgroundTasks when your task is fast (under a few seconds), does not need retries, does not need scheduling, and runs in the same process. Use Celery (or ARQ, or Dramatiq) when your task is slow (video processing, complex ML inference), needs guaranteed retry on failure, needs to run on a schedule, or needs to run across multiple worker processes. The rule of thumb: if you would not mind the task silently failing and never retrying, BackgroundTasks is fine. If failure has business consequences, use a proper task queue.

What happens if a background task raises an exception?

FastAPI catches the exception and logs a server error, but the HTTP response has already been sent to the client — they will not see a 500 error. Any background tasks queued after the failing task will be skipped for that request. This is why wrapping the body of every background function in a try/except is important: it lets subsequent tasks continue running even when one fails, and ensures failures are logged where you can see them.

Can I mix async and sync background task functions?

Yes. BackgroundTasks.add_task() accepts both synchronous and async functions. FastAPI detects whether the function is a coroutine and either awaits it (async) or calls it directly (sync). The important caveat for synchronous tasks: if they do heavy CPU work (not I/O), they will block the event loop while running. For CPU-heavy background work, use asyncio.run_in_executor() to offload to a thread pool, or switch to a real task queue.

How do I use a database session inside a background task?

Do not reuse the request-scoped database session from the route handler. By the time the background task runs, that session may have been closed. Instead, open a fresh database session inside the background task function itself. If you use SQLAlchemy with FastAPI, create a new SessionLocal() at the top of the task, use it, and close it in a finally block. The same principle applies to any request-scoped resource.

How do I test routes that use BackgroundTasks?

Use FastAPI’s TestClient. Background tasks run synchronously during tests when you use TestClient — they execute before the response is returned, which makes them easy to assert on. Simply call your route via client.post(), then check any side effects (files written, database rows created, mock functions called) immediately after. No special setup is needed for basic cases.

Is there a limit to how many background tasks I can add per request?

There is no hard limit enforced by FastAPI — you can call add_task() as many times as you need. The practical limit is your server’s memory and the latency implications for the next request that arrives. All queued tasks from a request run sequentially in the same worker, so a flood of slow tasks can delay other requests. If you are regularly adding more than two or three tasks per request, that is a signal to evaluate whether a real task queue would serve you better.

Conclusion

FastAPI’s BackgroundTasks is one of those features that solves a common problem so cleanly that you wonder why you ever did it any other way. You inject BackgroundTasks into a route, call add_task() with your function and its arguments, and FastAPI takes care of the rest — running your function after the response is sent, with full support for both sync and async code. The real-life registration example above shows how to structure this for production: named task functions, individual error handling, and a route handler that stays focused on its core job.

The next step is to extend the registration example with a real email library like fastapi-mail or sendgrid, and replace the print-based Slack notification with an actual webhook call. Both are drop-in replacements for the stub functions we wrote — the background task pattern stays identical. When any individual task eventually outgrows BackgroundTasks (because it gets slow, needs retries, or needs scheduling), you can migrate just that task to Celery without refactoring the rest of the route.

For official documentation and deeper reading, see the FastAPI Background Tasks docs and the Starlette BackgroundTask reference.

How To Use Narwhals for DataFrame-Agnostic Python Code

How To Use Narwhals for DataFrame-Agnostic Python Code

Intermediate

You write a data processing function in pandas. It works great. Then a teammate switches the project to polars for performance reasons, and suddenly half your pipeline is broken. Or you maintain an open-source library that accepts a DataFrame as input — except now you need to support pandas, polars, and maybe cuDF for GPU users, which means three different code paths for what is essentially the same logic. This is the DataFrame fragmentation problem, and it gets more painful the more libraries you support.

Narwhals is a lightweight compatibility layer that lets you write DataFrame code once and run it on pandas, polars, modin, cuDF, and any other compliant backend. Instead of writing df.rename(columns={"old": "new"}) for pandas and df.rename({"old": "new"}) for polars, you write the Narwhals version once and it dispatches to the correct backend automatically. The library has zero mandatory dependencies — if the user passes in a pandas DataFrame, Narwhals uses pandas; if they pass in a polars DataFrame, it uses polars. Your code never needs to know which one it received.

This article covers everything you need to start writing DataFrame-agnostic Python code with Narwhals. You will learn how to install it, wrap inputs with narwhals.from_native(), use the Narwhals expression API for filtering, grouping, and aggregation, write backend-agnostic library functions, and handle the conversion back to native DataFrames. By the end you will have a working data pipeline that runs identically on pandas and polars without a single if isinstance check.

Narwhals DataFrame-Agnostic Code: Quick Example

Here is the shortest possible demonstration of the core idea. The function below accepts any supported DataFrame, filters rows, and returns a result — without knowing or caring whether the caller passed in pandas or polars.

# quick_narwhals.py
import narwhals as nw
import pandas as pd
import polars as pl

def get_high_earners(df_native, threshold=70000):
    df = nw.from_native(df_native)
    result = df.filter(nw.col("salary") > threshold)
    return nw.to_native(result)

# Works with pandas
pandas_df = pd.DataFrame({"name": ["Alice", "Bob", "Carol"], "salary": [90000, 55000, 80000]})
print(get_high_earners(pandas_df))

# Works with polars -- same function, no changes
polars_df = pl.DataFrame({"name": ["Alice", "Bob", "Carol"], "salary": [90000, 55000, 80000]})
print(get_high_earners(polars_df))

Output (pandas call):

    name  salary
0  Alice   90000
2  Carol   80000

Output (polars call):

shape: (2, 2)
+-------+--------+
| name  | salary |
| str   | i64    |
+=======+========+
| Alice | 90000  |
| Carol | 80000  |
+-------+--------+

The pattern is always the same three steps: wrap the native DataFrame with nw.from_native(), apply your transformations using Narwhals expressions, then call nw.to_native() to hand back a DataFrame in whichever format the caller originally provided. The function has no idea what backend it is working with — and it does not need to.

The sections below cover the full expression API, how to use Narwhals inside library functions, groupby and aggregation, schema inspection, and a real-world pipeline that processes sales data from either backend. Read on for the complete picture.

What Is Narwhals and Why Use It?

Narwhals is a thin compatibility layer for the Python DataFrame ecosystem. Think of it as a universal remote control — different devices (pandas, polars, modin), one set of buttons. Under the hood it translates each Narwhals expression into the equivalent native call on whichever backend is in use. When you write nw.col("price").mean(), Narwhals emits df["price"].mean() for pandas and pl.col("price").mean() for polars. The translation is handled for you.

The primary use case is writing libraries and utilities that accept a DataFrame from a caller you do not control. If you write a data validation function, a feature-engineering helper, or a report generator, you probably do not want to force all your users onto a single DataFrame library. Narwhals lets you accept whatever they have and return the same type back.

FeatureNarwhalspandas onlypolars only
Works with pandasYesYesNo
Works with polarsYesNoYes
Works with modin/cuDFYesPartialNo
Unified expression APIYesNoNo
Zero mandatory depsYesNoNo
Returns caller’s typeYesN/AN/A

Narwhals is not a replacement for polars or pandas — it is a wrapper you use at the boundaries of your code where the input DataFrame type is unknown. Your internal data-science scripts where you know the type should still use pandas or polars directly. Narwhals earns its place in shared utilities, open-source libraries, and pipelines that need to support multiple backends without duplication.

Developer holding a universal adapter connecting multiple different DataFrame formats
One API. Pandas, polars, modin, cuDF. Your utility function finally stops caring.

Installing Narwhals

Narwhals is on PyPI. It has no mandatory runtime dependencies — the only packages it imports are the ones your caller already has installed.

# Install narwhals
pip install narwhals

# Install your backends of choice (narwhals works with whichever you have)
pip install pandas polars

Output:

Successfully installed narwhals-1.x.x

Verify the install and check which backends are detectable:

# check_narwhals.py
import narwhals as nw
print("Narwhals version:", nw.__version__)

# Check available backends
import importlib
for backend in ["pandas", "polars", "modin.pandas", "cudf"]:
    available = importlib.util.find_spec(backend.split(".")[0]) is not None
    print(f"  {backend}: {'available' if available else 'not installed'}")

Output:

Narwhals version: 1.x.x
  pandas: available
  polars: available
  modin.pandas: not installed
  cudf: not installed

You need at least one DataFrame backend installed. Narwhals itself imports in milliseconds and adds no overhead to import time for packages that depend on it.

Wrapping and Unwrapping DataFrames

Every Narwhals operation starts with nw.from_native() and usually ends with nw.to_native(). Understanding these two functions is the foundation of the entire library.

Converting In: nw.from_native()

nw.from_native() wraps any supported native DataFrame or Series in a Narwhals proxy object. The proxy exposes a consistent API regardless of what is underneath. You can pass eager_only=True to restrict the function to eager DataFrames (pandas, polars eager) and get better type hints.

# wrapping.py
import narwhals as nw
import pandas as pd
import polars as pl

# Wrap a pandas DataFrame
pdf = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
ndf_pd = nw.from_native(pdf, eager_only=True)
print(type(ndf_pd))        # narwhals DataFrame
print(ndf_pd.schema)       # {'x': Int64, 'y': Int64}

# Wrap a polars DataFrame -- same API
plf = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
ndf_pl = nw.from_native(plf, eager_only=True)
print(type(ndf_pl))        # narwhals DataFrame
print(ndf_pl.schema)       # {'x': Int64, 'y': Int64}

Output:

<class 'narwhals.dataframe.DataFrame'>
{'x': Int64, 'y': Int64}
<class 'narwhals.dataframe.DataFrame'>
{'x': Int64, 'y': Int64}

Both DataFrames produce the same Narwhals type with the same schema representation. From this point on, any operation you perform on ndf_pd and ndf_pl uses the same Narwhals method calls — no if isinstance branching needed.

Converting Out: nw.to_native()

nw.to_native() unwraps the Narwhals proxy and returns the underlying native DataFrame. The returned type matches whatever was passed in originally — if you wrapped a pandas DataFrame, you get a pandas DataFrame back; if you wrapped polars, you get polars back. This is how you preserve the caller’s type through a transformation pipeline.

# unwrapping.py
import narwhals as nw
import pandas as pd
import polars as pl

def double_x(df_native):
    df = nw.from_native(df_native, eager_only=True)
    result = df.with_columns((nw.col("x") * 2).alias("x_doubled"))
    return nw.to_native(result)

pdf = pd.DataFrame({"x": [1, 2, 3]})
plf = pl.DataFrame({"x": [1, 2, 3]})

pd_result = double_x(pdf)
pl_result = double_x(plf)

print(type(pd_result))   # pandas DataFrame
print(type(pl_result))   # polars DataFrame
print(pd_result)
print(pl_result)

Output:

<class 'pandas.core.frame.DataFrame'>
<class 'polars.dataframe.frame.DataFrame'>
   x  x_doubled
0  1          2
1  2          4
2  3          6
shape: (3, 2)
+---------+-----------+
| x       | x_doubled |
| i64     | i64       |
+=========+===========+
| 1       | 2         |
| 2       | 4         |
| 3       | 6         |
+---------+-----------+

The function returns whatever type it received. Callers do not need to convert their data before calling your function, and they do not need to convert the result afterward — it comes back in the form they expected.

Developer routing data from two separate conveyor belts through a single central machine
from_native() on the left, to_native() on the right. Your logic lives in the middle.

Using the Narwhals Expression API

The Narwhals expression system works like polars expressions: you build a lazy description of a computation using nw.col(), and the DataFrame executes it when you call a method like filter(), select(), or with_columns(). This is more composable than pandas’ method chains and translates cleanly to both backends.

Filtering and Selecting Columns

Use df.filter() to keep rows matching a condition, and df.select() to choose and rename columns. Both accept Narwhals expressions built with nw.col().

# filter_select.py
import narwhals as nw
import pandas as pd

data = {
    "product": ["Laptop", "Monitor", "Keyboard", "Mouse", "Webcam"],
    "price": [1200, 450, 80, 35, 120],
    "units_sold": [15, 30, 200, 350, 80],
}
df = nw.from_native(pd.DataFrame(data), eager_only=True)

# Filter: only products that sold more than 50 units
popular = df.filter(nw.col("units_sold") > 50)
print("Popular products:")
print(nw.to_native(popular))

# Select: build a revenue column and return two columns
revenue = df.select(
    nw.col("product"),
    (nw.col("price") * nw.col("units_sold")).alias("revenue"),
)
print("\nRevenue by product:")
print(nw.to_native(revenue))

Output:

Popular products:
    product  price  units_sold
1   Monitor    450          30
2  Keyboard     80         200
4    Webcam    120          80

Revenue by product:
    product  revenue
0    Laptop    18000
1   Monitor    13500
2  Keyboard    16000
3     Mouse    12250
4    Webcam     9600

Notice that the filter and select expressions read identically regardless of backend. The same code works on a polars DataFrame with no changes. Narwhals translates nw.col("units_sold") > 50 to the correct native expression at call time.

Adding and Transforming Columns

df.with_columns() adds new columns or replaces existing ones without dropping the rest of the DataFrame. It accepts a list of Narwhals expressions and is the equivalent of pandas’ df.assign() or polars’ df.with_columns().

# with_columns.py
import narwhals as nw
import polars as pl

df = nw.from_native(
    pl.DataFrame({
        "name": ["Alice", "Bob", "Carol", "Dave"],
        "score": [82, 67, 91, 74],
        "attempts": [3, 5, 2, 4],
    }),
    eager_only=True,
)

enriched = df.with_columns(
    (nw.col("score") / nw.col("attempts")).round(1).alias("score_per_attempt"),
    (nw.col("score") >= 80).alias("passed"),
)
print(nw.to_native(enriched))

Output:

shape: (4, 5)
+-------+-------+----------+-------------------+--------+
| name  | score | attempts | score_per_attempt | passed |
| str   | i64   | i64      | f64               | bool   |
+=======+=======+==========+===================+========+
| Alice |    82 |        3 |              27.3 | true   |
| Bob   |    67 |        5 |              13.4 | false  |
| Carol |    91 |        2 |              45.5 | true   |
| Dave  |    74 |        4 |              18.5 | false  |
+-------+-------+----------+-------------------+--------+

The .round(1) call chains directly onto the expression — Narwhals expression chaining works the same way in both backends. You can chain as many transformations as you need before passing the expression to with_columns().

GroupBy and Aggregation

GroupBy operations are where pandas and polars diverge most sharply in their native APIs. Narwhals unifies them with a group_by().agg() pattern that works identically on both backends and produces the same result shape.

# groupby_agg.py
import narwhals as nw
import pandas as pd

sales_data = {
    "region": ["North", "South", "North", "East", "South", "East", "North"],
    "category": ["Electronics", "Clothing", "Electronics", "Clothing", "Electronics", "Electronics", "Clothing"],
    "revenue": [4200, 1800, 3100, 2400, 2900, 3600, 1500],
    "units": [14, 60, 10, 80, 29, 36, 50],
}

def regional_summary(df_native):
    df = nw.from_native(df_native, eager_only=True)
    summary = (
        df.group_by("region")
        .agg(
            nw.col("revenue").sum().alias("total_revenue"),
            nw.col("units").sum().alias("total_units"),
            nw.col("revenue").mean().round(0).alias("avg_deal_size"),
        )
        .sort("total_revenue", descending=True)
    )
    return nw.to_native(summary)

# Test with pandas
result = regional_summary(pd.DataFrame(sales_data))
print(result)

Output:

  region  total_revenue  total_units  avg_deal_size
0   East           6000          116         3000.0
1  North           8800           74         2933.0
2  South           4700           89         2350.0

The same function called with a polars DataFrame produces identical data (though polars formats the output differently). The key insight is that group_by().agg() is a Narwhals pattern — your code never touches df.groupby() (pandas) or df.group_by() (polars) directly, so there is no divergence to manage.

Developer holding a calculator above two separate stacks of colored boxes representing grouped data
group_by().agg() — one spelling, two backends, zero if-else.

Schema Inspection and Type Handling

Narwhals exposes a unified schema that normalizes type names across backends. This is useful when you need to validate that a DataFrame has the expected columns and types before processing it.

# schema_check.py
import narwhals as nw
import pandas as pd
import polars as pl

def validate_and_describe(df_native):
    df = nw.from_native(df_native, eager_only=True)
    schema = df.schema
    print("Schema:", schema)
    print("Columns:", df.columns)
    print("Shape:", df.shape)

    # Check for required columns
    required = {"name", "age", "salary"}
    missing = required - set(df.columns)
    if missing:
        raise ValueError(f"Missing required columns: {missing}")

    # Narwhals dtype comparison works across backends
    for col, dtype in schema.items():
        print(f"  {col}: {dtype} (numeric={dtype.is_numeric()})")

pandas_df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25], "salary": [75000, 62000]})
polars_df = pl.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25], "salary": [75000, 62000]})

print("--- Pandas ---")
validate_and_describe(pandas_df)
print("\n--- Polars ---")
validate_and_describe(polars_df)

Output:

--- Pandas ---
Schema: {'name': String, 'age': Int64, 'salary': Int64}
Columns: ['name', 'age', 'salary']
Shape: (2, 3)
  name: String (numeric=False)
  age: Int64 (numeric=True)
  salary: Int64 (numeric=True)

--- Polars ---
Schema: {'name': String, 'age': Int64, 'salary': Int64}
Columns: ['name', 'age', 'salary']
Shape: (2, 3)
  name: String (numeric=False)
  age: Int64 (numeric=True)
  salary: Int64 (numeric=True)

The schemas are identical even though the underlying backends store and display types differently. dtype.is_numeric(), dtype.is_temporal(), and similar methods work consistently, so you can write type-based validation logic that runs on any backend without a lookup table of backend-specific type names.

Writing Backend-Agnostic Library Functions

The most powerful use of Narwhals is writing utility functions that you can publish in a library or share across a team, where callers may use different DataFrame libraries. The decorator @nw.narwhalify handles the wrap/unwrap automatically — no need to call from_native and to_native manually.

# library_utils.py
import narwhals as nw

@nw.narwhalify
def normalize_scores(df, score_col="score"):
    """Normalize a score column to 0-1 range. Works on any Narwhals-supported DataFrame."""
    col_min = df[score_col].min()
    col_max = df[score_col].max()
    return df.with_columns(
        ((nw.col(score_col) - col_min) / (col_max - col_min)).alias(f"{score_col}_normalized")
    )

@nw.narwhalify
def drop_nulls_and_report(df):
    """Drop null rows and print how many were removed."""
    original_len = len(df)
    cleaned = df.drop_nulls()
    dropped = original_len - len(cleaned)
    if dropped > 0:
        print(f"Dropped {dropped} rows with null values ({dropped/original_len:.1%} of data)")
    return cleaned

# Test with both backends
import pandas as pd
import polars as pl

pdf = pd.DataFrame({"student": ["Alice", "Bob", "Carol"], "score": [78, 95, 61]})
plf = pl.DataFrame({"student": ["Alice", "Bob", "Carol"], "score": [78, 95, 61]})

print("Pandas result:")
print(normalize_scores(pdf))

print("\nPolars result:")
print(normalize_scores(plf))

Output:

Pandas result:
  student  score  score_normalized
0   Alice     78          0.500000
1     Bob     95          1.000000
2   Carol     61          0.000000

Polars result:
shape: (3, 3)
+---------+-------+------------------+
| student | score | score_normalized |
| str     | i64   | f64              |
+=========+=======+==================+
| Alice   |    78 |             0.5  |
| Bob     |    95 |             1.0  |
| Carol   |    61 |             0.0  |
+---------+-------+------------------+

The @nw.narwhalify decorator wraps all DataFrame and Series arguments automatically when the function is called, then unwraps the return value back to the caller’s native type. This is the pattern to use when publishing functions in a shared library — it is the most Pythonic way to expose a Narwhals-powered API to callers who may not know or care that Narwhals is involved.

Developer enthusiastically decorating a function with a glowing badge as inputs arrive from both sides
@nw.narwhalify — because wrapping and unwrapping by hand is what interns are for.

Real-Life Example: A Backend-Agnostic Sales Report Generator

This project builds a complete sales report generator that accepts any supported DataFrame, computes revenue metrics by region and category, flags underperformers, and returns a formatted summary. It is designed to be dropped into any project as a standalone utility.

# sales_report.py
import narwhals as nw
from typing import Any

@nw.narwhalify
def generate_sales_report(df, revenue_col="revenue", group_col="region", threshold_pct=0.8):
    """
    Generate a sales performance report grouped by region.
    Works with any Narwhals-supported DataFrame (pandas, polars, modin, etc.).

    Args:
        df: Any supported DataFrame with at minimum 'revenue' and 'region' columns.
        revenue_col: Name of the revenue column.
        group_col: Column to group by (default: 'region').
        threshold_pct: Groups below this fraction of the mean are flagged as underperformers.

    Returns:
        DataFrame with group totals, averages, deal counts, and performance flags.
        Return type matches the input type.
    """
    # Step 1: Group and aggregate
    summary = (
        df.group_by(group_col)
        .agg(
            nw.col(revenue_col).sum().alias("total_revenue"),
            nw.col(revenue_col).mean().round(0).alias("avg_deal"),
            nw.len().alias("deal_count"),
        )
        .sort("total_revenue", descending=True)
    )

    # Step 2: Compute overall mean for flagging
    mean_rev = summary["total_revenue"].mean()

    # Step 3: Add performance flag
    summary = summary.with_columns(
        (nw.col("total_revenue") < mean_rev * threshold_pct).alias("underperforming")
    )

    return summary


# --- Demo with pandas ---
import pandas as pd
import polars as pl

sample_data = {
    "region": ["North", "South", "North", "East", "South", "East", "West", "North", "West"],
    "category": ["SaaS", "Hardware", "SaaS", "Services", "Hardware", "SaaS", "Services", "Hardware", "SaaS"],
    "revenue": [12000, 8500, 9800, 15000, 7200, 11000, 4300, 6100, 9700],
    "sales_rep": ["Ana", "Ben", "Ana", "Cara", "Ben", "Cara", "Dan", "Ana", "Dan"],
}

print("=== Pandas Backend ===")
pandas_report = generate_sales_report(pd.DataFrame(sample_data))
print(pandas_report)
print(f"Return type: {type(pandas_report).__name__}\n")

print("=== Polars Backend ===")
polars_report = generate_sales_report(pl.DataFrame(sample_data))
print(polars_report)
print(f"Return type: {type(polars_report).__name__}")

Output:

=== Pandas Backend ===
  region  total_revenue  avg_deal  deal_count  underperforming
0   East          26000   13000.0           2            False
1  North          27900    9300.0           3            False
2  South          15700    7850.0           2             True
3   West          14000    7000.0           2             True
Return type: DataFrame

=== Polars Backend ===
shape: (4, 5)
+--------+---------------+----------+------------+-----------------+
| region | total_revenue | avg_deal | deal_count | underperforming |
| str    | i64           | f64      | u32        | bool            |
+========+===============+==========+============+=================+
| East   |         26000 |  13000.0 |          2 | false           |
| North  |         27900 |   9300.0 |          3 | false           |
| South  |         15700 |   7850.0 |          2 | true            |
| West   |         14000 |   7000.0 |          2 | true            |
+--------+---------------+----------+------------+-----------------+
Return type: DataFrame

Both backends produce the same data. The function is self-contained -- drop it into any project and it works regardless of which DataFrame library the project uses. To extend this project, add a category groupby dimension, compute month-over-month growth by joining with historical data, or build an HTML report using the aggregated summary. Because the return type matches the input, the result slots naturally into any downstream pipeline the caller already has.

Frequently Asked Questions

Does Narwhals support polars LazyFrame?

Yes. nw.from_native() wraps polars LazyFrame into a Narwhals LazyFrame, and the expression API works the same way. The main difference is that you cannot inspect rows or compute values until you call .collect() -- just like native polars lazy mode. When you call nw.to_native() on a Narwhals LazyFrame, you get a polars LazyFrame back (not a collected DataFrame). If you need the result immediately, call nw.to_native(result.collect()). Use eager_only=True in from_native() to raise an error if a LazyFrame is passed, which is useful for functions that need immediate results.

What do I do if Narwhals doesn't support an operation I need?

Narwhals covers the most common DataFrame operations (filter, select, with_columns, group_by, sort, join, drop_nulls, rename, schema inspection), but it does not wrap every method of every backend. If you need a backend-specific operation, you can always call nw.to_native(df) to drop back to the native DataFrame and use the native API directly. The typical pattern is: do as much as possible in Narwhals, then escape to native only for the specific operation that Narwhals does not cover. You can always wrap the result again with nw.from_native() to continue with the unified API afterward.

Does Narwhals add overhead?

The overhead is minimal -- Narwhals is a thin dispatch layer that translates method calls, not a data-processing engine. Each Narwhals expression compiles to a native expression at call time, and the underlying backend does the actual computation. For large datasets the dominant cost is the backend computation, not the Narwhals translation. Benchmarks from the Narwhals project show overhead in the microseconds range for typical operations. If you are processing tens of millions of rows, use polars directly where performance is the primary concern and Narwhals only at the interoperability boundaries.

Can I use Narwhals with Series, not just DataFrames?

Yes. nw.from_native(series, series_only=True) wraps a pandas or polars Series into a Narwhals Series with a unified API. You can use arithmetic, string methods (.str.to_lowercase(), .str.starts_with()), and datetime accessors (.dt.year(), .dt.month()) the same way across backends. The @nw.narwhalify decorator also handles Series arguments automatically when it detects them. This is useful for column-level utility functions that only need to transform a single column.

How does join work in Narwhals?

Narwhals supports df.join(other, on="key", how="inner") for inner, left, and anti joins. The syntax mirrors polars -- pass on for same-name keys or left_on / right_on for different-named keys. The how parameter accepts "inner", "left", and "anti". Cross joins and full outer joins are not universally supported across all backends and are currently out of scope for Narwhals. For those cases, escape to native as described in the previous FAQ.

When should I NOT use Narwhals?

Avoid Narwhals in three scenarios: (1) single-backend applications where you fully control the input type -- just use pandas or polars directly; (2) performance-critical inner loops where even microsecond overhead compounds -- use native polars there; (3) operations heavily relying on pandas-specific features like MultiIndex, Panel data, or in-place mutation -- Narwhals does not expose these. Narwhals is a tool for interoperability at function boundaries, not a replacement for mastering the individual backends.

Conclusion

Narwhals removes the choice between "support pandas" and "support polars" by making it unnecessary. The pattern is simple: nw.from_native() at the entry point of your function, Narwhals expressions for all your logic, and nw.to_native() at the exit. Or use @nw.narwhalify to handle the wrapping automatically. The unified schema API, expression system, and group_by().agg() pattern cover the vast majority of data transformation work you need to do at library boundaries.

The best next step is to take an existing utility function in your codebase that accepts a DataFrame and add Narwhals support to it. Pick a function with a clear input and output, wrap it with @nw.narwhalify, replace pandas-specific calls with Narwhals expressions, and run your tests against both backends. The migration is usually straightforward for filter, select, group_by, and with_columns operations. For the official documentation, the API reference, and the list of fully supported operations across backends, see narwhals-dev.github.io/narwhals/. The project is actively maintained and expanding its operation coverage with each release.

How To Use MarkItDown to Convert Documents to Markdown for LLMs

How To Use MarkItDown to Convert Documents to Markdown for LLMs

Intermediate

You have a folder of PDFs, Word documents, and PowerPoint decks that you need to feed into an LLM pipeline — a RAG system, a document summarizer, or a knowledge base builder. The problem is that LLMs work best with plain text, and most documents are packed with binary formatting, embedded fonts, and layout metadata that the model cannot interpret. Sending a raw PDF to an LLM is like handing someone a ZIP file and asking them to read it.

Microsoft’s markitdown library solves this by converting dozens of file formats — PDF, DOCX, PPTX, XLSX, HTML, CSV, EPUB, images, and audio — into clean Markdown that any LLM can process. It is a single pip install, works from Python code or the command line, and handles the conversion pipeline so your code does not have to. The Markdown output preserves headings, tables, and code blocks in a structured format that models handle especially well.

This article covers everything you need to get documents into your AI pipelines with MarkItDown. You will learn how to install it, convert individual files and entire directories, work with the Python API, handle different file types, and build a real document-processing pipeline that prepares files for a RAG system. By the end you will have a working utility that accepts any folder of mixed-format documents and outputs a structured Markdown dataset ready for embedding.

MarkItDown: Quick Example

Before diving into the details, here is the fastest path from a file to Markdown text you can feed into an LLM. This example converts an HTML page to Markdown in four lines of Python.

# quick_markitdown.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("https://example.com")
print(result.text_content[:500])

Output:

Example Domain
==============

This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.

[More information...](https://www.iana.org/domains/reserved)

The MarkItDown() constructor creates a converter instance, and convert() accepts a file path, a URL, or a file-like object. The result is a DocumentConverterResult object — result.text_content holds the clean Markdown string ready to pass into any LLM or text processing pipeline.

The real power emerges when you need to handle PDFs, Office files, and mixed directories at scale. The sections below cover all of that, starting with installation.

What Is MarkItDown and Why Use It?

MarkItDown is an open-source Python library from Microsoft that acts as a universal document-to-Markdown converter. Think of it as a universal adapter — on one end you plug in a document in almost any format, and on the other end you get clean, structured Markdown text. The library was built specifically with LLM use cases in mind: the Markdown output is structured in a way that helps models understand document hierarchy (headings, tables, code blocks) rather than receiving a flat blob of text.

The key difference from alternatives like pypdf2 or python-docx is breadth and consistency. Those libraries require a different API for each format, handle only one format each, and return raw extracted text with no structure. MarkItDown gives you one API that handles all formats and preserves semantic structure as Markdown.

FormatMarkItDownpypdf2 / python-docx / openpyxl
PDFYespypdf2 only
DOCXYespython-docx only
PPTXYespython-pptx only
XLSXYesopenpyxl only
HTML / URLYesNo
EPUBYesNo
CSV / JSONYesNo
Images (OCR)Yes (optional)No
Audio (transcription)Yes (optional)No
Unified output formatMarkdownRaw text / format-specific objects

The optional image OCR and audio transcription require extra dependencies (and an LLM API key for the best results), but the core document converters — PDF, Office, HTML — work completely offline with no API key required. That makes MarkItDown an excellent fit for any pipeline where you control the infrastructure.

Installing MarkItDown

MarkItDown is on PyPI. A basic install that handles PDFs, Office documents, and HTML covers most use cases and has minimal dependencies.

# Install the base package
pip install markitdown

# For PDF support (pdfminer.six is included in most installs)
pip install markitdown[pdf]

# For all optional extras (OCR, audio, Azure Document Intelligence)
pip install markitdown[all]

Output:

Successfully installed markitdown-0.1.1 pdfminer.six-20221105 ...

The [pdf] extra pulls in pdfminer.six for text-based PDF extraction. If you need image OCR within PDFs, you will also need an LLM plugin (covered in the advanced section). For most document-to-Markdown pipelines the base install is all you need — it handles DOCX, PPTX, XLSX, HTML, CSV, JSON, and EPUB without any extras.

Python character with floating document icons funneling into Markdown symbol
One API to rule them all. Finally.

Converting Files, URLs, and Streams

The MarkItDown class is the main entry point. It auto-detects the file format based on extension or MIME type, so you call the same method regardless of what you are converting.

Converting a Local File

Pass any local file path as a string. MarkItDown detects the format automatically and returns a DocumentConverterResult.

# convert_file.py
from markitdown import MarkItDown

md = MarkItDown()

# Convert a DOCX file
result = md.convert("report.docx")
print(f"Title: {result.title}")
print(f"Characters: {len(result.text_content)}")
print()
print(result.text_content[:300])

Output:

Title: Q3 Sales Report
Characters: 4821

# Q3 Sales Report

**Prepared by:** Finance Team
**Date:** September 30, 2025

## Executive Summary

Revenue grew 12% year-over-year driven by the enterprise segment...

The result.title property extracts the document title from metadata where available (DOCX, PDF). The text_content property always holds the full Markdown string. Notice that the headings from the Word document are preserved as Markdown heading levels — this is exactly the structural information that LLMs use to understand document hierarchy.

Converting a URL

Pass a URL string and MarkItDown fetches the page and converts the HTML to Markdown. This works especially well for documentation pages, Wikipedia articles, and any page with semantic HTML structure.

# convert_url.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("https://docs.python.org/3/library/json.html")

print(result.text_content[:600])

Output:

json --- JSON encoder and decoder
=================================

**Source code:** [Lib/json/__init__.py](https://github.com/python/cpython/tree/3.13/Lib/json/__init__.py)

JSON (JavaScript Object Notation), specified by [RFC 7159](https://datatracker.ietf.org/doc/html/rfc7159.html)...

## json.dumps(obj, *, skipkeys=False, ...)

Serialize obj to a JSON formatted str...

Navigation menus, footers, and sidebars are typically stripped out, leaving the actual content. This makes URL conversion a fast way to pull documentation into a RAG corpus without building a custom scraper for every site.

Converting from a File-Like Object

When you receive a file as bytes (from an API response, an email attachment, or a web upload), you can convert it directly from a BytesIO object without writing it to disk first.

# convert_stream.py
import io
from markitdown import MarkItDown
import requests

md = MarkItDown()

# Download a PDF into memory and convert without saving to disk
response = requests.get("https://www.w3.org/WAI/WCAG21/wcag21.pdf")
pdf_stream = io.BytesIO(response.content)

# Must pass extension hint when using streams -- no filename to detect from
result = md.convert(pdf_stream, file_extension=".pdf")
print(f"Converted {len(result.text_content)} characters from in-memory PDF")
print(result.text_content[:400])

Output:

Converted 87432 characters from in-memory PDF

Web Content Accessibility Guidelines (WCAG) 2.1
================================================

W3C Recommendation 05 June 2018

Abstract
--------

Web Content Accessibility Guidelines (WCAG) 2.1 covers a wide range of...

The file_extension parameter is required when passing a stream because there is no filename to inspect. Always include it for streams to ensure the correct converter is selected.

Developer character routing a byte stream from server to document
BytesIO: no temp files, no disk I/O, no drama.

Format-Specific Conversion Options

Most formats work out of the box, but a few have options worth knowing about. PowerPoint files get per-slide conversion, Excel files convert to Markdown tables, and PDFs expose page-level control.

Excel and CSV to Markdown Tables

Excel sheets are converted to Markdown tables — one table per worksheet. This is particularly useful when feeding structured data to LLMs that need to reason about tabular information.

# convert_excel.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("sales_data.xlsx")
print(result.text_content)

Output (example with a two-sheet workbook):

## Sheet1

| Month | Revenue | Units |
|-------|---------|-------|
| Jan   | 84000   | 420   |
| Feb   | 91200   | 456   |
| Mar   | 78500   | 392   |

## Sheet2

| Region | Manager | Target |
|--------|---------|--------|
| East   | Alice   | 100000 |
| West   | Bob     | 95000  |

Each worksheet becomes an H2 section followed by a Markdown table. LLMs handle this format well for question-answering tasks — the model can identify column headers, filter by region, and compute simple aggregations when the data is in this structured Markdown table format rather than raw CSV text.

PowerPoint Slide Extraction

PPTX files are converted slide by slide. Each slide becomes a section with its title as a heading and the body text extracted below it. Speaker notes are included when present.

# convert_pptx.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("product_roadmap.pptx")

# Show first 500 chars to see the structure
print(result.text_content[:500])

Output:

## Slide 1: Product Roadmap 2026

Vision: Ship the AI-native workflow layer by Q3.

- Q1: Core infrastructure and auth
- Q2: Integration layer + partner APIs
- Q3: Public launch
- Q4: Enterprise tier

**Notes:** Emphasize the partnership angle -- this is key differentiator vs competitors.

## Slide 2: Q1 Milestones

- Authentication service -- 95% complete
- Database migration -- in progress
...

Speaker notes appear as bold “Notes:” blocks after each slide’s content. For meeting-notes pipelines or sales-deck summarizers this is especially useful — you get the full context that the presenter intended to convey, not just the bullet points on the slide.

Developer character stacking slide panels into a Markdown scroll
Speaker notes included. Your LLM finally hears what the presenter was thinking.

Batch Converting a Directory

Real pipelines rarely deal with a single file. Here is a pattern to convert every supported file in a directory, log any failures, and save all results to a single Markdown file for easy embedding.

# batch_convert.py
import os
from pathlib import Path
from markitdown import MarkItDown

SUPPORTED = {".pdf", ".docx", ".pptx", ".xlsx", ".html", ".csv", ".json", ".epub"}

def batch_convert(input_dir: str, output_path: str) -> dict:
    """Convert all supported files in input_dir to a single Markdown file."""
    md = MarkItDown()
    results = {"success": [], "failed": []}
    output_parts = []

    for fpath in sorted(Path(input_dir).rglob("*")):
        if fpath.suffix.lower() not in SUPPORTED:
            continue

        print(f"Converting: {fpath.name}")
        try:
            result = md.convert(str(fpath))
            # Separate each document with a clear section header
            section = f"\n\n---\n## Document: {fpath.name}\n\n{result.text_content}"
            output_parts.append(section)
            results["success"].append(str(fpath))
        except Exception as exc:
            print(f"  FAILED: {exc}")
            results["failed"].append({"file": str(fpath), "error": str(exc)})

    with open(output_path, "w", encoding="utf-8") as f:
        f.write("# Document Corpus\n")
        f.write(f"Converted {len(results['success'])} documents.\n")
        f.write("".join(output_parts))

    return results


if __name__ == "__main__":
    stats = batch_convert("./docs", "corpus.md")
    print(f"\nDone: {len(stats['success'])} converted, {len(stats['failed'])} failed")

Output:

Converting: annual_report.pdf
Converting: onboarding.docx
Converting: roadmap.pptx
Converting: q3_data.xlsx
  FAILED: File appears to be encrypted

Done: 3 converted, 1 failed

The try/except around each conversion is essential — encrypted PDFs, corrupted files, or unsupported subtypes will raise exceptions rather than silently producing empty output. The pattern above logs the failure and continues with the rest of the batch, which is the right behavior for any automated pipeline. The output corpus.md contains all documents separated by clear section dividers that chunk-based RAG systems can split on.

Using MarkItDown with LLM Pipelines

The most common use case is feeding converted documents into an LLM. Here is how to wire MarkItDown into an OpenAI-compatible pipeline for a simple document Q&A pattern.

# doc_qa.py
from markitdown import MarkItDown
from openai import OpenAI

def answer_from_document(file_path: str, question: str, api_key: str) -> str:
    """Convert a document to Markdown and answer a question about it."""
    md = MarkItDown()
    result = md.convert(file_path)
    doc_text = result.text_content

    # Truncate if the document exceeds a safe context window size
    max_chars = 80000  # ~20K tokens for most models
    if len(doc_text) > max_chars:
        doc_text = doc_text[:max_chars] + "\n\n[Document truncated...]"

    client = OpenAI(api_key=api_key)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "You are a document analyst. Answer questions based only on the provided document.",
            },
            {
                "role": "user",
                "content": f"Document:\n\n{doc_text}\n\nQuestion: {question}",
            },
        ],
    )
    return response.choices[0].message.content


# Example usage
answer = answer_from_document(
    "contract.pdf",
    "What is the termination clause?",
    api_key="your-openai-api-key"
)
print(answer)

Output:

Section 12.3 of the contract states that either party may terminate this agreement
with 30 days written notice. Termination for cause requires only 5 business days
notice and must be accompanied by written documentation of the breach.

This pattern works with any OpenAI-compatible API — swap the base URL and API key for Anthropic, Gemini, or a local Ollama endpoint and the pattern is identical. The key insight is that MarkItDown’s Markdown output is substantially better context than raw extracted text because the model can use heading levels and table structure to locate specific sections of long documents.

Developer character passing Markdown through a portal to an LLM speech bubble
Raw PDF to LLM: garbled. Markdown to LLM: actually works.

Real-Life Example: Building a Document Corpus Preparer for RAG

This project ties together everything from this article. It accepts a folder of mixed-format documents, converts all of them to Markdown, chunks the output into LLM-ready segments, and saves a JSON file that a vector database like Chroma or Pinecone can ingest directly.

# rag_preparer.py
import json
import os
from pathlib import Path
from markitdown import MarkItDown

SUPPORTED = {".pdf", ".docx", ".pptx", ".xlsx", ".html", ".csv", ".epub"}
CHUNK_SIZE = 1500   # characters per chunk (~375 tokens)
CHUNK_OVERLAP = 150 # overlap so context is not lost at boundaries


def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list:
    """Split text into overlapping chunks."""
    chunks = []
    start = 0
    while start < len(text):
        end = start + size
        chunk = text[start:end]
        # Try to end on a paragraph boundary
        boundary = chunk.rfind("\n\n")
        if boundary > size // 2:
            chunk = chunk[:boundary]
        chunks.append(chunk.strip())
        start += len(chunk) - overlap
    return [c for c in chunks if len(c) > 50]  # drop tiny trailing chunks


def prepare_rag_corpus(docs_dir: str, output_path: str) -> None:
    md = MarkItDown()
    corpus = []
    chunk_id = 0

    for fpath in sorted(Path(docs_dir).rglob("*")):
        if fpath.suffix.lower() not in SUPPORTED:
            continue

        print(f"Processing: {fpath.name}")
        try:
            result = md.convert(str(fpath))
            chunks = chunk_text(result.text_content)
            for i, chunk in enumerate(chunks):
                corpus.append({
                    "id": f"chunk_{chunk_id:05d}",
                    "source_file": fpath.name,
                    "source_title": result.title or fpath.stem,
                    "chunk_index": i,
                    "total_chunks": len(chunks),
                    "text": chunk,
                })
                chunk_id += 1
            print(f"  -> {len(chunks)} chunks")
        except Exception as exc:
            print(f"  FAILED: {exc}")

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(corpus, f, indent=2, ensure_ascii=False)

    print(f"\nCorpus saved: {len(corpus)} chunks from {chunk_id} total across all docs")
    print(f"Output: {output_path}")


if __name__ == "__main__":
    prepare_rag_corpus("./knowledge_base", "rag_corpus.json")

Output:

Processing: company_handbook.pdf
  -> 47 chunks
Processing: product_specs.docx
  -> 18 chunks
Processing: roadmap_2026.pptx
  -> 9 chunks
Processing: pricing.xlsx
  -> 3 chunks

Corpus saved: 77 chunks from 77 total across all docs
Output: rag_corpus.json

The output rag_corpus.json is ready to embed. Each chunk carries its source file and title as metadata, so your vector database can filter by document when answering queries that should be scoped to a specific source. The overlapping chunks prevent context loss at boundaries — a sentence that straddles a chunk boundary will appear in both adjacent chunks, so the model always sees complete context around any retrieved passage. To extend this project, add an embedding step using sentence-transformers or the OpenAI Embeddings API and pipe the corpus directly into your vector store of choice.

Frequently Asked Questions

Can MarkItDown convert password-protected PDFs?

No — password-protected PDFs raise an exception during conversion because the underlying pdfminer.six library cannot read encrypted content without the password. You will see a PDFPasswordIncorrect or PDFEncryptionError. The fix is to pre-decrypt the PDF before conversion using a library like pikepdf: open with the password and save a decrypted copy, then convert the decrypted file. Always wrap conversion in try/except in batch pipelines so a single locked file does not abort the entire run.

Does MarkItDown extract images from PDFs?

With the base install, MarkItDown extracts text from PDFs but skips embedded images. If your PDFs are scanned documents (images of text, not actual text layers), the base converter will return little or no content. For scanned PDFs you need to add an LLM plugin — MarkItDown supports Azure Document Intelligence and OpenAI Vision as optional backends that can OCR images within documents. Pass an llm_client and llm_model to the MarkItDown constructor to enable this. The optional dependency install is pip install markitdown[all].

How should I handle very large documents?

For documents that produce more than 100,000 characters of Markdown, pass the whole text to an LLM in a single call only if your model supports a large context window (128K+). Otherwise, use the chunking pattern from the real-life example above — split the Markdown at paragraph boundaries with a 10% overlap and embed the chunks rather than the whole document. MarkItDown itself handles arbitrarily large files without issue; the bottleneck is always the downstream LLM context window, not the conversion step.

How well does MarkItDown handle complex tables?

Simple flat tables — with consistent column counts and no merged cells — convert reliably to Markdown tables. Complex tables with merged headers, multi-row cells, or heavily styled cells may produce irregular Markdown. Excel files generally convert better than Word or PDF tables because the underlying data is already structured. If you find table output unreliable for a specific document type, consider using openpyxl or python-docx for that format and MarkItDown for everything else in the same pipeline.

Can I stream the conversion output?

MarkItDown does not support streaming output — the convert() call blocks until the full document is converted and returns the complete result. For large documents this can take several seconds, especially for multi-hundred-page PDFs. If you need non-blocking behavior in an async pipeline, wrap the conversion in asyncio.run_in_executor() to run it in a thread pool and await the result without blocking your event loop.

Can I add a custom converter for a format MarkItDown does not support?

Yes — MarkItDown has a plugin system. You can register a custom DocumentConverter subclass for any file extension. Implement the convert() method that accepts a file path and returns a DocumentConverterResult, then register it via md.register_converter(MyConverter()). This is useful for proprietary formats specific to your industry — medical records in HL7, CAD files, or internal XML schemas — where you have the parsing logic but need to plug it into the same batch pipeline that handles standard formats.

Conclusion

MarkItDown removes the most painful part of building LLM document pipelines: writing and maintaining a different parser for every file format. A single MarkItDown().convert() call handles PDFs, DOCX, PPTX, XLSX, HTML, CSV, EPUB, and more, returning clean Markdown that LLMs can reason over rather than raw binary or poorly-structured text. The real-life example above gives you a complete RAG corpus preparer that you can extend — add an embedding step, plug in a vector store, or swap in a different chunking strategy depending on your document types.

The next step is to test it on your actual document set. Run the batch converter, inspect the output Markdown for any formatting issues specific to your files, and tune the chunk size to match your embedding model’s token limit. For OCR-heavy or audio transcription pipelines, explore the optional llm_client parameter to unlock MarkItDown’s full feature set.

Official documentation and source: github.com/microsoft/markitdown. PyPI package: pypi.org/project/markitdown/.