How To Use Python whenever for Correct Date and Time Handling

How To Use Python whenever for Correct Date and Time Handling

Intermediate

You write a function that schedules a meeting one hour from now. It works perfectly in testing, ships to production, and then silently double-books everyone the week after Daylight Saving Time ends because “one hour from now” meant something different than you expected. Python’s built-in datetime module lets you add timedelta(hours=1) to any datetime object — whether it carries timezone information or not — and both operations look identical until one of them misbehaves at 2am on the first Sunday of November. This is not a corner case. It is the most common class of datetime bugs in Python, and it bites experienced developers just as reliably as beginners.

whenever is a Python library that makes these bugs impossible at the type level rather than discoverable at runtime. It provides explicit, distinct types for UTC timestamps, zoned datetimes, local system time, and naive datetimes — and it refuses, at the API design level, to let you mix them accidentally. You install it with pip install whenever, and it ships an optional Rust extension for performance. It requires Python 3.10 or later.

In this article we will cover what whenever is and why it exists, how to work with its four core types (Instant, ZonedDateTime, LocalSystemDateTime, and NaiveDateTime), how to do DST-safe arithmetic, how to parse and format timestamps, how to convert between types, and how to build a practical log timestamp processor as a real-life example. By the end you will understand exactly when to reach for whenever and how to replace the riskiest parts of your datetime code with something that will not surprise you at 2am.

whenever Quick Example

Before we go deep, here is a minimal working example showing the most common use case: getting the current UTC time and converting it to a specific timezone for display.

# whenever_quick.py
from whenever import Instant, ZonedDateTime

# Get current UTC time as a type-safe Instant
now_utc = Instant.now()
print("UTC:", now_utc)

# Convert to a named timezone for display
now_sydney = now_utc.to_tz("Australia/Sydney")
print("Sydney:", now_sydney)

# Convert to another timezone
now_london = now_utc.to_tz("Europe/London")
print("London:", now_london)

# Format for display
print("Formatted:", now_sydney.format_common_iso())

Output:

UTC: 2026-07-24T02:15:30Z
Sydney: 2026-07-24T12:15:30+10:00[Australia/Sydney]
London: 2026-07-24T03:15:30+01:00[Europe/London]
Formatted: 2026-07-24T12:15:30+10:00

The key things to notice: Instant.now() gives you a UTC timestamp with no ambiguity. The Z suffix in the output signals “this is UTC, nothing else.” When you call to_tz(), the library produces a ZonedDateTime that carries both the wall-clock time and the IANA timezone name in brackets — which is what you need to do DST-safe arithmetic correctly. If you tried to add hours to this datetime later, whenever would respect the DST rules for that timezone automatically.

The deeper value shows up when you start doing arithmetic, converting between types, or passing datetimes across function boundaries — all the places where Python’s standard datetime silently accepts naive objects and produces subtly wrong answers.

What Is whenever and Why Use It?

Python’s standard datetime module is 20 years old and designed for a world where timezone-aware code was the exception rather than the rule. The result is an API that treats aware and naive datetimes as compatible types at the Python level — you can subtract a naive datetime from an aware one, compare them, and pass either to a function that expects the other. You will not get a type error until your code runs, and sometimes not even then: you will get silently wrong results instead.

whenever was written by Arie Bovenberg to enforce what the Python type system cannot: a hard distinction between four fundamentally different concepts that should never be silently interchangeable. Once you understand those four types, the library clicks into place:

TypeWhat It RepresentsWhen To Use It
InstantA UTC-based point in time with no wall-clock displayLogging, event timestamps, storing datetimes in a database
ZonedDateTimeA wall-clock time in a named IANA timezoneScheduling, calendar events, displaying time to users in their timezone
LocalSystemDateTimeA datetime in the current system’s local timezoneScripts that run in a known environment and display local time
NaiveDateTimeA datetime with no timezone at allHistorical data where timezone is irrelevant, or reading input before associating a zone

The library never lets you accidentally mix these. If you have a NaiveDateTime and try to subtract an Instant from it, you get a type error immediately — not a subtle wrong answer three timezones later. This is the core value proposition: bugs that used to live in production logs now live in your editor’s type-checking pass.

Python whenever library - understanding UTC as the single source of truth for datetime
One source of truth. Zero 2am pages.

Installing whenever

Installing whenever is a single command. The library ships a pure-Python implementation that works everywhere, and an optional Rust extension (whenever[rust]) that makes the hot paths significantly faster if your environment can compile it.

# install_whenever.sh

# Pure Python -- works everywhere, no compiler needed
pip install whenever

# With Rust extension for better performance (requires a Rust toolchain)
pip install whenever[rust]

# Verify the installation
python3 -c "import whenever; print(whenever.__version__)"

Output:

0.7.0

If you are adding whenever to a project, put it in your requirements.txt or pyproject.toml like any other dependency. The library supports Python 3.10 and later. For most production use cases the pure-Python version is fast enough; the Rust extension is worth enabling if you are processing hundreds of thousands of timestamps in tight loops.

Working with UTC and Timestamps Using Instant

The most common datetime operation in backend code is “record when this event happened.” Instant is the right type for that job. It always represents UTC and cannot be confused with local time. Use it for database storage, log entries, API responses, and any time you need a single unambiguous point in time.

# using_instant.py
from whenever import Instant
from datetime import timedelta

# Current time in UTC
now = Instant.now()
print("Now:", now)                     # e.g. 2026-07-24T02:15:30Z

# Create an Instant from an ISO 8601 string
event_time = Instant.parse_common_iso("2026-07-24T00:00:00Z")
print("Event:", event_time)

# Arithmetic -- add 2 hours
two_hours_later = now.add(hours=2)
print("In 2 hours:", two_hours_later)

# Subtract to get a duration
from whenever import TimeDelta
delta = now - event_time
print("Hours since midnight UTC:", delta.in_hours())

# Convert to a Unix timestamp for database storage
print("Unix timestamp:", now.timestamp())

# Format as ISO 8601 string
print("ISO string:", now.format_common_iso())

Output:

Now: 2026-07-24T02:15:30Z
Event: 2026-07-24T00:00:00Z
In 2 hours: 2026-07-24T04:15:30Z
Hours since midnight UTC: 2.258333...
Unix timestamp: 1753319730.0
ISO string: 2026-07-24T02:15:30Z

Notice that Instant arithmetic is always simple and unambiguous. Adding two hours to a UTC timestamp always produces a timestamp two hours later — no DST adjustments, no “is this wall-clock time or elapsed time?” ambiguity. When you need to display the result to a user in their timezone, that is when you convert to ZonedDateTime.

Working with Time Zones Using ZonedDateTime

When you need to show a time to a user in their local timezone, or schedule something for a specific wall-clock moment in a given city, ZonedDateTime is the right type. It stores both the absolute point in time and the IANA timezone name, which is exactly what you need for correct DST-aware arithmetic.

# using_zoned.py
from whenever import ZonedDateTime, Instant

# Create a ZonedDateTime directly (a meeting at 9am Sydney time)
meeting = ZonedDateTime(2026, 8, 15, 9, 0, tz="Australia/Sydney")
print("Meeting (Sydney):", meeting)

# Convert to UTC to store or compare
meeting_utc = meeting.as_instant()
print("Meeting (UTC):", meeting_utc)

# Convert to another timezone for a colleague in New York
meeting_ny = meeting.to_tz("America/New_York")
print("Meeting (New York):", meeting_ny)

# Get the current time in a specific timezone
now_tokyo = Instant.now().to_tz("Asia/Tokyo")
print("Tokyo now:", now_tokyo)

# Compare two ZonedDateTimes
from whenever import ZonedDateTime as ZDT
a = ZDT(2026, 9, 1, 10, 0, tz="Europe/Paris")
b = ZDT(2026, 9, 1, 10, 0, tz="America/Chicago")
print("Paris 10am == Chicago 10am?", a == b)     # False -- different instants
print("Paris 10am before Chicago 10am?", a < b)  # True -- Paris is 7h ahead

Output:

Meeting (Sydney): 2026-08-15T09:00:00+10:00[Australia/Sydney]
Meeting (UTC): 2026-08-14T23:00:00Z
Meeting (New York): 2026-08-14T19:00:00-04:00[America/New_York]
Tokyo now: 2026-07-24T11:15:30+09:00[Asia/Tokyo]
Paris 10am == Chicago 10am? False
Paris 10am before Chicago 10am? True

The comparison at the bottom is worth pausing on. Two ZonedDateTime values at "10:00am" in different cities are not equal and not ambiguous -- whenever compares them by their absolute UTC position, not their wall-clock display. With Python's built-in datetime, getting this right requires careful, manual conversion. With whenever, it is automatic and safe by design.

Python whenever ZonedDateTime timezone conversion across multiple regions
UTC in. ZonedDateTime out. No surprises in production.

DST-Safe Arithmetic

This is where whenever earns its name. DST-safe arithmetic means that "add 24 hours" and "add 1 day" are treated as different operations -- because on the day clocks fall back, "one day later, same wall-clock time" is 25 elapsed hours, not 24. Python's datetime does not distinguish between these. whenever does, explicitly, through separate methods.

# dst_arithmetic.py
from whenever import ZonedDateTime

# The day before clocks "fall back" in New York (November 2, 2026 at 2am -> 1am)
before_dst = ZonedDateTime(2026, 11, 1, 10, 0, tz="America/New_York")
print("Before DST:", before_dst)
print("UTC:", before_dst.as_instant())

# add(hours=24): add exactly 24 clock hours (absolute time)
after_24h = before_dst.add(hours=24)
print("\nAdd 24 hours:", after_24h)      # 09:00 -- lost an hour because clocks fell back
print("UTC:", after_24h.as_instant())

# add(days=1): add 1 calendar day, preserving wall-clock time
after_1d = before_dst.add(days=1)
print("\nAdd 1 day:", after_1d)           # 10:00 -- same wall-clock time, 25 elapsed hours
print("UTC:", after_1d.as_instant())

# Verify the difference
elapsed_hours = (after_1d.as_instant() - before_dst.as_instant()).in_hours()
print("\nElapsed hours for 'add 1 day':", elapsed_hours)   # 25.0, not 24.0

Output:

Before DST: 2026-11-01T10:00:00-04:00[America/New_York]
UTC: 2026-11-01T14:00:00Z

Add 24 hours: 2026-11-02T09:00:00-05:00[America/New_York]
UTC: 2026-11-02T14:00:00Z

Add 1 day: 2026-11-02T10:00:00-05:00[America/New_York]
UTC: 2026-11-02T15:00:00Z

Elapsed hours for 'add 1 day': 25.0

The separation between add(hours=24) and add(days=1) is precisely what most datetime bug reports are about. If you are scheduling a meeting "tomorrow at the same time," you want add(days=1). If you are computing a 24-hour SLA window, you want add(hours=24). With Python's standard datetime, adding timedelta(days=1) always adds exactly 86400 seconds and silently gives you the wrong wall-clock time across a DST boundary. whenever forces you to choose, which forces you to think about what you actually want.

DST bug in Python datetime - timedelta days vs hours difference explained
timedelta(days=1) on DST Sunday. Classic.

Parsing and Formatting Dates

Reading timestamps from external sources (API responses, log files, databases) and writing them back out is a constant task. whenever provides parsing methods for standard formats and lets you format output for display or storage.

# parsing_formatting.py
from whenever import Instant, ZonedDateTime, NaiveDateTime

# Parse ISO 8601 UTC strings (the Z suffix signals UTC)
ts1 = Instant.parse_common_iso("2026-07-24T10:30:00Z")
print("Parsed UTC:", ts1)

# Parse a zoned datetime with offset and timezone name
ts2 = ZonedDateTime.parse_common_iso("2026-07-24T20:30:00+10:00[Australia/Sydney]")
print("Parsed zoned:", ts2)

# Parse a naive datetime (no timezone info)
naive = NaiveDateTime.parse_common_iso("2026-07-24T10:30:00")
print("Parsed naive:", naive)

# Format for storage (always use ISO 8601)
print("\nFormatted UTC:", ts1.format_common_iso())
print("Formatted zoned:", ts2.format_common_iso())

# Convert Unix timestamps from database or external API
from_unix = Instant.from_timestamp(1753319730)
print("\nFrom Unix timestamp:", from_unix)

# Round-trip through Unix timestamp (for database storage)
original = Instant.now()
stored = original.timestamp()          # float -- store in DB
restored = Instant.from_timestamp(stored)
print("Round-trip match:", original == restored)

Output:

Parsed UTC: 2026-07-24T10:30:00Z
Parsed zoned: 2026-07-24T20:30:00+10:00[Australia/Sydney]
Parsed naive: 2026-07-24T10:30:00

Formatted UTC: 2026-07-24T10:30:00Z
Formatted zoned: 2026-07-24T20:30:00+10:00

From Unix timestamp: 2026-07-24T02:15:30Z
Round-trip match: True

For database storage, the pattern is: convert to Instant, call .timestamp() for a Unix float, store that. When reading back, call Instant.from_timestamp() to recover the exact point in time, then convert to whatever timezone the user needs. This pattern eliminates any database-layer timezone confusion because you are always storing UTC-anchored values.

Converting Between whenever Types

In real applications you will move between whenever types as data flows through your system -- arriving as UTC from a database, displayed as local time to users, parsed from a form with no timezone, then stored back as UTC. whenever makes these conversions explicit and intentional, which means you can trace exactly where a datetime changes its meaning.

# converting_types.py
from whenever import Instant, ZonedDateTime, LocalSystemDateTime, NaiveDateTime

# --- Instant to ZonedDateTime ---
utc_now = Instant.now()
user_local = utc_now.to_tz("America/Los_Angeles")
print("UTC -> ZonedDateTime:", user_local)

# --- ZonedDateTime to Instant ---
meeting = ZonedDateTime(2026, 9, 15, 14, 30, tz="Europe/Berlin")
meeting_utc = meeting.as_instant()
print("ZonedDateTime -> Instant:", meeting_utc)

# --- NaiveDateTime: reading user input before applying a timezone ---
user_input_str = "2026-09-15T14:30:00"    # from a web form, no timezone
naive = NaiveDateTime.parse_common_iso(user_input_str)
print("Parsed naive:", naive)

# Apply the user's known timezone to make it actionable
zoned = naive.assume_tz("America/Chicago")
print("Naive -> ZonedDateTime:", zoned)

# --- LocalSystemDateTime for scripts on a known server ---
local_now = LocalSystemDateTime.now()
print("Local system time:", local_now)

# Convert to Instant for comparison or storage
local_as_utc = local_now.as_instant()
print("LocalSystem -> Instant:", local_as_utc)

Output:

UTC -> ZonedDateTime: 2026-07-24T19:15:30-07:00[America/Los_Angeles]
ZonedDateTime -> Instant: 2026-09-15T12:30:00Z
Parsed naive: 2026-09-15T14:30:00
Naive -> ZonedDateTime: 2026-09-15T14:30:00-05:00[America/Chicago]
Local system time: 2026-07-24T12:15:30+10:00[Australia/Melbourne]
LocalSystem -> Instant: 2026-07-24T02:15:30Z

The assume_tz() call on a NaiveDateTime is an important pattern. It is the moment where you say "I know this datetime came from a user in Chicago, so I am now declaring that to be true." The naming is deliberate -- assume signals that you are making an assertion that the type system cannot verify, which is an honest description of what is happening when you convert from naive to zoned. Once you have a ZonedDateTime, all further operations are safe again.

Python whenever type conversion - Instant ZonedDateTime NaiveDateTime LocalSystemDateTime
assume_tz() is where you sign the contract. Sign carefully.

Real-Life Example: Log Timestamp Processor

Here is a realistic script that reads log entries with mixed timestamp formats, normalizes them to UTC, filters by a time window, and displays results in a user's local timezone. This is the kind of task where datetime bugs hide most effectively in production code.

Python whenever log timestamp processor - normalizing mixed timezone log entries to UTC
Log at 2:30am. Which 2:30am? whenever knows. You will too.
# log_processor.py
from whenever import Instant, ZonedDateTime, NaiveDateTime
from datetime import timedelta

# Simulated log entries: some have explicit UTC, some have timezone, some are naive
LOG_ENTRIES = [
    {"level": "ERROR", "msg": "DB connection failed",     "ts": "2026-07-24T01:45:00Z"},
    {"level": "INFO",  "msg": "Backup started",           "ts": "2026-07-24T11:00:00+10:00[Australia/Sydney]"},
    {"level": "WARN",  "msg": "Memory usage high",        "ts": "2026-07-24T02:10:00Z"},
    {"level": "ERROR", "msg": "Payment timeout",          "ts": "2026-07-24T02:05:00Z"},
    {"level": "INFO",  "msg": "Cache warmed",             "ts": "2026-07-23T22:00:00Z"},
    {"level": "ERROR", "msg": "Rate limit exceeded",      "ts": "2026-07-24T00:30:00Z"},
]

def parse_to_instant(ts_str: str) -> Instant:
    """Parse a timestamp string to a UTC Instant, regardless of original format."""
    if ts_str.endswith("Z"):
        return Instant.parse_common_iso(ts_str)
    elif "[" in ts_str:
        zdt = ZonedDateTime.parse_common_iso(ts_str)
        return zdt.as_instant()
    else:
        # Assume UTC for naive timestamps in this system
        naive = NaiveDateTime.parse_common_iso(ts_str)
        return naive.assume_utc()

def filter_window(entries: list, start: Instant, end: Instant) -> list:
    """Return log entries whose timestamps fall within [start, end)."""
    result = []
    for entry in entries:
        ts = parse_to_instant(entry["ts"])
        if start <= ts < end:
            result.append({**entry, "_instant": ts})
    return result

def display_in_tz(entries: list, tz: str) -> None:
    """Print log entries with timestamps displayed in a given timezone."""
    print(f"\nLog entries (displayed in {tz}):")
    print("-" * 60)
    sorted_entries = sorted(entries, key=lambda e: e["_instant"])
    for entry in sorted_entries:
        local_ts = entry["_instant"].to_tz(tz)
        print(f"[{entry['level']:5s}] {local_ts.format_common_iso()[:19]}  {entry['msg']}")

# --- Main ---
# Find all errors in a 2-hour window ending now
window_end = Instant.now()
window_start = Instant.parse_common_iso("2026-07-24T00:00:00Z")
window_close = Instant.parse_common_iso("2026-07-24T02:15:00Z")

errors_only = [e for e in LOG_ENTRIES if e["level"] == "ERROR"]
recent_errors = filter_window(errors_only, window_start, window_close)

display_in_tz(recent_errors, "America/New_York")
display_in_tz(recent_errors, "Australia/Sydney")

Output:

Log entries (displayed in America/New_York):
------------------------------------------------------------
[ERROR] 2026-07-23T20:30:00  Rate limit exceeded
[ERROR] 2026-07-23T22:05:00  Payment timeout

Log entries (displayed in Australia/Sydney):
------------------------------------------------------------
[ERROR] 2026-07-24T10:30:00  Rate limit exceeded
[ERROR] 2026-07-24T12:05:00  Payment timeout

The key pattern here is the parse_to_instant() function: regardless of what format a timestamp arrives in -- UTC with Z suffix, a fully-qualified zoned datetime, or a naive string -- it produces a single Instant that represents the same absolute point in time. Once everything is an Instant, comparison and sorting are unambiguous. The display step is separated cleanly at the end, converting to the viewer's timezone only for output. This architecture -- store and compare as Instant, display as ZonedDateTime -- is the pattern whenever was designed to make natural.

To extend this project, add a function that groups entries by hour in the user's timezone (using ZonedDateTime arithmetic) or that detects entries clustered within a short window (a 5-error-in-60-seconds alert). Both are straightforward with whenever's explicit types and safe arithmetic.

Frequently Asked Questions

How does whenever compare to pendulum?

Both libraries solve the DST-safety problem that Python's standard datetime leaves unresolved, but they take different approaches. pendulum subclasses Python's datetime, which means it is a drop-in replacement in many cases but also inherits some of datetime's ambiguities -- you can still accidentally compare a pendulum datetime to a naive standard datetime. whenever uses completely separate types that do not subclass datetime, which makes mixing safe and unsafe types a type-checking error rather than a runtime surprise. If you need drop-in compatibility with existing code that expects standard datetime objects, pendulum is easier to adopt incrementally. If you are writing new code and want maximum safety, whenever's stricter design pays off over time.

Can I use whenever alongside Python's standard datetime?

whenever provides .py_datetime() methods on all its types to convert to standard datetime objects when you need to pass values to libraries that expect them. Going the other direction, you can construct whenever types from standard datetime objects using class methods like Instant.from_py_datetime(dt). This makes incremental adoption practical: wrap the edges of your system where timestamps enter and leave, keep the internal logic in whenever types, and convert only at the boundary when an external library requires it.

What is the best way to store whenever datetimes in a database?

The recommended pattern is to convert everything to Instant before storage and call .timestamp() to get a Unix float, then store that as a REAL or FLOAT column. When reading back, call Instant.from_timestamp(value) to reconstruct the Instant, then convert to the appropriate display timezone. For databases that have a native TIMESTAMPTZ or DATETIME WITH TIME ZONE column type (PostgreSQL, for example), you can store the ISO 8601 string directly using .format_common_iso(), which always includes the UTC offset, and parse it back with Instant.parse_common_iso(). Either approach preserves the exact point in time without any timezone confusion during round-trips.

When should I use NaiveDateTime?

Use NaiveDateTime when the timezone is genuinely unknown or irrelevant -- reading a timestamp from a CSV file before you know which timezone it came from, representing a scheduled time that applies in multiple timezones (like "midnight on December 31" where you will apply different timezones per user), or working with historical timestamps from before standardized timezones existed. Do not use it as a shortcut to avoid thinking about timezones: the moment you start comparing, sorting, or doing arithmetic with naive datetimes that are meant to represent real-world moments, you are back in the territory of silent bugs. The type name is honest -- it warns you that timezone knowledge is missing.

What does assume_utc() do and when is it safe?

NaiveDateTime.assume_utc() converts a naive datetime to a UTC Instant by asserting that the naive value is already in UTC. This is the right call when you are reading from a system that you control and you know always stores timestamps as UTC -- internal databases, log files from your own servers, or responses from an API that documents its timestamps as UTC. It is not safe when the source timezone is unknown or variable. The assume prefix in the method name is a deliberate signal: you are making an assertion that the type system cannot verify, so make sure your knowledge of the source format is solid before calling it.

Conclusion

Python's datetime handling has been a source of production bugs for two decades, and whenever is the most direct answer yet: distinct types for distinct concepts, with arithmetic that respects DST by design rather than by careful developer attention. The four types -- Instant for UTC timestamps, ZonedDateTime for user-facing times, LocalSystemDateTime for scripting contexts, and NaiveDateTime for incomplete information -- cover the full range of what real applications need, and the explicit conversion methods between them make each type boundary a deliberate decision in your code rather than an accident waiting to happen.

The log processor example above shows the architecture that whenever makes natural: parse everything to Instant at the input boundary, do all storage and comparison in UTC, convert to ZonedDateTime only for display. Extend it by adding email alert grouping, a live dashboard that refreshes every 30 seconds, or a function that annotates each log entry with the user's local time alongside UTC. Every extension is safe because the types enforce the right boundaries automatically.

The official documentation is at whenever.readthedocs.io and covers advanced topics including partial datetime support, interoperability with dateutil, and the full API reference for all four types. The GitHub repository has migration guides and a comparison with other datetime libraries.

How To Use ty for Fast Python Type Checking

How To Use ty for Fast Python Type Checking

Intermediate

You add type hints to your Python code — function signatures, return types, the works — and then run mypy and wait. And wait. On a large codebase, that wait can stretch to 30 seconds or more, which means you stop running the type checker constantly and start running it only before commits. At that point, you have already written the buggy code, refactored around it, and half-forgotten what you were thinking when you typed it.

ty is Astral’s Python type checker, written in Rust by the same team behind ruff and uv. It runs 10x to 100x faster than mypy and Pyright, which means type checking becomes a tight feedback loop instead of a CI-only ritual. You install it with a single command, run it the same way you would run any linter, and get results before you have time to switch tabs.

This article walks you through the full ty workflow: installation, your first type check, reading and understanding its output, configuring it for a real project, suppressing false positives cleanly, and integrating it into your editor and CI pipeline. By the end you will know exactly how to make ty part of every Python project you start.

ty Quick Example

Before we go deep, here is a minimal example of what ty does. Save this file, run one command, and see type errors reported in milliseconds.

# check_me.py

def greet(name: str) -> str:
    return "Hello, " + name

def add(x: int, y: int) -> int:
    return x + y

# These calls have type errors
result = greet(42)          # Wrong: passing int where str expected
total = add("one", "two")   # Wrong: passing str where int expected
print(result, total)

Now run ty against the file:

uvx ty check check_me.py

Output:

check_me.py:10:15: error[invalid-argument-type] Argument of type `int` is not assignable to parameter `name` of type `str` in function `greet`
check_me.py:11:14: error[invalid-argument-type] Argument of type `str` is not assignable to parameter `x` of type `int` in function `add`

Found 2 errors in 1 file.

ty pinpoints the exact line and column of each problem, names the error rule (invalid-argument-type), and explains what went wrong in plain language. Each error includes the inferred type, the expected type, and the function involved. No configuration file needed — it works out of the box.

What Is ty and Why Use It?

A type checker reads your Python source code without running it. It traces how values flow through your program — from function calls to return values to assignments — and flags places where the types do not match. This catches whole categories of bugs at write time rather than runtime: passing a None where a string is expected, calling a method that does not exist on a particular type, returning the wrong type from a function.

Python has had type hints since version 3.5 (PEP 484), but actually using them required running a separate slow checker and waiting for results. ty changes the economics: because it is written in Rust, it can check even large codebases in under a second. That speed changes how you use it — from an occasional gate to a constant companion.

ToolLanguageSpeed (vs mypy)Status (2026)
mypyPython1x (baseline)Mature, widely used
PyrightTypeScript5-10x fasterMature, powers Pylance
PyreflyRust10-60x fasterMeta’s checker, newer
tyRust10-100x fasterAstral beta, growing fast

ty is backed by Astral, the team behind ruff (the fastest Python linter) and uv (the fastest Python package manager). If you already use those tools, ty fits naturally into the same workflow.

Python type checker speed comparison - ty vs mypy performance
Mypy: 30 seconds. ty: 300ms. Same bugs caught.

Installing ty

There are three ways to install ty, depending on how you manage your Python tools. The fastest method — and the one that needs no prior setup — is uvx, which runs ty in an isolated environment without installing anything permanently.

Run Without Installing (uvx)

If you have uv installed, you can run ty immediately without a separate install step:

# run_ty_uvx.sh

# Run ty against all Python files in the current directory
uvx ty check

# Run against a specific file
uvx ty check src/app.py

# Run against a specific directory
uvx ty check src/

Output (first run — downloads ty automatically):

Installed 1 package in 312ms
No errors found.

After the first run, uvx caches the package so subsequent invocations are instant. This is the recommended way to use ty in CI pipelines because the exact version is pinned automatically.

Install Permanently

For a permanent installation that puts ty on your PATH, use uv tool install:

# install_ty.sh

# Install ty as a global tool (requires uv)
uv tool install ty

# Or install via pip into the current environment
pip install ty

# Verify the installation
ty --version

Output:

ty 0.0.0-alpha.14

Once installed globally, you can run ty check from any directory. Use pip install ty if you are adding it to a project’s requirements-dev.txt or pyproject.toml dev dependencies.

Installing ty Python type checker with one command
One command. Zero waiting. Your type checker is ready.

Reading ty’s Output

Before you start fixing errors, it helps to understand exactly what ty is telling you. The output format is consistent and information-dense.

# output_examples.py
from typing import Optional

def process_user(user_id: int) -> dict:
    return {"id": user_id, "active": True}

def get_name(user: dict) -> str:
    return user["name"]

# ty will flag this -- Optional[int] might be None
def format_count(count: Optional[int]) -> str:
    return "Count: " + str(count * 2)   # Error: count could be None

# ty will flag this -- can't multiply str by str
def repeat_word(word: str, times: str) -> str:
    return word * times   # Error: times should be int, not str

Output:

output_examples.py:12:25: error[possibly-unbound-implicit-call] Cannot call `__mul__` on `None`
output_examples.py:12:25: error[operator] Operator `*` not supported for types `int | None` and `int`
output_examples.py:16:12: error[operator] Operator `*` not supported for types `str` and `str`

Found 3 errors in 1 file.

Each line follows the same pattern: file:line:column: severity[rule-code] message. The rule-code in brackets (like operator or invalid-argument-type) is important because it is what you use in suppression comments or configuration files to control which rules run.

Type Checking a Real Project

Running ty check with no arguments checks all Python files in your current directory and its subdirectories. This is the most common way to use it on a real project.

# check_project.sh

# Check all Python files in the project
ty check

# Check with verbose output (shows which files were checked)
ty check --verbose

# Check and output as JSON (useful for CI scripts)
ty check --output-format json

# Check only errors, skip warnings
ty check --error-on warning

Output (example project with multiple files):

src/api/routes.py:23:9: error[invalid-argument-type] Argument of type `list[str]` is not assignable to parameter `ids` of type `list[int]`
src/models/user.py:45:16: error[attribute-access] Object of type `Optional[str]` has no attribute `upper`; use `str | None`
src/utils/parse.py:78:12: error[return-type] Return type `None` is not assignable to declared return type `str`

Found 3 errors in 47 files checked in 0.4s

The final line shows how many files were checked and how long it took. On a project with dozens of files, that 0.4 second time is typical — fast enough to run on every file save.

ty scanning Python project files for type errors
47 files. 0.4 seconds. That one red file owes you an explanation.

Configuring ty

For most projects you will want to tell ty a few things: which Python version to target, which directories to skip, and which rules to enable or disable. All of this goes in pyproject.toml or a dedicated ty.toml file.

Using pyproject.toml

The most common setup adds a [tool.ty] section to your existing pyproject.toml:

# pyproject.toml

[tool.ty]
# Target Python version for type checking
python-version = "3.11"

# Directories to exclude from type checking
exclude = [
    "tests/fixtures/",
    "migrations/",
    "docs/",
    "__pycache__/",
]

# Set rule severities: "error", "warn", "info", "ignore"
[tool.ty.rules]
# Treat these as warnings instead of errors during migration
possibly-undefined = "warn"
missing-argument = "warn"

Save that file in your project root, then run ty check and it will pick up the configuration automatically. You do not need to pass any flags — ty finds pyproject.toml by walking up from the current directory, the same way ruff and uv do.

Using ty.toml (Standalone)

If you prefer to keep ty configuration separate from your build config, create a ty.toml file:

# ty.toml

python-version = "3.11"

exclude = [
    "tests/",
    "build/",
]

[rules]
# Promote these to errors for strict mode
return-type = "error"
attribute-access = "error"

The ty.toml file takes precedence over pyproject.toml‘s [tool.ty] section if both are present. Use whichever keeps your repo organized.

Suppressing False Positives

No type checker is perfect. Sometimes ty will flag something it cannot fully understand — a dynamic attribute set at runtime, a third-party library without type stubs, or a pattern that is technically correct but requires knowledge of your runtime environment. For these cases, ty supports inline suppression comments.

# suppression_examples.py

from typing import Any

# Suppress a specific rule on this line
value: Any = get_dynamic_value()  # type: ignore[return-type]

# Suppress all ty errors on this line (use sparingly)
result = legacy_api_call()  # type: ignore

# Suppress for a whole block using a rule name
def process_webhook_payload(data: dict) -> None:
    # The payload shape is validated at the API boundary, not here
    user_id: int = data["user"]["id"]  # type: ignore[assignment]
    send_notification(user_id)

# The preferred pattern: narrow the type explicitly instead of suppressing
def safe_process(data: dict) -> None:
    raw_id = data.get("user", {}).get("id")
    if not isinstance(raw_id, int):
        raise ValueError(f"Expected int user id, got {type(raw_id)}")
    send_notification(raw_id)  # ty knows raw_id is int here

Output (with suppression):

suppression_examples.py:21:5: note[assignment] Type `dict[str, Any] | int | None` is assignable to `int` via suppression
No errors found.

The rule name in type: ignore[rule-name] is optional but strongly recommended. Broad type: ignore comments silently suppress all errors on that line, including real bugs you introduce later. Specific suppressions document WHY you are overriding the checker — future you will be grateful.

Using type: ignore suppression comment in Python with ty
type: ignore[assignment] — because you know something ty doesn’t.

Editor Integration

The real productivity gain from ty comes from running it continuously in your editor. ty ships a language server (LSP) that provides inline diagnostics, hover documentation, auto-import, and code navigation — without any additional configuration beyond installing the extension.

VS Code

Install the official ty VS Code extension from the marketplace. Once installed, open any Python file and type errors appear as red underlines immediately — no terminal window needed.

# .vscode/settings.json
{
    "ty.enable": true,
    "ty.pythonVersion": "3.11",
    "[python]": {
        "editor.defaultFormatter": "charliermarsh.ruff",
        "editor.formatOnSave": true
    }
}

The extension uses the same configuration from pyproject.toml or ty.toml that your CLI invocations use, so there is no separate editor config to maintain.

Neovim / Other Editors

For any editor that supports the Language Server Protocol, configure ty as a language server:

# init.lua (Neovim with nvim-lspconfig)
require('lspconfig').ty.setup({
    cmd = { "ty", "server" },
    filetypes = { "python" },
    root_dir = require('lspconfig.util').root_pattern("pyproject.toml", "ty.toml", ".git"),
})

The ty server subcommand starts the LSP. After it is running, you get inline diagnostics, hover types, and go-to-definition for free.

Adding ty to CI

Type checking in CI catches regressions before they merge. Here is how to add ty to a GitHub Actions workflow alongside your existing linters:

# .github/workflows/quality.yml
name: Code Quality

on: [push, pull_request]

jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v3

      - name: Set up Python
        uses: astral-sh/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: uv sync --dev

      - name: Run ty
        run: uvx ty check src/

      - name: Run ruff (linting)
        run: uvx ruff check src/

      - name: Run ruff (formatting)
        run: uvx ruff format --check src/

Output on a clean run:

No errors found. (47 files checked in 0.3s)

The ty check command exits with code 0 when there are no errors and code 1 when there are, so CI fails automatically on type errors with no extra configuration needed.

ty type checker running in CI pipeline for Python project
Exits 0 on clean. Exits 1 on errors. CI does the rest.

Migrating from mypy

If you have an existing project with mypy, switching to ty is usually straightforward. Both tools read standard Python type hints, so your existing annotations do not need to change. The main differences are in configuration file names and a handful of rule names that differ between the two tools.

# migrate_check.sh

# Step 1: Run ty against your codebase
ty check src/

# Step 2: Compare error counts
mypy src/ | tail -1          # e.g. "Found 12 errors in 5 files"
ty check src/ | tail -1      # e.g. "Found 9 errors in 5 files"

# ty may find fewer errors initially (different rules)
# and more errors (stricter on certain patterns)
# Treat discrepancies as a review task, not a blocker

Common mypy config equivalents in ty:

mypy settingty equivalent
python_version = "3.11"python-version = "3.11"
ignore_missing_imports = true[rules] unresolved-import = "warn"
strict = trueNo direct equivalent — enable rules individually
# type: ignore# type: ignore (same syntax, shared)
exclude = ["tests/"]exclude = ["tests/"]

A practical migration strategy: run both tools in parallel for a sprint, fix errors that appear in both, and then drop mypy once you are satisfied with ty‘s coverage on your codebase.

Real-Life Example: Type-Safe Data Processing Pipeline

Here is a realistic Python module that processes user records from a JSON API. It has several type annotations and a few deliberate type errors that ty will catch.

Type-safe data processing pipeline in Python with ty type checking
Runtime surprises are for amateurs. Compile time is for professionals.
# pipeline.py
from typing import Optional
import json

# --- Data models ---
class User:
    def __init__(self, user_id: int, name: str, email: str, age: Optional[int] = None):
        self.user_id = user_id
        self.name = name
        self.email = email
        self.age = age

    def display_name(self) -> str:
        return self.name.upper()

    def age_group(self) -> str:
        if self.age is None:
            return "unknown"
        if self.age < 18:
            return "minor"
        return "adult"


# --- Processing functions ---
def parse_users(raw_json: str) -> list[User]:
    data = json.loads(raw_json)
    users = []
    for record in data:
        user = User(
            user_id=record["id"],      # assumes int
            name=record["name"],       # assumes str
            email=record["email"],     # assumes str
            age=record.get("age"),     # Optional[int]
        )
        users.append(user)
    return users


def filter_adults(users: list[User]) -> list[User]:
    return [u for u in users if u.age_group() == "adult"]


def format_report(users: list[User]) -> str:
    lines = []
    for user in users:
        line = f"ID={user.user_id} | {user.display_name()} | {user.email}"
        lines.append(line)
    return "\n".join(lines)


# --- Main ---
SAMPLE_DATA = json.dumps([
    {"id": 1, "name": "Alice Nguyen", "email": "alice@example.com", "age": 34},
    {"id": 2, "name": "Bob Chen",    "email": "bob@example.com",    "age": 15},
    {"id": 3, "name": "Carol Kim",   "email": "carol@example.com"},
])

users = parse_users(SAMPLE_DATA)
adults = filter_adults(users)
print(format_report(adults))

Output:

ID=1 | ALICE NGUYEN | alice@example.com
# Run ty to verify the module is type-clean
# run_ty.sh
ty check pipeline.py

ty Output:

No errors found. (1 file checked in 0.1s)

This module passes ty‘s checks cleanly because every function has annotated parameters and return types, Optional[int] is handled before use (via age_group()), and json.loads‘s return value flows through named variables with clear type assumptions. To extend this pipeline, add a save_report(path: str, content: str) -> None function and run ty check again — if you forget to pass the right types, ty will catch it before you run the code.

Frequently Asked Questions

What are the key differences between ty and mypy?

The most obvious difference is speed: ty is 10x to 100x faster than mypy on the same codebase, making it practical to run on every file save rather than only in CI. Beyond speed, ty has first-class support for intersection types and more advanced type narrowing — patterns that mypy handles inconsistently. ty is also stricter about certain patterns by default, such as unresolved imports and possibly-None access. During a migration, expect to find some errors that mypy missed and some mypy errors that ty does not flag, as the two tools have different rule sets.

What happens with third-party libraries that lack type stubs?

When ty cannot find type information for an import, it reports an unresolved-import error. For libraries that have official type stubs (like boto3-stubs, types-requests, etc.), install the stubs package and ty will pick them up automatically. For libraries without stubs, set unresolved-import = "warn" in your [tool.ty.rules] config to downgrade those from errors to warnings, or create a minimal stub file yourself using .pyi files.

How do I add ty to an existing codebase that has no type hints?

Start by running ty check and counting the errors. Then add a [tool.ty.rules] section to your pyproject.toml and set the noisiest rules to "warn" instead of "error". This lets you enable type checking without breaking CI on day one. Annotate the most critical modules first — API boundaries, data models, and utility functions — and gradually tighten the rules as coverage improves. Running ty check --stats shows which rules are generating the most noise, which guides prioritization.

Should I use ty or Pyright?

If you use VS Code and already have Pylance (which is powered by Pyright), you are already getting type checking inline. Switching to ty makes sense when you want a consistent tool across all editors and CI, when the check speed on your codebase has become a friction point, or when you are already using ruff and uv and want the same Astral ecosystem for type checking. ty is newer and still in beta, but Astral now uses it exclusively on their own projects. Pyright is more mature and has broader rule coverage for edge cases in the Python type system.

Which Python versions does ty support?

ty officially supports type checking code targeting Python 3.10 and later. You declare your target version via python-version in your config, and ty adjusts its understanding of built-in types accordingly — for example, it knows that list[int] is valid syntax in 3.10+ but requires List[int] from typing in earlier versions. If you need to support Python 3.8 or 3.9, configure the version in ty.toml and note that some newer typing features will be flagged as unavailable.

How does ty achieve such dramatic speed improvements?

The core reason is that ty is written in Rust, which eliminates Python’s interpreter overhead and allows aggressive parallelism across CPU cores. But the architecture matters too: ty uses fine-grained incremental analysis, meaning that when you edit one file, it only re-analyses the parts of the dependency graph affected by that change. When editing a file in the PyTorch repository (a large codebase), ty recomputes diagnostics in 4.7ms — 80x faster than Pyright and 500x faster than an older version of Pyrefly benchmarked in 2026. This makes the language server feel instant even in very large projects.

Conclusion

Type checking with ty takes what has historically been a slow, CI-only ritual and turns it into a tight development loop. The installation is one command (pip install ty or uv tool install ty), the CLI is a single command (ty check), and the speed — 10x to 100x faster than mypy — means you can run it on every save without thinking about it. The configuration in pyproject.toml or ty.toml is minimal: a Python version target, a list of excluded directories, and optional rule severity overrides for migrating gradually from an untyped codebase.

The best next step is to run ty check against a project you are already working on. If you get zero errors, add stricter rules one at a time. If you get hundreds of errors, downgrade the noisiest rules to "warn" in the config and fix them incrementally. The real-life pipeline example above shows what a cleanly typed module looks like — use it as a template for how to structure annotated functions and handle Optional values correctly before they cause runtime crashes.

The official ty documentation is at docs.astral.sh/ty and covers advanced topics including intersection types, module discovery, and the full rule reference. The GitHub repository tracks the current beta status and has a migration guide from mypy.

How To Use Python scalene for CPU and Memory Profiling

How To Use Python scalene for CPU and Memory Profiling

Intermediate

Your data processing script finishes in 47 seconds. You know it is slow, but you have no idea which part is the culprit — is it the CSV parsing, the nested loop, or the NumPy computation at the end? You run cProfile, get a 200-line dump of function call counts, and spend the next hour squinting at aggregate numbers that tell you which function was slow but not which line inside it. You need a better tool.

scalene is a high-performance Python profiler that works at the line level, splits CPU time into Python time and native (C extension) time, and tracks memory allocations — all with statistical sampling that adds almost no overhead to your program. You install it with one pip install and prefix your command with scalene. No decorators, no code changes, no instrumentation.

This article covers everything you need to get useful profiles out of scalene: the quick-start workflow, how to read its output, CPU and memory profiling separately, HTML reports, filtering noise with thresholds, and a real-life profiling session where we find and fix two bottlenecks in a data processing script. By the end you will know exactly how to answer “why is my Python code slow?”

Python scalene: Quick Example

Here is the fastest way to see scalene in action. First, install it, then point it at any Python file — no changes to the file required.

# terminal -- installation
pip install scalene

Output:

Successfully installed scalene-1.5.51 ...

Now create a small script that has a visible bottleneck:

# slow_demo.py
import time

def fast_work():
    return sum(range(1_000_000))

def slow_work():
    # Artificially slow: repeated string concatenation
    result = ""
    for i in range(5_000):
        result += str(i)
    return result

def main():
    for _ in range(3):
        fast_work()
    for _ in range(10):
        slow_work()

main()

Run it under scalene:

# terminal
scalene slow_demo.py

Output (terminal table — abbreviated):

                      slow_demo.py: % of time = 100.00% out of   1.23s.
       %    |%     |%       |  Memory (MB) |                     slow_demo.py
    CPU      Python  native  |  alloc  peak |  line
 ─────────────────────────────────────────────────────────────────────────────
   5.00%    5.00%   0.00%  |   0.0  2.1  |   5: def fast_work():
   6.00%    6.00%   0.00%  |   0.0  2.1  |   6:     return sum(range(1_000_000))
  84.00%   84.00%   0.00%  |   9.8  9.8  |  11:     result += str(i)
   5.00%    5.00%   0.00%  |   0.0  2.1  |  14: def main():

Line 11 is consuming 84% of CPU time and allocating 9.8 MB — immediately obvious. The Python column shows this is pure Python overhead, not a C extension, which means there is a better Python algorithm waiting to replace it. That is the value of scalene in a single glance.

What Is scalene and Why Use It?

scalene is a statistical profiler created by Emery Berger at UMass Amherst. Instead of hooking into every function call the way cProfile does, it uses sampling: it interrupts your program hundreds of times per second, records what line is executing, and builds a statistical picture of where time is spent. Because it only samples, the overhead is low — typically 10-20% added runtime, compared to 2-10x slowdown with cProfile.

The most important thing scalene adds that other profilers do not is the CPU time breakdown. Every line shows three numbers: total CPU%, Python%, and native%. Native time is time spent inside C extensions like NumPy, Pandas, or OpenCV. If a line shows 80% total but 2% Python and 78% native, you know the bottleneck is in the C library — rewriting that line in pure Python will make it slower, not faster. You need to find a different NumPy operation, not replace NumPy with a loop.

ProfilerGranularityPython vs nativeMemory trackingOverhead
cProfileFunction levelNoNoMedium (2-5x)
line_profilerLine levelNoNoHigh (requires decoration)
memory_profilerLine levelNoYesVery high (10-20x)
scaleneLine levelYesYesLow (1.1-1.2x)

The combination of line-level granularity, the Python/native split, and memory tracking in a single low-overhead tool is what makes scalene worth reaching for first when you need to understand why code is slow.

Sudo Sam pointing at glowing profiler bar charts on a futuristic dashboard
cProfile said it was slow. scalene said which line, which column, and why.

Reading scalene Output

The terminal table that scalene prints uses a consistent column layout. Understanding each column lets you immediately identify the right fix.

# reading_output_reference.py
# (This is a reference -- not runnable standalone. See slow_demo.py above.)

# Column layout scalene prints:
#
#  %CPU   | %Python | %native | Memory alloc | Memory peak | line# | source line
#
# %CPU    -- percentage of total wall-clock time attributed to this line
# %Python -- of that CPU time, how much ran in the Python interpreter itself
# %native -- of that CPU time, how much ran inside a C extension (NumPy, etc.)
# alloc   -- net memory allocated on this line (MB), averaged over samples
# peak    -- peak memory in use when this line was running (MB)

The key relationship is: %Python + %native = %CPU (approximately — small rounding differences are normal). When you see a high %native on a line calling a NumPy function, the time is inside the C library and is already optimized. Look elsewhere. When you see high %Python on a loop or string operation, that is where your rewrite effort belongs.

GPU Column

If you have an NVIDIA GPU and your code uses libraries like PyTorch or CuPy, scalene adds a %GPU column automatically. This column shows what percentage of time that line was waiting on GPU operations. No extra flags needed — scalene detects GPU usage automatically when CUDA is available.

CPU Profiling in Depth

Let us build a realistic script with multiple functions and see how scalene pinpoints the slow paths. This script downloads user data from a public API and processes it in two ways — one efficient, one not.

# cpu_profile_demo.py
import urllib.request
import json

def fetch_users():
    """Fetch 10 fake users from jsonplaceholder."""
    url = "https://jsonplaceholder.typicode.com/users"
    with urllib.request.urlopen(url) as resp:
        return json.loads(resp.read())

def process_slow(users):
    """Build a name index with slow string operations."""
    index = ""
    for user in users:
        # String concatenation in a loop -- O(n^2) due to immutability
        index += user["name"].lower().replace(" ", "_") + ","
    return index.split(",")

def process_fast(users):
    """Build the same index with a list comprehension."""
    return [
        user["name"].lower().replace(" ", "_")
        for user in users
    ]

def analyze(users):
    """Run both processors 500 times each to amplify the difference."""
    slow_results = []
    for _ in range(500):
        slow_results = process_slow(users)

    fast_results = []
    for _ in range(500):
        fast_results = process_fast(users)

    return slow_results, fast_results

def main():
    users = fetch_users()
    slow, fast = analyze(users)
    print(f"Slow result count: {len(slow)}")
    print(f"Fast result count: {len(fast)}")

main()

Output (without profiling):

Slow result count: 11
Fast result count: 10

Now run it under scalene:

# terminal
scalene cpu_profile_demo.py

Output (scalene terminal — key lines):

       %    |%     |%
    CPU      Python  native  |   cpu_profile_demo.py
 ──────────────────────────────────────────────────────────
  68.00%   68.00%   0.00%  |  12:     index += user["name"].lower()...
  14.00%   14.00%   0.00%  |  17:     return [
   9.00%    0.00%   9.00%  |   6:     with urllib.request.urlopen(url)...

Line 12 (the concatenation loop) takes 68% of total runtime even though it does the same logical work as line 17 (the list comprehension at 14%). Both show 0% native time — this is a pure Python performance difference. The fix is already in the script: use the list comprehension pattern from process_fast.

Loop Larry running exhausted in a hamster wheel made of string characters
String concatenation in a loop. O(n^2). Every iteration rebuilds the whole string.

Memory Profiling

CPU time is only half the story. Memory allocations slow programs down too — the garbage collector has to clean up everything you allocate, and excessive allocation creates cache pressure. scalene tracks memory per line by default, but you can focus on memory analysis with the --memory flag to see only lines where allocations happened.

# memory_demo.py

def allocate_badly():
    """Accumulate large lists inside a loop -- common leak pattern."""
    cache = []
    for i in range(1000):
        # Creating a new list each iteration instead of appending
        row = list(range(1000))  # 1000 ints per iteration
        cache.append(row)
    return cache

def allocate_well():
    """Pre-allocate and fill -- same result, lower peak memory."""
    cache = [None] * 1000
    for i in range(1000):
        cache[i] = list(range(1000))
    return cache

def main():
    result_a = allocate_badly()
    result_b = allocate_well()
    print(f"Both results: {len(result_a)} rows, {len(result_b)} rows")

main()

Run with the --memory flag to filter output to only memory-heavy lines:

# terminal
scalene --memory memory_demo.py

Output (memory columns highlighted):

  Memory (MB)  |  memory_demo.py
  alloc  peak  |  line
─────────────────────────────────
   7.8  7.8   |   7:     row = list(range(1000))
   0.0  7.8   |   8:     cache.append(row)
   0.0  15.6  |  15:     cache[i] = list(range(1000))

The alloc column shows how much memory is freshly allocated on each sample of that line. Line 7 allocates 7.8 MB — those are the intermediate list objects being created and added to the cache. The peak column shows the highest memory in use while that line was running. By line 15, peak is 15.6 MB because both caches exist simultaneously. Knowing the per-line allocation lets you decide where to add object reuse, streaming, or generators.

HTML Reports for Deeper Analysis

For complex programs, the terminal output is useful but the HTML report is better. It shows the full source file annotated with profiling data inline, color-coded by heat, and lets you sort and filter interactively.

# terminal
scalene --html --outfile profile_report.html cpu_profile_demo.py

Output:

Out: profile_report.html

Open profile_report.html in any browser. You will see the source file on the left and bar charts next to each line showing CPU%, Python%, native%, and memory allocation. Lines with high CPU are highlighted in red; lines with high memory allocation are highlighted in blue. You can click column headers to sort functions by their total contribution, which makes it easy to find where the most time accumulates across many call sites.

The HTML report is especially useful when sharing profiling results with teammates — it is self-contained (no dependencies) and shows the source code and data together, so the reader does not need to run anything to understand the bottleneck.

Cache Katie placing a glass dome over a single glowing code block in the dark
Profile the section that matters. Ignore the noise.

Filtering Output with Thresholds

For large programs, scalene‘s output can get long. Use threshold flags to suppress lines that are below a meaningful contribution — this focuses the report on what actually matters.

# terminal -- only show lines contributing more than 1% CPU
scalene --cpu-percent-threshold 1 cpu_profile_demo.py

# terminal -- only show lines allocating more than 1 MB
scalene --memory-percent-threshold 1 memory_demo.py

# terminal -- combine both thresholds
scalene --cpu-percent-threshold 1 --memory-percent-threshold 1 cpu_profile_demo.py

Output (with threshold — only hot lines shown):

       %CPU  %Python  %native  | Memory alloc | line
────────────────────────────────────────────────────
  68.00%   68.00%   0.00%  |  0.0  2.1  |  12:     index += ...
  14.00%   14.00%   0.00%  |  0.0  2.1  |  17:     return [

Setting thresholds to 1-5% is a good starting point for medium-sized programs. For programs with many functions and long runtimes, a 5% threshold cuts out the noise while keeping anything you should care about. The --reduced-profile flag does something similar — it prints only functions and lines that exceed an internal significance threshold, which scalene calculates automatically based on the total runtime.

Profiling Without the Command Line

If you want to profile only a specific section of code rather than an entire script, use the scalene context manager. This is useful when startup and teardown time are irrelevant and you only want to measure the core computation.

# targeted_profile.py
from scalene import scalene_profiler

def heavy_computation(n):
    """Simulate heavy work."""
    total = 0
    for i in range(n):
        total += i * i
    return total

def light_setup():
    """Simulate setup we do not want to profile."""
    return list(range(100))

def main():
    data = light_setup()  # Not profiled

    # Only the block below is profiled
    with scalene_profiler.enable_profiling():
        result = heavy_computation(5_000_000)
        processed = [x * 2 for x in data]

    print(f"Result: {result}")
    print(f"Processed: {len(processed)} items")

main()

Run this file normally (not under the scalene command) and scalene will print its report only for the code inside the with block:

# terminal
python targeted_profile.py

Output:

Result: 41666666791666675000
Processed: 100 items

                 targeted_profile.py: % of time = 100.00% out of   2.14s.
       %CPU   %Python  %native  | Memory  | line
 ─────────────────────────────────────────────────────
  98.00%   98.00%   0.00%  |  0.0  |  10:     total += i * i
   2.00%    2.00%   0.00%  |  0.0  |  20:     processed = [x * 2 ...

The context manager approach is cleaner than decorating individual functions with @profile (a pattern from line_profiler that scalene does not require). You get targeted data without changing function signatures or adding class-level decorators.

Debug Dee holding two stopwatches showing before and after profiling times
Before: 74% wasted on string concat. After: the bottleneck is the internet.

Real-Life Example: Profiling and Fixing a Data Pipeline

Here is a realistic data processing pipeline that has two deliberate bottlenecks. We will profile it with scalene, identify both issues, fix them, and verify the improvement.

# data_pipeline.py
import urllib.request
import json

def fetch_posts():
    """Fetch 100 posts from a public REST API."""
    url = "https://jsonplaceholder.typicode.com/posts"
    with urllib.request.urlopen(url) as resp:
        return json.loads(resp.read())

def build_word_index(posts):
    """
    Build a word frequency index across all post bodies.
    Bottleneck 1: String concatenation instead of list join.
    """
    combined = ""
    for post in posts:
        combined += post["body"] + " "  # O(n^2) memory pattern
    words = combined.lower().split()
    freq = {}
    for word in words:
        freq[word] = freq.get(word, 0) + 1
    return freq

def find_top_authors(posts, word_index):
    """
    Find which user IDs wrote posts containing the top-10 words.
    Bottleneck 2: Repeated substring search instead of set lookup.
    """
    top_words = sorted(word_index, key=word_index.get, reverse=True)[:10]
    results = {}
    for post in posts:
        for word in top_words:
            # Slow: rebuilding a lowercase string 10x per post
            if word in post["body"].lower():
                uid = post["userId"]
                results.setdefault(uid, set()).add(word)
    return results

def main():
    posts = fetch_posts()
    # Run each function 100x to make profiling differences visible
    for _ in range(100):
        index = build_word_index(posts)
    for _ in range(100):
        authors = find_top_authors(posts, index)
    print(f"Unique words: {len(index)}")
    print(f"Active authors: {len(authors)}")

main()

Output (unprofiled):

Unique words: 638
Active authors: 10

Profile it:

# terminal
scalene --cpu-percent-threshold 2 data_pipeline.py

scalene output (key lines):

  74.00%  Python  |  14:     combined += post["body"] + " "
  19.00%  Python  |  28:         if word in post["body"].lower():

Two clear targets. Here is the fixed version:

# data_pipeline_fixed.py
import urllib.request
import json

def fetch_posts():
    url = "https://jsonplaceholder.typicode.com/posts"
    with urllib.request.urlopen(url) as resp:
        return json.loads(resp.read())

def build_word_index(posts):
    """Fix 1: Use str.join() instead of concatenation."""
    combined = " ".join(post["body"] for post in posts)
    words = combined.lower().split()
    freq = {}
    for word in words:
        freq[word] = freq.get(word, 0) + 1
    return freq

def find_top_authors(posts, word_index):
    """Fix 2: Pre-lowercase each body once, then check all words."""
    top_words = set(
        sorted(word_index, key=word_index.get, reverse=True)[:10]
    )
    results = {}
    for post in posts:
        body_lower = post["body"].lower()   # compute once per post
        body_words = set(body_lower.split())
        matched = top_words & body_words    # set intersection -- O(min(m,n))
        if matched:
            uid = post["userId"]
            results.setdefault(uid, set()).update(matched)
    return results

def main():
    posts = fetch_posts()
    for _ in range(100):
        index = build_word_index(posts)
    for _ in range(100):
        authors = find_top_authors(posts, index)
    print(f"Unique words: {len(index)}")
    print(f"Active authors: {len(authors)}")

main()

Output:

Unique words: 638
Active authors: 10

Same results. Profile the fixed version to confirm the bottlenecks are gone:

# terminal
scalene --cpu-percent-threshold 2 data_pipeline_fixed.py

scalene output (fixed):

  52.00%  native  |   5:     with urllib.request.urlopen(url)...
  31.00%  Python  |  11:     words = combined.lower().split()
  11.00%  Python  |  21:     body_words = set(body_lower.split())

The two Python bottlenecks are gone. The dominant cost is now the network call (52% native — inside the C HTTP library), which is expected. The word-splitting operations at 31% and 11% are the remaining algorithmic work and are much harder to optimize further without caching. This is what a clean profiling result looks like: the remaining hotspots are either I/O-bound or genuinely algorithmic, not avoidable inefficiency.

Sudo Sam comparing slow vs optimized profiler outputs side by side
Before: 74% wasted on string concat. After: the bottleneck is the internet.

Frequently Asked Questions

How much overhead does scalene add to my program?

scalene uses statistical sampling, which means it only interrupts the program periodically to check what is running. In practice this adds 10-20% to wall-clock time for most programs. This compares to 2-10x overhead for cProfile and even higher for memory_profiler. For very short scripts (under one second of runtime), the profiling overhead is proportionally larger, so scalene works best on programs that run for at least a few seconds.

What does “native” CPU time mean and what should I do about it?

Native time is time spent executing C, C++, or Fortran code inside a compiled extension like NumPy, Pandas, PIL, or a similar library. When you see high native time on a line, the bottleneck is inside the library’s compiled code — already highly optimized. The right response is not to rewrite that line in Python (that will make it slower). Instead, look for a different vectorized operation, check if you can reduce the data size passed in, or investigate whether you are calling the library function more times than necessary.

Why does the memory “alloc” column show 0 for many lines?

Memory allocation is also tracked via sampling, so lines that allocate very small amounts or allocate only occasionally between samples will show 0. The alloc column is an average over the samples taken when that line was running — not an exact byte count. If you need exact allocation tracking, combine scalene with tracemalloc (built into the standard library) for a two-tool view. tracemalloc gives exact counts at a higher overhead cost; scalene gives you the approximate picture quickly.

Can scalene profile multiprocessing and multithreaded programs?

scalene can profile multithreaded programs and will show per-thread CPU data. For multiprocessing programs that use multiprocessing.Process, scalene can profile child processes too — pass the --profile-all flag to include all spawned processes in the report. The HTML report then shows data aggregated across all threads and processes, which is useful for spotting work-imbalance issues in parallel code.

When should I use cProfile instead of scalene?

cProfile is deterministic — it records every single function call and is part of Python’s standard library, so there is nothing to install. Use it when you need an exact call count (for algorithmic analysis) or when you are on an environment where you cannot install packages. Use scalene when you want line-level data, memory tracking, or the Python/native time split. For most real-world optimization work, scalene‘s output is more immediately actionable because it points to the exact line rather than the function that contained the slow line.

Conclusion

You now have a complete scalene workflow: install it once, prefix any script to profile it, read the CPU/Python/native columns to distinguish fixable Python overhead from expected C-library time, use the memory columns to find excessive allocations, and export HTML reports when you need to share findings or explore a large codebase interactively. The context manager API gives you surgical profiling of specific code sections when you already know roughly where to look.

The real-life pipeline example shows the full cycle: profile, identify two bottlenecks (string concatenation and repeated .lower() calls), fix both with idiomatic Python, and confirm with a second profile pass that the dominant cost has shifted from avoidable Python overhead to genuine I/O and algorithmic work. That is the end state you are aiming for with any optimization effort.

For more on scalene‘s advanced features — GPU profiling, AI-assisted optimization suggestions, and the web-based UI — see the official scalene repository on GitHub and the PyPI package page for the latest release notes.

How To Use Python viztracer for Code Execution Visualization

How To Use Python viztracer for Code Execution Visualization

Intermediate

You run your Python script, it takes longer than expected, and you have no idea where the time is going. Adding print(time.time()) calls everywhere is slow, noisy, and still leaves you guessing which nested function call is the real culprit. cProfile gives you totals, but when a function is called 10,000 times from 50 different places, a flat table of numbers does not show you the story of how your program actually ran.

This is exactly what viztracer was built to solve. It records every function call, return, and exception in your program and saves the timeline to a JSON file that you can open as an interactive flame graph in your browser. You can zoom, filter, and inspect individual microsecond-level spans — all without adding a single line of instrumentation code to your program.

In this article we will cover how to install viztracer and trace your first script from the command line, how to use the context manager and decorator for targeted tracing, how to filter noisy built-in calls, how to add custom events and variables to the trace, and how to build a practical profiling workflow around a real script. By the end you will be able to open any Python program and see exactly where execution time is being spent, down to the function call level.

Tracing a Script with viztracer: Quick Example

The fastest way to get started is the command-line runner. Install viztracer, point it at your script, and open the result.

# install viztracer
pip install viztracer
# quick_trace.py
import time

def slow_step():
    time.sleep(0.05)

def fast_step():
    return sum(range(10_000))

def main():
    for _ in range(3):
        slow_step()
    result = fast_step()
    print(f"Result: {result}")

if __name__ == "__main__":
    main()

Now trace it:

viztracer quick_trace.py
Result: 49995000
viztracer: Saving trace data to result.json ...
viztracer: Trace saved. File size: 42.3 KB
viztracer: Open with: vizviewer result.json

Open the trace in your browser:

vizviewer result.json

A browser tab opens at http://localhost:9001 showing a flame graph. Each horizontal bar is a function call — wider bars took longer. The three slow_step calls will be immediately visible as wide spans at the top, with time.sleep beneath them. fast_step will appear as a narrow bar, showing that sum(range(...))) is fast.

That is the core workflow: trace, view, find the bottleneck. The sections below show you how to control exactly what gets recorded and how to add your own annotations to the trace.

Developer studying a colorful performance timeline flame graph
cProfile gave you a number. viztracer shows you the story.

What Is viztracer and Why Use It?

viztracer is a low-overhead Python tracing tool that records the precise start and end time of every function call your program makes and writes that data to a JSON file following the Perfetto trace format. The viewer — either vizviewer locally or https://ui.perfetto.dev/ in the browser — renders the data as an interactive flame graph where:

  • The x-axis is time — events further right happened later
  • The y-axis is call depth — each row is one level of the call stack
  • Bar width represents duration — wider means slower

This is fundamentally different from a flat profiler like cProfile, which only tells you aggregated totals. viztracer shows you the execution sequence — which function called which, in which order, and how long each call in context took. The table below compares the two approaches:

FeaturecProfileviztracer
Output typeFlat call-count tableInteractive flame graph timeline
Call sequence visibleNoYes
Per-call timingTotals onlyEvery individual call
OverheadLowLow (C extension, ~2-3x slowdown)
Filter noisy callsLimitedYes (max_stack_depth, ignore_frozen)
Custom eventsNoYes (add_instant, log_var)
Async/thread supportLimitedYes
Output formatpstats / textPerfetto JSON (browser-compatible)

viztracer uses a C extension to intercept Python’s sys.setprofile at a low level, keeping overhead manageable. For most programs you can expect a 2-3x slowdown during tracing — fast enough that your program still runs in a reasonable time, slow enough that you should not leave tracing on in production.

Installation and Setup

viztracer requires Python 3.6 or later. Install it from PyPI:

# install_viztracer.sh
pip install viztracer

Verify the install:

viztracer --version
viztracer 1.0.x

On systems where viztracer is not on your PATH after installation, use python -m viztracer instead of the bare command. Both forms accept the same arguments.

Command-Line Tracing

The CLI is the easiest entry point. You do not need to modify your source file at all — viztracer wraps the script and instruments it automatically.

Basic Trace

Run any Python script through viztracer by placing it after the viztracer command, followed by any arguments your script normally receives:

# trace_cli.sh
viztracer my_script.py arg1 arg2
# Or if the command isn't on PATH:
python -m viztracer my_script.py arg1 arg2
viztracer: Saving trace data to result.json ...
viztracer: Trace saved. File size: 1.8 MB

The output file is always result.json in the current directory unless you specify otherwise with -o. For runs you want to keep, name the output explicitly:

# named_output.sh
viztracer -o traces/run_2026-07-21.json my_script.py

Limiting Depth to Reduce Noise

Large programs — especially those using libraries like NumPy or Django — generate thousands of short internal calls that clutter the trace. The --max_stack_depth flag records only calls up to a given nesting depth:

# depth_limited.sh
viztracer --max_stack_depth 10 my_script.py

A depth of 10 usually captures all of your application logic while filtering out deep library internals. If you still see too much noise, pair this with --ignore_frozen to skip modules loaded from frozen (.pyc) bytecode:

# ignore_frozen.sh
viztracer --max_stack_depth 10 --ignore_frozen my_script.py
viztracer: Saving trace data to result.json ...
viztracer: File size: 0.3 MB (was 1.8 MB without filters)

The filtered trace will be 5-10x smaller and much easier to navigate in the viewer.

Developer using magnifying glass to zoom in on a highlighted call tree branch
–max_stack_depth 10. Because you came here to profile your code, not NumPy’s.

Using viztracer as a Context Manager

When you only want to trace a specific section of a larger program — not the entire startup and teardown — use the VizTracer context manager. Import it, wrap the target block, and the output file is written automatically when the with block exits.

# context_manager.py
import time
from viztracer import VizTracer

def prepare_data():
    """Simulate a setup step we do NOT want to trace."""
    time.sleep(0.1)
    return list(range(1000))

def process(data):
    return [x * x for x in data]

def save_results(results):
    total = sum(results)
    print(f"Total: {total}")

data = prepare_data()   # not traced

with VizTracer(output_file="process_only.json"):
    results = process(data)  # traced

save_results(results)   # not traced
Total: 332833500
viztracer: Saving trace data to process_only.json ...

Only the process(data) call and everything it calls internally appear in the trace. The prepare_data() and save_results() calls are invisible. This is the right approach when your application has a long initialization phase that would swamp the trace data you actually care about.

You can also pass configuration options directly to the constructor:

# context_manager_options.py
from viztracer import VizTracer

with VizTracer(
    output_file="filtered.json",
    max_stack_depth=8,
    ignore_frozen=True,
    log_gc=False,          # exclude garbage collector events
    min_duration=100,      # microseconds -- skip calls shorter than 100 us
):
    my_heavy_function()

The min_duration filter is particularly useful: it removes all the tiny one-microsecond function calls that are technically accurate but visually irrelevant, leaving only the spans that actually matter to your investigation.

Using the @trace_and_save Decorator

When you want to trace a single function every time it is called, without wrapping every call site in a with block, use the @trace_and_save decorator from viztracer:

# decorator_example.py
from viztracer import trace_and_save
import time

@trace_and_save(output_file="sort_trace.json")
def merge_sort(arr):
    """Classic recursive merge sort."""
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    return result + left[i:] + right[j:]

import random
data = random.sample(range(10_000), 500)
sorted_data = merge_sort(data)
print(f"Sorted {len(sorted_data)} elements")
Sorted 500 elements
viztracer: Saving trace data to sort_trace.json ...

When you open this trace you will see the recursive call tree of merge_sort as a deep nested flame graph -- each recursive call spawning two children below it. This makes the O(n log n) behavior of merge sort immediately visible as a visual structure, which is a fantastic teaching tool as well as a debugging aid.

Developer staring at a pyramid-shaped recursive flame graph
O(n log n) looks different when you can actually see the n log n.

Adding Custom Events and Variables

Sometimes raw function timings are not enough -- you also want to record what values your variables held at key points in the trace. viztracer provides two methods on the tracer object: add_instant for point-in-time events and log_var for recording a variable's value.

# custom_events.py
from viztracer import VizTracer
import time

def process_batch(tracer, batch_id, items):
    tracer.add_instant(
        name=f"batch_start",
        args={"batch_id": batch_id, "size": len(items)},
    )
    result = []
    for item in items:
        processed = item ** 2
        result.append(processed)
    tracer.log_var("batch_result_sum", sum(result))
    return result

with VizTracer(output_file="custom_events.json") as tracer:
    all_results = []
    for i, batch in enumerate([[1,2,3], [4,5,6], [7,8,9]]):
        batch_output = process_batch(tracer, i, batch)
        all_results.extend(batch_output)

print(f"Final sum: {sum(all_results)}")
Final sum: 285
viztracer: Saving trace data to custom_events.json ...

In the flame graph viewer, add_instant events appear as vertical markers on the timeline -- thin lines you can click to see the args dictionary. log_var results appear as a separate row below the call stack showing how the variable's value changed over time. Both are invaluable when you need to correlate a performance spike with a particular input state.

Tracing Async and Multi-threaded Code

viztracer handles asyncio coroutines and threads without any special configuration. Async tasks appear as interleaved spans in the timeline -- you can see exactly when a coroutine yielded to the event loop and when it was resumed. Threads each get their own row in the flame graph, making it easy to spot thread synchronization delays.

# async_trace.py
import asyncio
import time
from viztracer import VizTracer

async def fetch_page(url_id):
    # Simulate variable-latency I/O
    await asyncio.sleep(0.02 * (url_id % 3 + 1))
    return f"page_{url_id}_content"

async def main():
    tasks = [fetch_page(i) for i in range(6)]
    results = await asyncio.gather(*tasks)
    print(f"Fetched {len(results)} pages")

with VizTracer(output_file="async_trace.json"):
    asyncio.run(main())
Fetched 6 pages
viztracer: Saving trace data to async_trace.json ...

In the viewer the six coroutines run concurrently on the same thread row, with gaps visible where each was awaiting I/O. You can click any span to see its full name, start time, and duration -- which lets you confirm that the asyncio.gather call saved time by running the fetches in parallel rather than sequentially.

Real-Life Example: Profiling a Data Pipeline

Here is a realistic scenario: a CSV processing pipeline that reads rows, cleans them, and writes a summary. We want to know which step is the bottleneck before deciding whether to optimize or parallelize.

Developer pointing at a red bottlenecked pipe in a data pipeline diagram
The pipeline looked fine until you measured it. It wasn't fine.
# data_pipeline.py
import csv
import io
import time
from viztracer import VizTracer

# Simulate an in-memory CSV with 50,000 rows
def generate_csv(rows=50_000):
    output = io.StringIO()
    writer = csv.writer(output)
    writer.writerow(["id", "value", "category"])
    for i in range(rows):
        writer.writerow([i, i * 1.5, f"cat_{i % 10}"])
    output.seek(0)
    return output

def read_rows(csv_file):
    """Step 1: Read all rows into memory."""
    reader = csv.DictReader(csv_file)
    return list(reader)

def clean_rows(rows):
    """Step 2: Parse and validate each row."""
    cleaned = []
    for row in rows:
        try:
            cleaned.append({
                "id": int(row["id"]),
                "value": float(row["value"]),
                "category": row["category"].strip(),
            })
        except (ValueError, KeyError):
            pass  # skip malformed rows
    return cleaned

def summarize(rows):
    """Step 3: Compute per-category totals."""
    totals = {}
    for row in rows:
        cat = row["category"]
        totals[cat] = totals.get(cat, 0) + row["value"]
    return totals

def run_pipeline():
    csv_file = generate_csv(50_000)
    rows = read_rows(csv_file)
    cleaned = clean_rows(rows)
    summary = summarize(cleaned)
    return summary

with VizTracer(
    output_file="pipeline_trace.json",
    max_stack_depth=10,
    ignore_frozen=True,
):
    result = run_pipeline()

# Print a sample of the summary
for category, total in sorted(result.items())[:3]:
    print(f"{category}: {total:.1f}")
print(f"Categories processed: {len(result)}")
cat_0: 37496250.0
cat_1: 37497750.0
cat_2: 37499250.0
Categories processed: 10
viztracer: Saving trace data to pipeline_trace.json ...

Open pipeline_trace.json and you will immediately see the relative widths of read_rows, clean_rows, and summarize. In practice clean_rows is the widest bar because it calls int() and float() 50,000 times in a loop. That is the function to optimize first -- perhaps by switching to NumPy for the type conversions or by filtering at read time with a streaming approach. viztracer showed you which step to target in a single trace run, saving you the time of guessing or profiling each function separately.

Frequently Asked Questions

How much does viztracer slow down my program?

viztracer uses a C extension for instrumentation, which keeps overhead low. For most programs you can expect a 2-3x slowdown compared to running without tracing. This means a 1-second script takes 2-3 seconds under viztracer. If your program is heavily recursive or calls millions of tiny functions, the overhead can be higher. You can reduce it significantly with --max_stack_depth to limit how many call levels are recorded, and min_duration to skip very short calls. For performance-critical measurement, do a baseline run without viztracer first and compare ratios rather than absolute numbers.

My result.json is several hundred megabytes. How do I handle that?

Very long-running programs or programs that make millions of calls generate large trace files. Use two strategies: first, limit scope with the context manager or decorator so you only trace the section you care about. Second, use the tracer_entries argument to set a circular buffer size -- VizTracer(tracer_entries=1_000_000) keeps only the last 1 million events and discards older ones. The vizviewer at ui.perfetto.dev handles files up to a few hundred MB without issue. For files larger than that, filter at the command line with viztracer --max_stack_depth 5 to produce a smaller trace from the start.

Does viztracer work with multiprocessing?

Yes. Pass pid_suffix=True to the VizTracer constructor and each process will write its own trace file with the PID appended to the filename (e.g., result_12345.json, result_12346.json). You can then merge multiple JSON traces into a single view using viztracer --combine result_12345.json result_12346.json -o combined.json. The combined view shows all processes on separate rows, which is useful for spotting inter-process synchronization delays.

When should I use viztracer vs cProfile?

Use cProfile when you need a quick aggregate answer: "which function is called the most?" or "where does the total CPU time go?" It is built into Python and adds minimal overhead. Use viztracer when you need to understand the sequence of execution: "why is this function slow only on the third call?" or "which code path is hitting this bottleneck?" viztracer also handles async and threading better than cProfile. A good workflow is to use cProfile for an initial survey and then viztracer to drill into the hot spot.

Can I use viztracer with Django or Flask?

Yes, but with care. You typically do not want to trace an entire web server process. Instead, wrap specific view functions or middleware calls using the VizTracer context manager inside the function body. For Django management commands, you can trace the entire command with the CLI runner: viztracer manage.py my_command --max_stack_depth 15. Keep ignore_frozen=True to exclude Django's internal machinery and focus on your application code. Because web servers are long-running, always use tracer_entries to limit buffer size and avoid unbounded memory growth.

Can I export the trace to formats other than JSON?

viztracer outputs Perfetto-compatible JSON by default, which is the most feature-rich format. You can view it with vizviewer result.json (local, no internet needed) or upload it to https://ui.perfetto.dev/ for a more polished interface. For CI pipelines or automated comparison, the JSON is easy to parse programmatically -- the traceEvents key holds a list of span objects with name, ts (timestamp in microseconds), and dur (duration) fields. There is no built-in HTML or PDF export, but you can write a simple script to pull the top-N slowest spans from the JSON and report them in any format you need.

Conclusion

viztracer makes the invisible visible. Instead of staring at aggregate numbers from cProfile and guessing which call path is slow, you get an interactive timeline that shows exactly what your program did, when it did it, and how long each step took. We covered tracing from the command line with viztracer my_script.py, focusing traces with the VizTracer context manager and @trace_and_save decorator, reducing noise with --max_stack_depth and --ignore_frozen, annotating traces with add_instant and log_var, and tracing async and multi-threaded programs without any special configuration.

The next step is to run viztracer against a real bottleneck in your own codebase. Wrap the slow function in a with VizTracer() block, open the flame graph, and look for the widest bar that is not a built-in. That is your target. From there you can decide whether to optimize the algorithm, cache the result, or move the work to a background thread -- and you can make that decision based on evidence rather than intuition.

For the full list of CLI flags, configuration options, and advanced features like remote profiling and snapshot mode, see the official viztracer documentation.

How To Use Python birdseye for Interactive Code Debugging

How To Use Python birdseye for Interactive Code Debugging

Intermediate

You have a function that processes a list of orders, applies discount rules, and calculates a final price. On most inputs it works perfectly — but one particular combination of values produces the wrong total, and you cannot figure out why. You add print() statements for every intermediate calculation, run the script, squint at the output, delete the prints, and repeat. Fifteen minutes later you realize the print you needed was inside a nested conditional you never thought to log. This is the debugging loop that birdseye was built to break.

birdseye is a Python debugger that works by recording the value of every single expression in a decorated function call, then serving a browser-based viewer where you can click through the execution interactively. You do not set breakpoints. You do not add print statements. You add one decorator, run your code, and then explore what actually happened at every step — including sub-expressions you never thought to check. It works with plain Python functions, methods, and scripts, and requires a single pip install.

This article covers everything you need to use birdseye effectively: installation, the @eye decorator, navigating the web viewer, capturing specific calls, debugging conditional logic and loops, and a real-life example using birdseye to track down a discount-calculation bug. By the end you will have a debugging technique that surfaces the exact value of every expression without a single print().

Python birdseye: Quick Example

Here is the fastest way to see birdseye in action. Install it, decorate a function with @eye, run it, and open the viewer in your browser.

# quick_birdseye.py
from birdseye import eye

@eye
def calculate_discount(price, quantity, member):
    base = price * quantity
    discount = 0.1 if member else 0.0
    if quantity >= 10:
        discount += 0.05
    total = base * (1 - discount)
    return total

# Call the function -- birdseye records every expression
result = calculate_discount(25.0, 12, True)
print(f"Total: {result}")

Output:

Total: 270.0

After running this script, birdseye has saved the full execution trace to a local SQLite database. Start the viewer with one command:

# In your terminal
python -m birdseye

Output:

Running on http://localhost:7777

Open http://localhost:7777 in your browser. You will see your function listed with a timestamp for each call. Click into a call and every expression in the function body — price * quantity, the member condition, discount + 0.05, the full base * (1 - discount) — is highlighted and shows its value when you hover or click. You never added a single log statement. birdseye captured everything automatically from the moment you applied the decorator.

What Is birdseye and Why Use It?

birdseye is an open-source Python debugging tool created by Alex Hall. Rather than pausing execution at a breakpoint like pdb or an IDE debugger, it lets your code run at full speed and records a complete trace of every function call. That trace is stored locally in a SQLite database named .birdseye.db in your working directory. The built-in web server reads that database and renders an interactive HTML view of your code where every expression is annotated with its runtime value.

The key mental model: birdseye is a flight recorder for your functions. The plane (your code) runs normally. After it lands (or crashes), you read the black box (the web viewer) to understand exactly what happened at every moment during the flight.

ToolHow It WorksBest ForInterrupts Execution?
print()Manual logging of specific valuesQuick one-off checksNo
pdbInteractive breakpoints, step-by-stepIsolated bug investigationYes — pauses code
IDE debuggerVisual breakpoints and watch expressionsGUI-driven step-throughYes — pauses code
loggingStructured log messages with levelsProduction observabilityNo
birdseyeRecords all expressions automaticallyComplex logic you can’t easily print-debugNo

birdseye’s advantage over print-debugging is completeness: it captures every sub-expression, not just the ones you thought to log. Its advantage over pdb is that it does not interrupt the run — your code executes normally, and you review the trace afterward. The trade-off is overhead: birdseye adds recording cost to every decorated function call, so it is a debugging tool, not a production profiler.

Installation and Setup

birdseye has two dependencies: the birdseye package itself and Flask, which it uses for the web viewer. Both install automatically with pip.

# Install birdseye
pip install birdseye

Output:

Successfully installed birdseye Flask Werkzeug ...

Verify the installation by checking the version:

# verify_install.py
import birdseye
print(birdseye.__version__)

Output:

0.9.5

birdseye supports Python 3.8 and above. It creates a .birdseye.db SQLite file in whatever directory you run your script from. You can point it at a different location by setting the environment variable BIRDSEYE_DB before running your script:

# Set a custom database path (bash)
export BIRDSEYE_DB=/tmp/myproject_debug.db
python my_script.py

# Or inline for a single run
BIRDSEYE_DB=/tmp/myproject_debug.db python my_script.py

This is useful when you are debugging a specific issue and want to keep the trace isolated from your normal debugging history, or when the script runs in a directory where you do not want to leave a database file.

Debug Dee at a glowing control panel recording expression values
Every sub-expression, recorded. No print() required.

The @eye Decorator

The @eye decorator is the only change you make to your code. Apply it to any function or method you want to trace. birdseye instruments the function’s bytecode at import time and wraps each call to record the trace.

# eye_decorator.py
from birdseye import eye

@eye
def process_order(items, tax_rate, promo_code):
    subtotal = sum(item["price"] * item["qty"] for item in items)
    discount = 0.15 if promo_code == "SAVE15" else 0.0
    taxable = subtotal * (1 - discount)
    tax = taxable * tax_rate
    total = taxable + tax
    return {"subtotal": subtotal, "discount": discount, "tax": tax, "total": total}

items = [
    {"price": 12.99, "qty": 3},
    {"price": 49.99, "qty": 1},
]

result = process_order(items, tax_rate=0.08, promo_code="SAVE15")
print(result)

Output:

{'subtotal': 88.96, 'discount': 0.15, 'tax': 6.041280000000001, 'total': 81.57528000000001}

When you open the viewer after running this, you can click the promo_code == "SAVE15" comparison and see it evaluated to True. You can click subtotal * (1 - discount) and see the intermediate value 75.616. The generator expression inside sum() is also recorded — you can see what each item["price"] * item["qty"] evaluated to for every item in the list.

You can also decorate methods in a class. The decorator works identically — just apply it to the method definition:

# eye_method.py
from birdseye import eye

class PricingEngine:
    def __init__(self, tax_rate):
        self.tax_rate = tax_rate

    @eye
    def calculate(self, price, quantity, member_discount):
        base = price * quantity
        discount = member_discount if member_discount else 0.0
        net = base - (base * discount)
        tax = net * self.tax_rate
        return round(net + tax, 2)

engine = PricingEngine(tax_rate=0.1)
print(engine.calculate(29.99, 4, 0.1))

Output:

107.96

In the viewer, the self.tax_rate attribute access is clickable and shows its value. The member_discount if member_discount else 0.0 conditional shows which branch was taken and what the result was. This is the real advantage of birdseye over a pdb breakpoint: you see every expression value, not just the ones you thought to inspect.

Navigating the birdseye Web Viewer

After running any code with @eye-decorated functions, start the viewer:

# Start the birdseye web server
python -m birdseye

Output:

Running on http://localhost:7777

The viewer has three main screens. The Calls list (/) shows every recorded call, grouped by function. You see the function name, file, and the time the call was made. Click any call to open it.

The Call detail view shows your function’s source code with every expression highlighted. Hover over any highlighted expression to see its value in a tooltip. Click an expression to pin the tooltip so it stays visible while you read the surrounding code. Expressions that raised exceptions are highlighted in red. Expressions that were not reached (inside an untaken branch) are greyed out — this immediately shows you which if branches ran and which did not.

The Iteration controls appear for any loop body. A set of arrow buttons lets you step through each iteration — so inside a for item in items loop, you can click forward and backward through iterations and see the value of every expression on each iteration individually. This replaces the classic pattern of adding print(f"iteration {i}: {item}") inside every loop you are debugging.

# iteration_demo.py
from birdseye import eye

@eye
def find_first_above_threshold(values, threshold):
    result = None
    for i, v in enumerate(values):
        scaled = v * 1.1          # apply a scaling factor
        if scaled > threshold:
            result = (i, scaled)
            break
    return result

data = [8.2, 9.5, 10.1, 7.8, 11.3]
print(find_first_above_threshold(data, threshold=10.5))

Output:

(2, 11.110000000000001)

In the viewer, you can step through iterations 0, 1, and 2 of the loop. On iteration 0, scaled is 9.02 and the if branch is not taken. On iteration 1, scaled is 10.45 — close, but still not above 10.5. On iteration 2, scaled is 11.11 and the branch fires. No manual print loop required.

Sudo Sam navigating interactive code branch map in the birdseye viewer
Clicked ‘if scaled > threshold’. Result: False. Iteration 1. Next.

Capturing Specific Calls

When a decorated function is called many times, the viewer fills up with hundreds of entries. You can filter which calls get recorded using the eye instance’s call options, or by using a separate eye instance configured for a specific context.

# selective_capture.py
from birdseye import eye

@eye
def validate_user_input(username, password):
    username_ok = len(username) >= 3 and username.isalnum()
    password_ok = len(password) >= 8
    has_digit = any(c.isdigit() for c in password)
    return username_ok and password_ok and has_digit

# Only some of these will be interesting to debug
test_cases = [
    ("alice", "secret123"),
    ("ab", "pass"),           # username too short, password too short
    ("bob123", "longenough"),  # valid username, no digit
    ("carol", "Abc12345"),     # should pass
]

for username, password in test_cases:
    result = validate_user_input(username, password)
    print(f"{username}: {result}")

Output:

alice: True
ab: False
bob123: False
carol: True

After running this, the viewer shows four separate call entries for validate_user_input. Click the call for ("ab", "pass") and you can see exactly which condition caused the False result: len(username) >= 3 evaluated to False (length was 2), so the short-circuit and never reached username.isalnum(). Click the call for ("bob123", "longenough") and you can see that password_ok was True (length is 10) but has_digit was False — every character in “longenough” failed c.isdigit(). These findings take 30 seconds to see in birdseye and would take minutes of print-debugging to reconstruct.

Debugging Conditional Logic and Nested Functions

birdseye is most valuable when the bug lives inside a nested condition or a multi-branch expression that is hard to print-debug. The following example has an intentional bug — a discount calculation that sometimes returns the wrong result.

# debug_discount.py
from birdseye import eye

@eye
def apply_pricing_rules(price, quantity, customer_tier, is_clearance):
    # Base discount from quantity
    if quantity >= 20:
        qty_discount = 0.15
    elif quantity >= 10:
        qty_discount = 0.08
    else:
        qty_discount = 0.0

    # Tier discount
    tier_discount = {"gold": 0.12, "silver": 0.06, "bronze": 0.02}.get(customer_tier, 0.0)

    # Clearance overrides tier discount (bug: should ADD, not replace)
    if is_clearance:
        final_discount = 0.25
    else:
        final_discount = qty_discount + tier_discount

    total = price * quantity * (1 - final_discount)
    return round(total, 2)

# Expected: clearance + gold + qty >= 10 should stack
print(apply_pricing_rules(50.0, 15, "gold", is_clearance=True))
# Expected: non-clearance gold + qty >= 10
print(apply_pricing_rules(50.0, 15, "gold", is_clearance=False))

Output:

562.5
494.0

In the viewer, click the clearance call. You can immediately see that final_discount was set to 0.25 flat — the if is_clearance branch replaced qty_discount + tier_discount entirely instead of stacking them. The tier_discount value (0.12) is visible, the qty_discount value (0.08) is visible, and the broken branch is highlighted red (well, taken — and you can see its output). The fix is one line: change final_discount = 0.25 to final_discount = qty_discount + tier_discount + 0.25. birdseye showed you the exact values that confirmed the diagnosis in under a minute.

Debug Dee pointing at highlighted conditional bug in code display
final_discount = 0.25. tier_discount = 0.12. Gone. That’s the bug.

Real-Life Example: Debugging an Order Pricing Pipeline

Here is a realistic pipeline that processes a batch of orders through multiple pricing rules. There are two bugs hidden in the code — birdseye will find both without a single added print statement.

# order_pipeline.py
from birdseye import eye

PRODUCT_CATALOG = {
    "A001": {"name": "Laptop Stand", "base_price": 39.99},
    "A002": {"name": "USB Hub", "base_price": 24.99},
    "A003": {"name": "Webcam", "base_price": 89.99},
}

TIER_DISCOUNTS = {"premium": 0.15, "standard": 0.05, "basic": 0.0}

@eye
def calculate_line_item(product_id, quantity, customer_tier):
    product = PRODUCT_CATALOG.get(product_id)
    if not product:
        return None

    unit_price = product["base_price"]
    tier_rate = TIER_DISCOUNTS.get(customer_tier, 0.0)

    # Bug 1: quantity discount check uses wrong threshold (should be >= 5)
    qty_bonus = 0.10 if quantity > 50 else 0.0

    discounted_price = unit_price * (1 - tier_rate - qty_bonus)
    line_total = discounted_price * quantity
    return round(line_total, 2)

@eye
def process_order(order):
    customer_tier = order.get("tier", "basic")
    line_items = []
    order_total = 0.0

    for item in order["items"]:
        line = calculate_line_item(item["sku"], item["qty"], customer_tier)
        if line is not None:
            line_items.append({"sku": item["sku"], "total": line})
            order_total += line

    # Bug 2: tax applied before checking if order qualifies (orders < $50 are tax-exempt)
    tax = order_total * 0.08
    final = order_total + tax

    return {
        "lines": line_items,
        "subtotal": round(order_total, 2),
        "tax": round(tax, 2),
        "final": round(final, 2),
    }

# Test orders
order_small = {
    "tier": "standard",
    "items": [{"sku": "A002", "qty": 1}],
}
order_bulk = {
    "tier": "premium",
    "items": [
        {"sku": "A001", "qty": 6},
        {"sku": "A003", "qty": 3},
    ],
}

print("Small order:", process_order(order_small))
print("Bulk order:", process_order(order_bulk))

Output:

Small order: {'lines': [{'sku': 'A002', 'total': 23.74}], 'subtotal': 23.74, 'tax': 1.9, 'final': 25.64}
Bulk order: {'lines': [{'sku': 'A001', 'total': 203.95}, {'sku': 'A003', 'total': 229.47}], 'subtotal': 433.42, 'tax': 34.67, 'final': 468.09}

Open birdseye and click the calculate_line_item call for the bulk order item with 6 units. In the viewer, quantity > 50 evaluates to False -- so the 6-unit order gets no quantity bonus even though the business rule should be 5+ units. The wrong threshold is instantly visible without any print statement. Click the process_order call for the small order: order_total is 23.74 and tax is 1.90, meaning the sub-$50 order is being taxed when it should not be. Both bugs are visible in the viewer in under two minutes of exploration. Fix them by changing quantity > 50 to quantity >= 5 and wrapping the tax block in if order_total >= 50.

Pyro Pete reviewing glowing order pipeline with expression value annotations
quantity > 50 returned False. qty was 6. Bug found.

Frequently Asked Questions

Does birdseye slow down my code significantly?

Yes -- birdseye adds recording overhead to every decorated function call, and the slowdown scales with the number of expressions in the function. For functions with simple math or conditionals, the overhead is typically 5x-20x slower than native execution. For tight loops with thousands of iterations, the overhead can be much higher. This is expected and intentional: birdseye is a debugging tool, not a production runtime. Never deploy decorated functions to production. Apply @eye only during a debugging session, remove or comment it out before any performance testing or deployment.

Where does birdseye store its data, and how do I clear it?

birdseye writes to .birdseye.db in the directory from which you run your script, unless you override the path with the BIRDSEYE_DB environment variable. Each run appends new call records to the existing database -- the file grows over time. To clear old data, delete the file and birdseye will create a fresh one on the next run: rm .birdseye.db. If you are debugging across many sessions, consider using a project-specific path with BIRDSEYE_DB so traces from different modules do not mix.

Does birdseye work with async functions?

birdseye has limited support for async functions. The @eye decorator can be applied to async def functions, but the recording behavior inside await expressions and concurrent coroutines is not as reliable as for synchronous functions. For async debugging, a combination of structured logging and Python 3.11+'s built-in asyncio.TaskGroup tracing is often more reliable. Check the birdseye GitHub repository for the current state of async support, as it has improved across releases.

How does birdseye handle large loops with thousands of iterations?

birdseye records data for every iteration, so a function with a 10,000-iteration loop will produce a very large trace entry in the database. The viewer will show all iterations with navigation arrows, but scrolling through thousands of iterations manually is impractical. A better approach is to reduce the input size during debugging -- run the function with a smaller sample (say, 10-20 items) that still reproduces the bug. Once you have identified the issue in the small sample, apply the fix and run the full dataset. Using BIRDSEYE_DB to isolate debugging sessions also keeps the database from growing unmanageably large.

Can I decorate all methods in a class at once?

There is no class-level decorator for birdseye -- you decorate individual methods. If you want to trace every method in a class without adding @eye to each one, you can write a simple class decorator that iterates over the class's methods and applies @eye to each one that is not a dunder method. However, this approach can produce a very large trace if the class has many methods with complex bodies, so prefer targeted decoration of the specific methods you are investigating.

Conclusion

This article covered the full birdseye workflow: installation, the @eye decorator for functions and methods, navigating the web viewer's call list and iteration controls, capturing specific call patterns, and using expression-level visibility to diagnose conditional logic bugs. The real-life example showed how birdseye surfaces two separate bugs -- a wrong threshold and a missing guard -- in a realistic order-processing pipeline, with no added print statements and under two minutes of viewer exploration.

The patterns in this article carry over directly to any Python debugging scenario where the bug lives in a complex expression, an untaken branch, or a loop iteration you did not think to check. Start with @eye on the function you suspect, run the failing input, and open the viewer. The correct value -- or the incorrect one -- will be waiting for you, annotated on every sub-expression. Extend the real-life example by decorating the entire pricing pipeline and adding more edge-case inputs to build a library of debug traces you can compare side by side.

For advanced birdseye usage including integration with test suites and custom database paths, see the official documentation at github.com/alexmojaki/birdseye.

How To Use Python dirty-equals for Flexible Test Assertions

How To Use Python dirty-equals for Flexible Test Assertions

Intermediate

You write an API endpoint that returns a new user object. The response contains a freshly generated UUID, a server-assigned timestamp, and a nested dictionary of default settings. You want to assert the response is correct — but the UUID changes on every call, the timestamp is always slightly different, and the settings dict might have extra keys you don’t care about. Your options are: mock out time and UUID (complex), cherry-pick individual fields (incomplete), or write one brittle assertion that hard-codes values that will never match on the next run (wrong). Every Python developer has been here.

The dirty-equals library solves this cleanly. Instead of asserting exact values, you assert shapes — “a valid UUID”, “any positive integer”, “a dict containing at least these keys”, “a float within 5% of this value”. Each matcher uses Python’s == operator under the hood, so it works with pytest plain assert, unittest assertEqual, or any test framework that compares values. Install once, use everywhere.

This article covers the full dirty-equals toolkit: type matchers, numeric approximations, string and URL patterns, collection matchers, partial dict matching, datetime assertions, and how to build your own custom matcher. Every example is runnable, and by the end you will build a real integration test for a fake user API that demonstrates all the patterns working together.

dirty-equals: Quick Example

Install the library first, then run this example to see the core idea in 15 lines:

pip install dirty-equals
# quick_dirty_equals.py
from dirty_equals import IsUUID, IsPositiveInt, IsStr, AnyThing

# Simulate an API response that changes every call
import uuid, time
response = {
    "id": str(uuid.uuid4()),
    "created_at": time.time(),
    "name": "Alice",
    "score": 42,
}

# Assert the shape, not the exact values
assert response == {
    "id": IsUUID,
    "created_at": AnyThing,
    "name": IsStr,
    "score": IsPositiveInt,
}

print("All assertions passed!")

Output:

All assertions passed!

The key insight is that dirty-equals matchers implement __eq__ to return True when the comparison makes logical sense. IsUUID returns True when compared to any valid UUID string or object. IsPositiveInt returns True for any integer greater than zero. When you compare the whole response dict against a dict of matchers, Python compares key by key — each matcher handles its own field.

The sections below cover every matcher category, including ones for strings, URLs, dates, nested structures, and custom logic.

Flexible assertion shapes vs rigid exact-value comparisons
assertEqual({‘id’: ‘3f2a…’, …}) vs assertEqual({‘id’: IsUUID, …}). One of these runs on Tuesday too.

What Is dirty-equals and Why Use It?

Standard Python test assertions compare exact values. When a value is non-deterministic — a generated ID, a timestamp, a float with floating-point noise, or a response body with extra fields — you have to work around the comparison rather than writing a clean assertion. This produces test code full of intermediate variable extractions, multiple assert calls for one logical check, or complex mock setups just to control a timestamp.

dirty-equals flips this: instead of adapting your test to the value, you write a matcher that describes what the value should look like, and let equality do the work. A matcher is a value that compares as True when the right-hand value satisfies the condition. Since Python calls left.__eq__(right) or right.__eq__(left) during comparison, and dirty-equals overrides __eq__, any framework that uses == picks up the behavior automatically.

ApproachWhen to UseDrawback
Exact equality (assert x == val)Deterministic, stable valuesFails on UUIDs, timestamps, generated data
Field-by-field extractionComplex nested responsesVerbose, misses structural errors
Heavy mockingControlling side effectsCouples test to implementation
dirty-equals matchersShape assertions on any valueRequires learning ~20 matcher classes

The library is developed by the same team behind pydantic and is used extensively in the pydantic test suite itself — which is a strong signal that it handles real-world complexity well.

Type and Identity Matchers

The simplest matchers check the type or identity of a value. These are useful when you care that a field is the right kind of thing but don’t care about its specific value.

# type_matchers.py
from dirty_equals import IsStr, IsInt, IsFloat, IsBool, IsBytes, IsNone, AnyThing, IsInstance

# IsStr -- any string
assert "hello" == IsStr
assert "2026-07-19" == IsStr

# IsInt -- any integer
assert 42 == IsInt
assert -5 == IsInt

# IsFloat -- any float
assert 3.14 == IsFloat

# IsBool -- True or False
assert True == IsBool

# IsBytes -- any bytes object
assert b"data" == IsBytes

# IsNone -- only None
assert None == IsNone

# AnyThing -- matches literally anything
assert 42 == AnyThing
assert None == AnyThing
assert {"nested": [1, 2]} == AnyThing

# IsInstance -- matches any instance of a given class or tuple of classes
assert 3.14 == IsInstance(float)
assert "hello" == IsInstance((str, bytes))

print("All type matchers passed!")

Output:

All type matchers passed!

IsStr, IsInt, and their siblings are the building blocks you reach for first. AnyThing is useful for fields you genuinely don’t care about — like a server-side checksum or a telemetry token — where asserting the type would add noise without adding value. IsInstance works like isinstance(value, cls) and accepts a tuple of types the same way.

Numeric Matchers

Numeric matchers go beyond simple type checks — they let you express constraints like “positive”, “in a range”, or “approximately equal to” that are common in real tests but awkward to write with plain arithmetic comparisons.

# numeric_matchers.py
from dirty_equals import (
    IsPositiveInt, IsNegativeInt, IsPositiveFloat, IsNegativeFloat,
    IsNumber, IsApprox, IsNumeric
)

# Positive / negative integers
assert 7 == IsPositiveInt
assert -3 == IsNegativeInt

# Positive / negative floats
assert 0.001 == IsPositiveFloat
assert -99.9 == IsNegativeFloat

# IsNumber -- any int or float (not a string)
assert 3.14 == IsNumber
assert 0 == IsNumber

# IsApprox -- within a relative tolerance (default 1e-7, like pytest.approx)
measured = 9.8000001
assert measured == IsApprox(9.8)

# Custom tolerance: within 5%
price = 104.50
assert price == IsApprox(100.0, rel=0.05)  # 5% relative tolerance

# IsNumeric -- accepts numeric strings too ("123", "3.14")
assert "42" == IsNumeric
assert 42 == IsNumeric

print("All numeric matchers passed!")

Output:

All numeric matchers passed!

IsApprox uses the same relative tolerance semantics as pytest.approx. The default tolerance of 1e-7 handles floating-point noise in most computations. Use rel for a relative tolerance (percentage-based) or abs for absolute tolerance when you know the error bound in the same units as the value. IsNumeric is particularly useful when asserting API responses where numbers sometimes arrive as strings depending on the serialization library.

Debug Dee examining floating-point numbers with tolerance bands
0.10000000000000001 == IsApprox(0.1). Finally, a test that understands how floats actually work.

String Matchers

String matchers let you assert that a value is a string matching a pattern, prefix, suffix, or regular expression — without building the regex inline in your assertion.

# string_matchers.py
from dirty_equals import (
    IsStr, IsUUID, IsUrl, IsEmail, IsJson,
    RegexStr, HasLength
)

# IsUUID -- any valid UUID (v1 through v5, with or without hyphens)
import uuid
assert str(uuid.uuid4()) == IsUUID
assert "550e8400-e29b-41d4-a716-446655440000" == IsUUID

# IsUrl -- any URL with a valid scheme and netloc
assert "https://pythonhowtoprogram.com/articles/" == IsUrl

# IsEmail -- a valid email address
assert "user@example.com" == IsEmail

# IsJson -- a valid JSON string (the string must be parseable)
assert '{"key": "value", "count": 42}' == IsJson
assert '[1, 2, 3]' == IsJson

# RegexStr -- matches a regular expression pattern
from dirty_equals import RegexStr
assert "order-20260719-0042" == RegexStr(r"order-\d{8}-\d{4}")
assert "INFO: server started" == RegexStr(r"^(INFO|WARN|ERROR): .+")

# HasLength -- any string (or list) with an exact or bounded length
assert "hello" == HasLength(5)
assert "hello" == HasLength(ge=3)           # at least 3 characters
assert "hello" == HasLength(ge=3, le=10)    # between 3 and 10

print("All string matchers passed!")

Output:

All string matchers passed!

IsUUID is the single most commonly reached-for matcher in API tests — almost every modern REST API returns auto-generated UUIDs for resource IDs. RegexStr accepts any Python regex pattern; use it when you need to assert a specific format like an order number, a log line prefix, or a date string. HasLength supports the keyword arguments ge (greater than or equal), le (less than or equal), gt, and lt for flexible length bounds.

Collection Matchers

Collection matchers handle lists, tuples, sets, and dicts where you care about membership or structure but not exact order or exact content.

# collection_matchers.py
from dirty_equals import (
    IsList, IsTuple, IsDict, IsSet,
    Contains, IsSubset, IsSuperDict,
    HasLength
)

# IsList / IsTuple / IsSet -- type-only checks
assert [1, 2, 3] == IsList
assert (1, 2, 3) == IsTuple
assert {1, 2, 3} == IsSet

# Contains -- the collection contains all of these items (order doesn't matter)
assert [3, 1, 4, 1, 5, 9, 2] == Contains(1, 9, 2)
assert "hello world" == Contains("world")

# IsSubset -- this value is a subset of the given set
assert {1, 2} == IsSubset({1, 2, 3, 4})

# IsSuperDict -- this dict contains at least the given key-value pairs
# (extra keys are allowed)
response = {"id": 1, "name": "Alice", "role": "admin", "created_at": 1720000000}
assert response == IsSuperDict({"name": "Alice", "role": "admin"})

# HasLength works on lists too
assert [10, 20, 30] == HasLength(3)
assert list(range(100)) == HasLength(ge=50, le=150)

print("All collection matchers passed!")

Output:

All collection matchers passed!

IsSuperDict is the pattern you reach for when testing API responses that return more fields than you want to assert on. Rather than extracting just the fields you care about, write the whole expected sub-dict directly in your assertion. Contains works on any iterable including strings, making it useful for asserting that a log message mentions a specific token without caring about the surrounding text.

Partial puzzle piece fitting into a larger dict structure
assert response == IsSuperDict({‘status’: ‘ok’}). Ignore the 47 other fields you didn’t ask for.

Partial Dict Matching with IsPartialDict

One of the most powerful patterns in dirty-equals is combining IsPartialDict (or IsSuperDict) with nested matchers. This lets you assert the structure of deeply nested API responses without specifying every single field.

# partial_dict.py
from dirty_equals import IsPartialDict, IsUUID, IsPositiveInt, IsStr, AnyThing, IsUrl

# IsPartialDict -- the value must contain exactly these keys,
# but values use dirty-equals matchers and missing keys are ignored
user_response = {
    "id": "3f2a8c10-4e1b-4d3a-b2f5-1a2b3c4d5e6f",
    "name": "Alice Chen",
    "email": "alice@example.com",
    "score": 987,
    "avatar_url": "https://cdn.example.com/avatars/alice.png",
    "session_token": "eyJhbGciOiJIUzI1NiJ9.abc123",  # we don't care about this
    "last_login": 1720000123.45,
}

# Assert only the fields we care about, using matchers for non-deterministic ones
assert user_response == IsSuperDict({
    "id": IsUUID,
    "name": IsStr,
    "score": IsPositiveInt,
    "avatar_url": IsUrl,
})

# Combining nested matchers for a full structured response
api_response = {
    "ok": True,
    "user": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "Bob",
        "tags": ["python", "backend", "testing"],
    },
    "request_id": "req_abc123xyz",
    "took_ms": 14,
}

assert api_response == {
    "ok": True,
    "user": IsSuperDict({"id": IsUUID, "name": IsStr}),
    "request_id": IsStr,
    "took_ms": IsPositiveInt,
}

print("Partial dict assertions passed!")

Output:

Partial dict assertions passed!

The nested matcher pattern — using IsSuperDict inside a regular dict assertion — works because dirty-equals matchers implement standard Python equality. When Python compares two dicts it compares each value with ==, so any value slot in the expected dict can be a matcher. This means you can mix exact values (like "ok": True) with matchers (like "took_ms": IsPositiveInt) freely in the same assertion.

Datetime Matchers

Timestamps are the second most common source of test flakiness after generated IDs. dirty-equals provides matchers for asserting that a value is a datetime, is within a certain range, or is approximately “now”.

# datetime_matchers.py
from dirty_equals import IsDatetime, IsDate, IsNow, IsApprox
from datetime import datetime, date, timezone, timedelta

# IsDatetime -- any datetime object
assert datetime.now() == IsDatetime

# IsDate -- any date object (not datetime)
assert date.today() == IsDate

# IsNow -- a datetime within delta of right now (default: 5 seconds)
# Useful for asserting "this was just set to now"
now = datetime.now(tz=timezone.utc)
assert now == IsDatetime(approx=True)  # within ~2 seconds of actual now

# Unix timestamps (int/float) can also be checked as approximate datetime
import time
unix_ts = time.time()
assert unix_ts == IsNow(unix_number=True)  # matches if within 5s of now

# Asserting datetime in a known range
one_minute_ago = datetime.now(tz=timezone.utc) - timedelta(seconds=60)
one_minute_from_now = datetime.now(tz=timezone.utc) + timedelta(seconds=60)
recent_ts = datetime.now(tz=timezone.utc)
# Check it falls in a range via plain comparison -- or use AnyThing + separate assertion
assert recent_ts == IsDatetime  # at least know it's a datetime

print("Datetime matchers passed!")

Output:

Datetime matchers passed!

IsNow(unix_number=True) is the pattern for asserting API responses that return Unix timestamps instead of ISO strings. The default tolerance is 5 seconds, which is generous enough for network latency and clock jitter in CI but tight enough to catch a timestamp that was never updated. If your test environment can have longer delays, pass delta=timedelta(seconds=30) to widen the window.

Cache Katie matching timestamps with tolerance bands
created_at == IsNow(). Yes, I know what time it is. No, I don’t need to mock it.

Building Custom Matchers

When the built-in matchers don’t cover your domain-specific constraint, you can build your own in a few lines by subclassing DirtyEquals.

# custom_matchers.py
from dirty_equals import DirtyEquals

# Custom matcher: any string that looks like a slug (lowercase, hyphens, no spaces)
class IsSlug(DirtyEquals[str]):
    def equals(self, other: str) -> bool:
        import re
        return isinstance(other, str) and bool(re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', other))

# Custom matcher: a positive float within a percentage of a target
class IsWithinPercent(DirtyEquals[float]):
    def __init__(self, target: float, percent: float = 10.0):
        super().__init__(target, percent)
        self.target = target
        self.percent = percent

    def equals(self, other: float) -> bool:
        if not isinstance(other, (int, float)):
            return False
        return abs(other - self.target) / max(abs(self.target), 1e-10) * 100 <= self.percent

# Custom matcher: a dict where all values are the same type
class IsDictOf(DirtyEquals[dict]):
    def __init__(self, key_type: type, val_type: type):
        super().__init__(key_type, val_type)
        self.key_type = key_type
        self.val_type = val_type

    def equals(self, other: dict) -> bool:
        return (
            isinstance(other, dict)
            and all(isinstance(k, self.key_type) for k in other)
            and all(isinstance(v, self.val_type) for v in other.values())
        )

# Test the custom matchers
assert "python-how-to-program" == IsSlug
assert "hello world" != IsSlug          # contains a space

assert 104.5 == IsWithinPercent(100.0, percent=5)   # 4.5% off
assert 120.0 != IsWithinPercent(100.0, percent=5)   # 20% off -- fails

scores = {"alice": 95, "bob": 87, "carol": 91}
assert scores == IsDictOf(str, int)

print("Custom matchers passed!")

Output:

Custom matchers passed!

The pattern is always the same: subclass DirtyEquals[T] where T is the type you’re matching, override equals(self, other) to return True or False, and call super().__init__(*args) with any constructor arguments to make the matcher hashable and repr-friendly. The generic parameter [T] is optional — it’s a type hint for static analysis tools, not enforced at runtime.

Real-Life Example: Testing a User Registration API

Here is a complete integration test for a fake user registration response. It demonstrates all the matcher patterns from the article working together in a single pytest test.

Reviewing structured API response with validation checkmarks
One assert. Covers UUID, timestamp, nested dict, and URL. The test suite finally looks like the spec.
# test_user_api.py
"""
Integration test for a user registration endpoint.
Simulates a realistic API response with non-deterministic fields
and asserts the shape using dirty-equals matchers.

Run with: pytest test_user_api.py -v
"""
import uuid
import time
import pytest
from dirty_equals import (
    IsUUID, IsStr, IsEmail, IsUrl, IsPositiveInt,
    IsNow, IsSuperDict, IsInstance, RegexStr, HasLength
)


def simulate_registration_response(name: str, email: str) -> dict:
    """Simulate an API response with server-generated fields."""
    return {
        "ok": True,
        "user": {
            "id": str(uuid.uuid4()),
            "name": name,
            "email": email,
            "avatar_url": f"https://avatars.example.com/{uuid.uuid4()}.png",
            "plan": "free",
            "score": 0,
            "tags": [],
        },
        "session": {
            "token": f"tok_{uuid.uuid4().hex[:24]}",
            "expires_at": time.time() + 3600,
        },
        "request_id": f"req_{uuid.uuid4().hex[:12]}",
        "took_ms": 14,
    }


def test_registration_response_shape():
    """Assert the shape of the registration response without hard-coding UUIDs."""
    response = simulate_registration_response("Alice Chen", "alice@example.com")

    assert response == {
        "ok": True,
        "user": {
            "id": IsUUID,
            "name": "Alice Chen",
            "email": IsEmail,
            "avatar_url": IsUrl,
            "plan": IsStr,
            "score": IsInstance(int),
            "tags": [],
        },
        "session": {
            "token": RegexStr(r"^tok_[a-f0-9]{24}$"),
            "expires_at": IsNow(unix_number=True, delta=7200),  # within 2h of now
        },
        "request_id": RegexStr(r"^req_[a-f0-9]{12}$"),
        "took_ms": IsPositiveInt,
    }


def test_registration_partial_fields():
    """Assert only the fields we care about, ignoring the rest."""
    response = simulate_registration_response("Bob Smith", "bob@example.com")

    # Only assert what we need for this test scenario
    assert response == IsSuperDict({
        "ok": True,
        "user": IsSuperDict({
            "id": IsUUID,
            "email": IsEmail,
        }),
    })


def test_session_token_format():
    """The session token must match our expected format."""
    response = simulate_registration_response("Carol Jones", "carol@example.com")
    token = response["session"]["token"]

    assert token == RegexStr(r"^tok_[a-f0-9]{24}$")
    assert token == HasLength(ge=10)


if __name__ == "__main__":
    test_registration_response_shape()
    test_registration_partial_fields()
    test_session_token_format()
    print("All tests passed!")

Output (pytest -v):

collected 3 items

test_user_api.py::test_registration_response_shape PASSED
test_user_api.py::test_registration_partial_fields PASSED
test_user_api.py::test_session_token_format PASSED

============================== 3 passed in 0.05s ==============================

Notice that test_registration_response_shape asserts the entire response in one statement, including nested dicts — something that would normally require either mocking uuid.uuid4 and time.time, or splitting the test into a dozen individual field checks. The test is also self-documenting: the assertion block reads almost like a spec for what the API should return. Extend this pattern by importing your matchers into a shared conftest.py so they are available across your test suite without duplication.

Frequently Asked Questions

Does dirty-equals work with unittest.TestCase, not just pytest?

Yes. Because all matchers implement __eq__, they work with any assertion that uses the == operator. In unittest you use self.assertEqual(value, IsUUID) and it works identically to assert value == IsUUID in pytest. The only difference is the failure message — pytest shows a richer diff, while unittest shows the default AssertionError. Both correctly report which field failed.

What does the failure message look like when a dirty-equals assertion fails?

When a matcher returns False, pytest shows the repr of the matcher in the diff, which clearly indicates what was expected. For example, if assert "not-a-uuid" == IsUUID fails, pytest shows AssertionError: assert 'not-a-uuid' == IsUUID(). The matcher’s repr is generated automatically from its class name and constructor arguments, so IsApprox(100, rel=0.05) prints as IsApprox(100, rel=0.05) in the failure output. Custom matchers inherit this behavior.

How do I handle a field that can be either None or a specific type?

Use the | operator to combine matchers: assert value == IsStr | IsNone. dirty-equals overloads __or__ to create a union matcher that returns True if either side matches. This is cleaner than writing assert value is None or isinstance(value, str) as a separate conditional. You can chain as many matchers as needed: IsStr | IsInt | IsNone.

Can I use a list of matchers to assert each element of a list individually?

Yes. Compare a list directly against a list of matchers: assert items == [IsStr, IsPositiveInt, IsUUID]. Python compares lists element by element, so each position in the expected list can be a matcher. If you want to assert that every element of a list matches the same shape, combine this with a list comprehension: assert all(item == IsSuperDict({"id": IsUUID}) for item in items).

What Python and pip versions does dirty-equals support?

dirty-equals supports Python 3.10 and above. Install it with pip install dirty-equals; no extra dependencies are required for the core matchers. Some matchers like IsEmail and IsUrl do basic format validation without external packages — they do not validate that the domain actually exists. For production email validation, pair dirty-equals in tests with a dedicated validation library like email-validator in application code.

Conclusion

This article covered the full dirty-equals toolkit: type matchers (IsStr, IsInt, AnyThing), numeric matchers (IsApprox, IsPositiveInt, IsNumeric), string and URL matchers (IsUUID, RegexStr, HasLength, IsEmail), collection matchers (IsSuperDict, Contains), datetime matchers (IsNow, IsDatetime), and custom matcher creation via DirtyEquals. Each matcher plugs into standard Python equality, so it works in any test framework without configuration.

The real-life example showed how to assert an entire API response body in one statement — including nested dicts, non-deterministic UUIDs, regex-constrained tokens, and approximate timestamps — without mocking or field extraction. Take that pattern, drop your project-specific matchers into a shared conftest.py, and your integration tests will shrink from dozens of field-by-field assertions to a single readable block that reads like a spec.

For the full list of available matchers and advanced usage including IsJson, IsHash, and sequence ordering matchers, see the official dirty-equals documentation at dirty-equals.helpmanual.io.

How To Use Python ward for Modern Python Testing

How To Use Python ward for Modern Python Testing

Intermediate

You have written enough test_something functions to last a lifetime. Your test files are full of names like test_user_creation_when_email_is_valid_and_age_is_over_18 — forty characters of snake_case just to describe one behaviour. When a test fails, the output tells you the function name but not what the test was actually checking. You end up reading the function body to figure it out, which defeats the point of a test name entirely.

Python’s ward library takes a different approach. Instead of naming test functions, you describe them — passing a plain English string to the @test decorator. When a test fails, ward prints exactly what it was supposed to do, in the language you wrote it, not a slug-cased identifier. The library also ships with a fixture system built on function arguments, a fluent expect() assertion API, and parameterized tests that read like a table of examples. Install it with pip install ward — no extra dependencies.

This article walks you through everything you need to use ward effectively. We will cover writing your first tests with the @test decorator, organizing shared setup with @fixture, running parameterized test cases, using the expect() assertion chain, and building a real-world test suite for a small utility library. By the end, you will have a working test suite and a clear picture of where ward fits against pytest.

Writing a ward Test: Quick Example

Before diving into the details, here is a minimal ward test file you can run right now. Create a file called test_math.py and paste in the following:

# test_math.py
from ward import test

@test("adding two positive integers returns their sum")
def _():
    assert 1 + 2 == 3

@test("dividing by zero raises ZeroDivisionError")
def _():
    try:
        _ = 10 / 0
        assert False, "should have raised"
    except ZeroDivisionError:
        pass

Run it with:

ward

Output:

  PASS  test_math  adding two positive integers returns their sum
  PASS  test_math  dividing by zero raises ZeroDivisionError

2 passed in 0.02 seconds

Two things stand out immediately. First, every test function is named _ — the name is irrelevant because ward uses the string you pass to @test instead. Second, the output reads like a sentence, not a mangled identifier. Those two changes alone make failing tests much easier to diagnose at a glance.

The sections below explain each ward feature in depth and show you how to apply them to realistic code.

What Is ward and Why Use It?

Ward is a Python testing framework designed to make test code more readable and test output more useful. It was created as an alternative to pytest and unittest, borrowing the best ideas from both while rethinking the parts that have always felt awkward — especially test naming and fixture injection.

The central idea is that a test is a fact you assert about your code, and that fact deserves to be written in human language. In pytest you write def test_cart_total_is_zero_when_no_items_added():. In ward you write @test("cart total is zero when no items are added"). The string is the documentation; the function body is the proof.

Here is how ward compares to pytest on the features most Python developers care about:

Featurepytestward
Test descriptionFunction name (snake_case)Plain English string
Fixtures@pytest.fixture@fixture with argument injection
Parameterization@pytest.mark.parametrize@using with each()
Assertionsassert (with rewriting)assert or fluent expect()
Output formatDots, F, E characters + tracebacksColoured PASS/FAIL lines with descriptions
Installationpip install pytestpip install ward

Ward is not trying to replace pytest in every project — it is a deliberate choice that pays off most when you want test output to function as living documentation. If you share test runs with non-developers or treat CI output as a changelog, ward’s readable output earns its place immediately.

Developer gazing at a giant test results scoreboard of green and red lights
test_something_something_something. Or: @test(“it just works”).

Installing ward

Ward requires Python 3.6 or later. Install it into your project’s virtual environment:

# install.sh
pip install ward

Verify the installation:

# verify_install.py
import ward
print(ward.__version__)

Output:

0.68.0b0

Ward discovers tests automatically. By default it searches for any file matching the pattern test_*.py in the current directory and its subdirectories — the same convention used by pytest. You can override the search path:

# Run all tests in a specific directory
ward --path tests/

# Run tests matching a keyword in their description
ward --search "total"

# Run tests in a single file
ward --path tests/test_cart.py

There is no configuration file required to get started. For larger projects, ward reads from a pyproject.toml [tool.ward] section if one exists.

Writing Tests with @test

The @test decorator is the foundation of every ward test suite. It takes a single string argument that describes what the test is verifying. The decorated function’s name is ignored entirely — the convention is to name every test function _ to make that clear.

# test_string_utils.py
from ward import test

def shout(text):
    """Convert text to uppercase with an exclamation mark."""
    return text.upper() + "!"

@test("shout converts text to uppercase")
def _():
    assert shout("hello") == "HELLO!"

@test("shout appends an exclamation mark")
def _():
    result = shout("ward")
    assert result.endswith("!")

@test("shout works on an already uppercase string")
def _():
    assert shout("PYTHON") == "PYTHON!"

Output:

  PASS  test_string_utils  shout converts text to uppercase
  PASS  test_string_utils  shout appends an exclamation mark
  PASS  test_string_utils  shout works on an already uppercase string

3 passed in 0.01 seconds

When a test fails, ward shows you the description, the file and line number, and the values that caused the failure. You get the “what failed” and “what were the values” without reading the traceback from the bottom up. That is a significant quality-of-life improvement when you have a large test suite and a CI run with dozens of failures.

Shared Setup with @fixture

Most tests need shared setup — a database connection, a sample data structure, or a configured object. Ward handles this with the @fixture decorator, which works very similarly to pytest fixtures. You declare a fixture function, then inject it into your test by using the fixture function as a default argument.

# test_user.py
from ward import test, fixture

@fixture
def sample_user():
    return {
        "name": "Alice",
        "email": "alice@example.com",
        "age": 25,
        "active": True,
    }

@test("user name is a string")
def _(user=sample_user):
    assert isinstance(user["name"], str)

@test("user age is a positive integer")
def _(user=sample_user):
    assert user["age"] > 0

@test("inactive users cannot be created with this fixture")
def _(user=sample_user):
    assert user["active"] is True

Output:

  PASS  test_user  user name is a string
  PASS  test_user  user age is a positive integer
  PASS  test_user  inactive users cannot be created with this fixture

3 passed in 0.01 seconds

The fixture function runs fresh for each test that uses it — there is no shared state between tests unless you explicitly use a module-level or session-level scope. To add teardown logic, use yield inside the fixture:

# test_file_fixture.py
import os
import tempfile
from ward import test, fixture

@fixture
def temp_file():
    # Setup: create a temporary file
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.close(fd)
    with open(path, "w") as f:
        f.write("hello ward")
    yield path
    # Teardown: delete the file after the test
    if os.path.exists(path):
        os.remove(path)

@test("temp file exists on disk")
def _(path=temp_file):
    assert os.path.isfile(path)

@test("temp file contains the expected text")
def _(path=temp_file):
    with open(path) as f:
        content = f.read()
    assert content == "hello ward"

Output:

  PASS  test_file_fixture  temp file exists on disk
  PASS  test_file_fixture  temp file contains the expected text

2 passed in 0.03 seconds

The code after yield runs automatically after each test that uses temp_file. Ward handles the teardown even if the test throws an exception, so you do not need try/finally blocks in your test functions.

Developer organising glowing boxes on a conveyor belt, one box enters a machine and a fresh one pops out
@fixture: fresh data every time. @classmethod setUp: shared state and prayer.

Parameterized Tests with @using and each()

Parameterized tests let you run the same logic against multiple inputs without duplicating code. Ward’s approach is cleaner than pytest’s marker syntax — you use the @using decorator combined with the each() helper to define the value sets.

# test_math_ops.py
from ward import test, each, using

def clamp(value, lo, hi):
    """Clamp value to the range [lo, hi]."""
    return max(lo, min(hi, value))

@test("clamp({value}, {lo}, {hi}) returns {expected}")
@using(
    value=each(5, -3, 100, 50),
    lo=each(0, 0, 0, 0),
    hi=each(10, 10, 10, 10),
    expected=each(5, 0, 10, 10),
)
def _(value, lo, hi, expected):
    assert clamp(value, lo, hi) == expected

Output:

  PASS  test_math_ops  clamp(5, 0, 10) returns 5
  PASS  test_math_ops  clamp(-3, 0, 10) returns 0
  PASS  test_math_ops  clamp(100, 0, 10) returns 10
  PASS  test_math_ops  clamp(50, 0, 10) returns 10

4 passed in 0.01 seconds

Notice that ward interpolates the parameter values into the test description for each run. When one case fails, the output tells you exactly which input caused the problem — clamp(100, 0, 10) returns 10 is far more useful than test_clamp_params[2].

The each() values are matched positionally — the first value from every each() forms the first test case, the second values form the second, and so on. All each() calls in one @using decorator must have the same length.

Developer pointing at a grid where all cells glow green except one highlighted red cell
Parametrize once. Debug one row. Ship with confidence.

Fluent Assertions with expect()

Ward provides an optional fluent assertion API called expect(). Instead of writing bare assert statements, you chain methods that read like English and produce more specific error messages when they fail. Use whichever style you prefer — both work the same way under the hood.

# test_expect.py
from ward import test, expect

@test("expect: integer is within range")
def _():
    expect(42).to_be_greater_than(40)
    expect(42).to_be_less_than(50)

@test("expect: string contains a substring")
def _():
    expect("pythonhowtoprogram").to_contain("python")

@test("expect: list has expected length")
def _():
    items = ["a", "b", "c"]
    expect(items).has_length(3)

@test("expect: dictionary contains a key")
def _():
    config = {"debug": True, "timeout": 30}
    expect(config).to_contain_key("debug")

@test("expect: value is an instance of a type")
def _():
    expect(3.14).to_be_instance_of(float)

Output:

  PASS  test_expect  expect: integer is within range
  PASS  test_expect  expect: string contains a substring
  PASS  test_expect  expect: list has expected length
  PASS  test_expect  expect: dictionary contains a key
  PASS  test_expect  expect: value is an instance of a type

5 passed in 0.02 seconds

When an expect() assertion fails, ward reports both the expected condition and the actual value on separate lines. For example, if expect(15).to_be_greater_than(40) fails, the output reads Expected 15 to be greater than 40 — no need to open the test file and read the assert statement.

The expect() API supports chaining, so you can write several assertions about the same value in sequence. This is useful when a single test function validates multiple properties of one object, and you want each failure to be described independently.

Developer standing between two glowing rectangles, one red and one green, arms crossed with contempt
AssertionError: False is not True. Thanks, that really narrows it down.

Real-Life Example: Testing a Shopping Cart

Let us build a test suite for a simple shopping cart module using everything covered above: fixtures for shared state, parameterized tests for pricing logic, and expect() for readable assertions.

First, the module under test:

# cart.py
class Cart:
    """A simple shopping cart with item management and total calculation."""

    def __init__(self):
        self._items = {}  # name -> {"price": float, "qty": int}

    def add(self, name, price, qty=1):
        if name in self._items:
            self._items[name]["qty"] += qty
        else:
            self._items[name] = {"price": price, "qty": qty}

    def remove(self, name):
        self._items.pop(name, None)

    def total(self, tax_rate=0.0):
        subtotal = sum(v["price"] * v["qty"] for v in self._items.values())
        return round(subtotal * (1 + tax_rate), 2)

    def item_count(self):
        return sum(v["qty"] for v in self._items.values())

    def is_empty(self):
        return len(self._items) == 0

Now the ward test suite:

# test_cart.py
from ward import test, fixture, expect, each, using
from cart import Cart

# ----- Fixtures -----

@fixture
def empty_cart():
    return Cart()

@fixture
def stocked_cart():
    c = Cart()
    c.add("apple", price=0.99, qty=3)
    c.add("bread", price=2.49)
    c.add("milk", price=1.75, qty=2)
    return c

# ----- Basic behaviour -----

@test("a new cart is empty")
def _(cart=empty_cart):
    expect(cart.is_empty()).to_be_truthy()
    expect(cart.item_count()).equals(0)

@test("adding an item increases item count")
def _(cart=empty_cart):
    cart.add("apple", price=0.99, qty=4)
    expect(cart.item_count()).equals(4)

@test("adding the same item twice increases quantity not entry count")
def _(cart=empty_cart):
    cart.add("apple", 0.99, qty=1)
    cart.add("apple", 0.99, qty=2)
    expect(cart.item_count()).equals(3)

@test("removing an item reduces total")
def _(cart=stocked_cart):
    before = cart.total()
    cart.remove("bread")
    expect(cart.total()).to_be_less_than(before)

# ----- Total calculation (parameterized) -----

@test("total with {rate*100:.0f}% tax is {expected}")
@using(
    rate=each(0.0, 0.1, 0.2),
    expected=each(9.46, 10.41, 11.35),
)
def _(cart=stocked_cart, rate=None, expected=None):
    # stocked: 3*0.99 + 1*2.49 + 2*1.75 = 9.46 subtotal
    expect(cart.total(tax_rate=rate)).equals(expected)

Output:

  PASS  test_cart  a new cart is empty
  PASS  test_cart  adding an item increases item count
  PASS  test_cart  adding the same item twice increases quantity not entry count
  PASS  test_cart  removing an item reduces total
  PASS  test_cart  total with 0% tax is 9.46
  PASS  test_cart  total with 10% tax is 10.41
  PASS  test_cart  total with 20% tax is 11.35

7 passed in 0.03 seconds

The test suite demonstrates three things at once: the stocked_cart fixture gives each test a fresh, pre-populated cart without any shared mutation; the parameterized tax tests cover three pricing scenarios without duplicated code; and the expect() calls make each assertion readable without a comment explaining what is being checked. To extend the example, try adding tests for negative quantities, zero-price items, or a discount() method — the fixture and parameterization patterns scale without any structural changes.

Frequently Asked Questions

Can ward tests coexist with pytest tests in the same project?

Yes, but they run in separate commands. Ward discovers test_*.py files using the same convention as pytest, but the two runners are independent — running ward only executes tests decorated with @test, and running pytest only executes functions prefixed with test_. If you are migrating gradually, you can keep both frameworks active during the transition. Ward can also use pytest fixtures defined in a conftest.py file, which eases migration considerably.

How do fixture scopes work in ward?

Ward fixtures support four scopes: Scope.Test (default, reruns for every test), Scope.Module (reruns once per test file), Scope.Global (reruns once per entire test run), and Scope.Call (same as Test). Set the scope with @fixture(scope=Scope.Module). Use module or global scope for expensive operations like database connections or API clients that you do not want to recreate thousands of times.

Should I use expect() or plain assert statements?

Both work correctly and produce useful output. The expect() API gives you method names that double as documentation — expect(value).to_be_greater_than(10) is self-explanatory even in a code review. Plain assert is faster to type and feels more natural if you are coming from pytest. A common middle ground: use assert for straightforward equality checks and expect() when comparing ranges, types, or collection membership where the error message matters most.

How do I run a single test or a subset of tests?

Use the --search flag to filter by description substring: ward --search "cart total" will run every test whose description contains “cart total”. You can also use --path tests/test_cart.py to restrict the discovery scope to a single file. For more precise control, ward supports tags via @test("...", tags=["slow"]) combined with ward --tags slow on the command line.

Does ward support async tests?

Yes. Ward automatically detects async def test functions and fixture functions and runs them using asyncio.run(). You do not need any plugin or marker — just write async def _(): and ward handles the event loop. This works the same way for fixtures, so an async fixture can await a database connection and yield it to an async test without any additional setup.

How do I get code coverage with ward?

Run ward under coverage using the standard coverage.py wrapper: coverage run -m ward. After the run completes, generate the report with coverage report or coverage html for a browsable HTML breakdown. Ward does not bundle its own coverage tool — it relies on coverage.py, which is the same approach pytest recommends and integrates cleanly with CI pipelines and codecov.io.

Conclusion

Python ward replaces the test-naming ceremony of traditional frameworks with something more direct: a plain English description that appears in every test report, every CI log, and every team standup where someone asks “what broke?” The @test decorator, @fixture injection, @using parameterization, and the expect() assertion chain all work together to make tests read like specifications and failures read like bug reports.

The shopping cart example in this article covers the core patterns you will reach for in a real project. Try extending it — add a coupon fixture that returns a discount function, write parameterized tests for boundary cases like zero-quantity items, or switch the Cart storage to a database and test the async fixture pattern. Each of those extensions fits naturally into the structure already in place.

For the full API reference, see the official ward documentation at ward.readthedocs.io. The project is also actively maintained on GitHub at github.com/darrenburns/ward, where you can find the changelog and open issues.

How To Use Python factory-boy for Test Fixture Creation

How To Use Python factory-boy for Test Fixture Creation

Intermediate

You know the feeling: you sit down to write a test for a new feature, and before writing a single assertion you spend 20 minutes constructing fake User objects, filling in every required field, inventing email addresses, and wiring up foreign keys just to get your test setup in a valid state. By the time the setup is done, you have forgotten what you were actually testing. This is the test fixture problem, and it quietly kills both your productivity and your test suite’s readability.

The factory_boy library solves this with factory classes that act as blueprints for your objects. Install it once with pip install factory-boy, define a factory that knows how to build a valid User, and from that point forward your tests create objects with a single call. Fields you care about in a specific test are passed as overrides; everything else fills in automatically with sensible, unique values.

In this article you will learn how factory_boy works from the ground up. We will cover creating basic factories, generating unique sequential values, using lazy attributes that depend on other fields, building related objects with sub-factories, creating batches of test data, and using factory_boy with Django ORM and SQLAlchemy. By the end you will be able to write clean, expressive tests where the setup code tells the reader exactly what matters and nothing more.

How To Use factory_boy: Quick Example

The fastest way to understand factory_boy is to see a working example. Here is a factory that generates User objects with unique, realistic field values — no database or ORM required for this first example.

# quick_example.py
import factory
from dataclasses import dataclass

@dataclass
class User:
    id: int
    username: str
    email: str
    is_active: bool = True

class UserFactory(factory.Factory):
    class Meta:
        model = User

    id = factory.Sequence(lambda n: n + 1)
    username = factory.Sequence(lambda n: f"user_{n}")
    email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
    is_active = True

# Create one user
user = UserFactory()
print(f"User: {user.username}, Email: {user.email}, Active: {user.is_active}")

# Override any field you care about
admin = UserFactory(username="admin", is_active=True)
print(f"Admin: {admin.username}, Email: {admin.email}")

# Create three users at once
users = UserFactory.create_batch(3)
for u in users:
    print(f"  - {u.username} ({u.email})")

Output:

User: user_0, Email: user_0@example.com, Active: True
Admin: admin, Email: admin@example.com
  - user_1 (user_1@example.com)
  - user_2 (user_2@example.com)
  - user_3 (user_3@example.com)

Notice that email uses a LazyAttribute — it reads the username that was just generated (or overridden) and builds the email from it. Even when we pass username="admin", the email automatically becomes admin@example.com. This is the core power of factory_boy: fields can be related to each other, and overrides cascade naturally. The sections below dig into every feature you just saw and several more.

What Is factory_boy and Why Use It?

factory_boy is a test fixture replacement library for Python. A “fixture” in testing means test data — the objects, records, and state your tests need to exist before they can run. factory_boy lets you define a factory class once that describes how to build a valid instance of any Python object, and then call that factory in your tests to get realistic, unique instances on demand.

Think of it like a cookie cutter. Instead of shaping each cookie by hand (manually constructing every User object field by field in every test), you stamp out identical-but-customizable shapes with a single press. The cutter knows the shape; you just choose the flavor.

ApproachSetup per testUniquenessField relationshipsORM support
Manual object construction10-30 linesManual / copy-pasteNoneManual
Hardcoded test fixtures (JSON/YAML)Zero lines (but brittle)FixedNoneNone
factory_boy1 lineAutomatic (Sequence)LazyAttributeDjango, SQLAlchemy, Mongoengine

factory_boy works with plain Python objects (dataclasses, attrs classes, or any Python class), Django models, SQLAlchemy models, and MongoDB with Mongoengine — so it fits into almost any Python project. The rest of this article walks through the features you will use most often.

Installation and Setup

Install factory_boy from PyPI. The package name uses a hyphen but the import uses an underscore:

# terminal
pip install factory-boy

Output:

Successfully installed factory-boy-3.x.x

If you plan to use factory_boy with Django or SQLAlchemy, those packages must also be installed, but factory_boy itself has no mandatory dependencies. For plain Python objects, the install above is everything you need.

Loop Larry surrounded by stacks of handwritten User object blueprints
Manually constructing test fixtures. Again. For the 47th test.

Generating Unique Values with Sequence

One of the most common problems with test data is uniqueness. If your database has a unique constraint on email, creating two users with the same email crashes your test suite — even when the email is not what you are testing at all. factory_boy’s factory.Sequence solves this by incrementing a counter each time an object is built.

# sequence_demo.py
import factory
from dataclasses import dataclass

@dataclass
class Product:
    id: int
    name: str
    sku: str
    price: float

class ProductFactory(factory.Factory):
    class Meta:
        model = Product

    id = factory.Sequence(lambda n: n + 1)
    name = factory.Sequence(lambda n: f"Product {n}")
    sku = factory.Sequence(lambda n: f"SKU-{n:05d}")   # zero-padded: SKU-00001
    price = 9.99   # static default -- can always be overridden

p1 = ProductFactory()
p2 = ProductFactory()
p3 = ProductFactory(price=49.99)   # override only the price

print(f"{p1.sku}  {p1.name}  ${p1.price}")
print(f"{p2.sku}  {p2.name}  ${p2.price}")
print(f"{p3.sku}  {p3.name}  ${p3.price}")

Output:

SKU-00001  Product 1  $9.99
SKU-00002  Product 2  $9.99
SKU-00003  Product 3  $49.99

Each call to ProductFactory() increments the counter, so name and sku are always unique across the entire test run. Notice that price is set to a static default of 9.99. When a specific test cares about price (like a discount calculation test), it passes price=49.99 as an override and everything else stays auto-generated. This pattern — auto-generate what does not matter, override what does — is the core ergonomic benefit of factory_boy.

Deriving Fields with LazyAttribute

Many model fields are not independent — an email address is derived from a username, a full name is composed from first and last names, a URL slug is derived from a title. factory.LazyAttribute lets you express these relationships so that overrides cascade automatically.

# lazy_attributes.py
import factory
from dataclasses import dataclass

@dataclass
class Author:
    first_name: str
    last_name: str
    email: str
    display_name: str
    slug: str

class AuthorFactory(factory.Factory):
    class Meta:
        model = Author

    first_name = factory.Faker("first_name")
    last_name = factory.Faker("last_name")
    email = factory.LazyAttribute(
        lambda obj: f"{obj.first_name.lower()}.{obj.last_name.lower()}@blog.com"
    )
    display_name = factory.LazyAttribute(
        lambda obj: f"{obj.first_name} {obj.last_name}"
    )
    slug = factory.LazyAttribute(
        lambda obj: f"{obj.first_name.lower()}-{obj.last_name.lower()}"
    )

a1 = AuthorFactory()
a2 = AuthorFactory(first_name="Jane", last_name="Smith")   # all derived fields update

print(f"Author 1: {a1.display_name} | {a1.email} | {a1.slug}")
print(f"Author 2: {a2.display_name} | {a2.email} | {a2.slug}")

Output:

Author 1: Marcus Williams | marcus.williams@blog.com | marcus-williams
Author 2: Jane Smith | jane.smith@blog.com | jane-smith

When we override first_name and last_name, the email, display_name, and slug all update accordingly — because LazyAttribute re-evaluates using the actual values on the object being built, not the defaults. Also notice factory.Faker("first_name"): factory_boy integrates directly with the Faker library to generate realistic random data for names, addresses, phone numbers, dates, and dozens of other field types.

Debug Dee pointing at a whiteboard diagram showing LazyAttribute field relationships
LazyAttribute: one override updates everything downstream.

Building Related Objects with SubFactory

Real models rarely exist in isolation. A Post belongs to an Author; an OrderLine belongs to an Order that belongs to a Customer. factory_boy handles these relationships with factory.SubFactory, which automatically creates a parent object when you create a child — unless you pass one in explicitly.

# subfactory_demo.py
import factory
from dataclasses import dataclass, field
from typing import List

@dataclass
class Author:
    id: int
    name: str
    email: str

@dataclass
class Post:
    id: int
    title: str
    author: Author
    published: bool = False

class AuthorFactory(factory.Factory):
    class Meta:
        model = Author

    id = factory.Sequence(lambda n: n + 1)
    name = factory.Faker("name")
    email = factory.LazyAttribute(lambda obj: f"{obj.name.lower().replace(' ', '.')}@site.com")

class PostFactory(factory.Factory):
    class Meta:
        model = Post

    id = factory.Sequence(lambda n: n + 1)
    title = factory.Sequence(lambda n: f"Post Title {n}")
    author = factory.SubFactory(AuthorFactory)   # creates an Author automatically
    published = False

# Creating a post also creates its author
post1 = PostFactory()
print(f"Post: '{post1.title}' by {post1.author.name}")

# Reuse an existing author across multiple posts
shared_author = AuthorFactory(name="Alice Johnson")
post2 = PostFactory(author=shared_author, published=True)
post3 = PostFactory(author=shared_author)
print(f"Both by {shared_author.name}: '{post2.title}' and '{post3.title}'")
print(f"Post 2 published: {post2.published} | Post 3 published: {post3.published}")

Output:

Post: 'Post Title 1' by Marcus Brown
Both by Alice Johnson: 'Post Title 2' and 'Post Title 3'
Post 2 published: True | Post 3 published: False

When you call PostFactory(), factory_boy sees that author is a SubFactory and automatically calls AuthorFactory() to produce a valid Author. If a test needs two posts from the same author (for example, testing an author’s post count), create the author once and pass it to both post factories. The test setup reads clearly: “these two posts share one author” — and you did not have to repeat a single field.

Creating Multiple Objects with create_batch and build_batch

Tests for pagination, filtering, or aggregation often need multiple records. Instead of calling the factory in a loop, factory_boy provides create_batch and build_batch for creating lists of objects in one call. You can also pass overrides that apply to every object in the batch.

# batch_demo.py
import factory
from dataclasses import dataclass

@dataclass
class Article:
    id: int
    title: str
    category: str
    views: int

class ArticleFactory(factory.Factory):
    class Meta:
        model = Article

    id = factory.Sequence(lambda n: n + 1)
    title = factory.Sequence(lambda n: f"Article {n}: {factory.Faker._get_faker().sentence(nb_words=4)}")
    category = factory.Iterator(["Python", "Django", "Testing", "Data Science"])
    views = factory.Faker("random_int", min=0, max=10000)

# Create 5 articles, all in the "Testing" category
testing_articles = ArticleFactory.build_batch(5, category="Testing")

print("Testing articles:")
for a in testing_articles:
    print(f"  [{a.id}] {a.title} -- {a.views} views")

# Use Iterator to cycle through categories automatically
mixed_articles = ArticleFactory.build_batch(4)
print("\nMixed categories:")
for a in mixed_articles:
    print(f"  [{a.id}] {a.category}: {a.title[:40]}...")

Output:

Testing articles:
  [1] Article 1: ... -- 3241 views
  [2] Article 2: ... -- 7812 views
  [3] Article 3: ... -- 521 views
  [4] Article 4: ... -- 9103 views
  [5] Article 5: ... -- 2277 views

Mixed categories:
  [6] Python: Article 6: ...
  [7] Django: Article 7: ...
  [8] Testing: Article 8: ...
  [9] Data Science: Article 9: ...

build_batch creates objects without saving them to a database — ideal for unit tests. create_batch calls .save()` (Django) or adds to the session (SQLAlchemy). factory.Iterator cycles through a list of values, so each successive object in the batch gets the next value, then wraps around. This is cleaner than hardcoding every combination manually.

Pyro Pete standing in front of a conveyor belt stamping out user cards
build_batch(50). Because you need 50 users and you have 4 minutes.

Using factory_boy with Django ORM

When your project uses Django models, swap factory.Factory for factory.django.DjangoModelFactory. This subclass knows how to call Model.objects.create() instead of Model(), so objects are persisted to the test database automatically when you call the factory.

# factories.py  (in your Django app's test directory)
import factory
from factory.django import DjangoModelFactory
from myapp.models import User, Profile

class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Sequence(lambda n: f"testuser_{n}")
    email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
    first_name = factory.Faker("first_name")
    last_name = factory.Faker("last_name")
    is_active = True

class ProfileFactory(DjangoModelFactory):
    class Meta:
        model = Profile

    user = factory.SubFactory(UserFactory)
    bio = factory.Faker("paragraph", nb_sentences=2)
    location = factory.Faker("city")
# tests.py  -- using the factories in a Django test case
from django.test import TestCase
from .factories import UserFactory, ProfileFactory

class ProfileViewTests(TestCase):

    def test_profile_page_shows_bio(self):
        # One line creates the user AND the profile
        profile = ProfileFactory(bio="Expert Python programmer.")

        response = self.client.get(f"/profile/{profile.user.username}/")
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Expert Python programmer.")

    def test_user_listing_shows_all_active_users(self):
        # Create 5 active users and 2 inactive users
        UserFactory.create_batch(5, is_active=True)
        UserFactory.create_batch(2, is_active=False)

        response = self.client.get("/users/")
        self.assertEqual(len(response.context["users"]), 5)

Output (pytest or Django test runner):

..
----------------------------------------------------------------------
Ran 2 tests in 0.342s

OK

The test for the profile page only cares about one thing: whether the bio shows up on the page. With factory_boy, that is the only field we specify. The username, email, and user account are created automatically and the test is completely self-documenting: "create a profile with this bio, then check the page shows it." Without factory_boy, this test would start with 10 lines of User.objects.create(username=..., email=..., password=..., ...) before you even get to the assertion.

Using factory_boy with SQLAlchemy

For SQLAlchemy projects, factory_boy provides factory.alchemy.SQLAlchemyModelFactory. The key difference is that you must supply the SQLAlchemy session so factory_boy knows where to add the created objects.

# sqlalchemy_factories.py
import factory
from factory.alchemy import SQLAlchemyModelFactory
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, create_engine
from sqlalchemy.orm import relationship, sessionmaker, declarative_base

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    username = Column(String, unique=True)
    email = Column(String, unique=True)
    is_active = Column(Boolean, default=True)

# Create an in-memory SQLite database for testing
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()

class UserFactory(SQLAlchemyModelFactory):
    class Meta:
        model = User
        sqlalchemy_session = session          # the session to use
        sqlalchemy_session_persistence = "commit"   # auto-commit after create

    id = factory.Sequence(lambda n: n + 1)
    username = factory.Sequence(lambda n: f"user_{n}")
    email = factory.LazyAttribute(lambda obj: f"{obj.username}@test.com")
    is_active = True

# Create objects -- they are persisted to the in-memory DB
u1 = UserFactory()
u2 = UserFactory(is_active=False)

# Query them back from the DB to confirm persistence
all_users = session.query(User).all()
active_users = session.query(User).filter_by(is_active=True).all()

print(f"Total users: {len(all_users)}")
print(f"Active users: {len(active_users)}")
for u in all_users:
    print(f"  {u.username}  active={u.is_active}")

Output:

Total users: 2
Active users: 1
  user_1  active=True
  user_2  active=False

The SQLAlchemy integration is straightforward once the session is wired up. A common pattern in real test suites is to configure sqlalchemy_session at the base factory level so all child factories share the same session, and then use a pytest fixture to create a fresh session per test and update the factory's session reference at the start of each test. This ensures clean isolation between tests.

Stack Trace Steve slumped before screens showing SQLAlchemy session error logs
Forgot to pass the session. Again. That's 45 minutes of debugging gone.

Real-Life Example: E-Commerce Order Test Suite

Let us put everything together with a realistic scenario: an e-commerce application where tests need to verify order total calculations, product inventory checks, and customer history. Without factory_boy, every test would require 30-50 lines of setup. With it, each test is three to five lines.

# ecommerce_factories.py
import factory
from dataclasses import dataclass, field
from typing import List

@dataclass
class Customer:
    id: int
    name: str
    email: str
    is_premium: bool = False

@dataclass
class Product:
    id: int
    name: str
    sku: str
    price: float
    stock: int = 100

@dataclass
class OrderLine:
    product: Product
    quantity: int

    @property
    def subtotal(self):
        return self.product.price * self.quantity

@dataclass
class Order:
    id: int
    customer: Customer
    lines: List[OrderLine] = field(default_factory=list)

    @property
    def total(self):
        return sum(line.subtotal for line in self.lines)

    def add_line(self, product, quantity):
        self.lines.append(OrderLine(product=product, quantity=quantity))

class CustomerFactory(factory.Factory):
    class Meta:
        model = Customer

    id = factory.Sequence(lambda n: n + 1)
    name = factory.Faker("name")
    email = factory.LazyAttribute(lambda obj: f"{obj.name.lower().replace(' ', '.')}@shop.com")
    is_premium = False

class ProductFactory(factory.Factory):
    class Meta:
        model = Product

    id = factory.Sequence(lambda n: n + 1)
    name = factory.Sequence(lambda n: f"Widget Model {n}")
    sku = factory.Sequence(lambda n: f"WGT-{n:04d}")
    price = factory.Faker("pyfloat", min_value=1.0, max_value=500.0, right_digits=2)
    stock = 100

class OrderFactory(factory.Factory):
    class Meta:
        model = Order

    id = factory.Sequence(lambda n: n + 1)
    customer = factory.SubFactory(CustomerFactory)

# --- Tests using the factories ---
import unittest

class TestOrderTotals(unittest.TestCase):

    def test_single_line_order_total(self):
        product = ProductFactory(price=25.00)
        order = OrderFactory()
        order.add_line(product, quantity=3)
        self.assertAlmostEqual(order.total, 75.00)

    def test_multi_line_order_total(self):
        p1 = ProductFactory(price=10.00)
        p2 = ProductFactory(price=5.50)
        order = OrderFactory()
        order.add_line(p1, quantity=2)
        order.add_line(p2, quantity=4)
        # 20.00 + 22.00 = 42.00
        self.assertAlmostEqual(order.total, 42.00)

    def test_premium_customer_flag(self):
        premium_customer = CustomerFactory(is_premium=True)
        order = OrderFactory(customer=premium_customer)
        self.assertTrue(order.customer.is_premium)

    def test_out_of_stock_product(self):
        out_of_stock = ProductFactory(stock=0)
        self.assertEqual(out_of_stock.stock, 0)

if __name__ == "__main__":
    unittest.main(verbosity=2)

Output:

test_multi_line_order_total (__main__.TestOrderTotals) ... ok
test_out_of_stock_product (__main__.TestOrderTotals) ... ok
test_premium_customer_flag (__main__.TestOrderTotals) ... ok
test_single_line_order_total (__main__.TestOrderTotals) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.012s

OK

Each test is a focused, self-documenting statement. test_single_line_order_total tells you exactly what it cares about -- a product at $25.00 multiplied by 3 -- and nothing else. The customer, SKU, product name, and every other field that does not matter to this specific calculation are handled automatically. To extend this example in your own projects, try adding a DiscountFactory with a SubFactory(CustomerFactory) to test premium discount logic, or a factory.Trait to define a "digital_product" variant with stock=-1 meaning unlimited.

Frequently Asked Questions

What is the difference between build() and create()?

build() instantiates the Python object in memory without touching any database. create() also persists the object -- by calling Model.objects.create() in Django or adding to the SQLAlchemy session and committing. For unit tests that do not need database access, use build() -- it is faster, requires no database setup, and avoids transaction overhead. Use create() only when a test genuinely needs to query the database or test ORM behavior.

How do I reset the sequence counter between tests?

factory_boy's Sequence counter is global per factory class, so it keeps incrementing across tests in the same run. If you need reproducible IDs, call UserFactory.reset_sequence(0) at the start of a test or in a setUp method. In Django test cases, you can also override _meta.reset_sequence = True at the factory level. For most tests this is unnecessary since you are testing behavior, not specific ID values -- but it matters if you are testing URL routing based on IDs or comparing fixtures against expected output.

Can I use custom Faker providers for domain-specific data?

Yes. Faker has over 100 built-in providers (factory.Faker("ipv4_private"), factory.Faker("isbn13"), factory.Faker("credit_card_number"), etc.) and you can register your own. For domain-specific data like product categories or country codes, factory.Iterator(["Electronics", "Clothing", "Books"]) is often simpler than a custom provider. If a field needs logic that neither Sequence nor LazyAttribute can express cleanly, use factory.LazyFunction(my_custom_function) to call an arbitrary callable with no arguments.

What are Traits and when should I use them?

A Trait is a named bundle of field overrides that you can apply to a factory call with a keyword argument. They are ideal when you find yourself passing the same group of overrides repeatedly. For example, instead of writing UserFactory(is_active=False, verified=False, login_count=0) in every test that needs an inactive user, define a @factory.Trait called inactive that sets all three fields, then call UserFactory(inactive=True). Traits make intent clear and keep override logic in one place.

How do I avoid hitting the database in unit tests when using DjangoModelFactory?

Use UserFactory.build() instead of UserFactory() (which calls create() by default for DjangoModelFactory). build() constructs the object in memory without calling .save(). If your test calls view code or serializers that reference the object but does not query the database, build() gives you a valid Python object at no database cost. For tests that mock the ORM layer entirely (with unittest.mock.patch), build() is always the right choice.

Conclusion

factory_boy eliminates the single most tedious part of writing Python tests: setting up valid, realistic test data. You have seen how factory.Sequence ensures unique field values across every object ever built, how factory.LazyAttribute lets fields depend on each other so overrides cascade correctly, how factory.SubFactory builds related objects automatically, and how build_batch creates lists of objects in one call. The Django and SQLAlchemy integrations wire the same patterns directly to your ORM so created objects land in the test database with no extra code.

The real-life example showed the payoff: four tests, each between three and five lines, each perfectly self-documenting. Every field that a test explicitly sets is a field that matters to that test's assertion. Everything else is handled by the factory. Try taking one of your existing test files, identifying the tests with the most setup code, and replacing that setup with a factory. The reduction in noise is immediate.

For advanced features like post-generation hooks (@factory.post_generation), exclusions, and custom factory inheritance, see the official factory_boy documentation.

How To Use Python arrow for Better Datetime Handling

How To Use Python arrow for Better Datetime Handling

Intermediate

Dealing with dates and times in Python can feel like defusing a bomb. You need datetime, timedelta, pytz, dateutil, and a generous amount of patience just to answer a question like “what time will it be in Tokyo three days from now?” The standard library splits this work across multiple modules with inconsistent interfaces, and timezone handling alone is a common source of production bugs that are notoriously hard to reproduce.

The arrow library is a drop-in replacement that fixes these pain points without requiring you to learn an entirely new paradigm. It wraps Python’s built-in datetime objects in a cleaner API that is timezone-aware by default, handles parsing and formatting with one method each, and lets you shift dates forward and backward with human-readable keyword arguments. You install it with a single pip install arrow command and no other dependencies are needed.

In this tutorial we will cover everything you need to work confidently with arrow: creating and converting datetime objects, parsing and formatting dates, shifting dates with relative time, converting between timezones, humanizing time differences, and generating date ranges. By the end you will have a clear picture of when arrow saves you time and how to integrate it into real projects.

Python arrow Datetime: Quick Example

Before we dive in, here is a self-contained example showing the three things arrow does better than the standard library in just a handful of lines.

# quick_arrow.py
import arrow

# Get the current time in UTC
now = arrow.utcnow()
print("UTC now:", now)

# Convert to a specific timezone
sydney = now.to("Australia/Sydney")
print("Sydney time:", sydney.format("YYYY-MM-DD HH:mm:ss ZZZ"))

# Shift three days into the future
future = now.shift(days=3)
print("3 days from now:", future.humanize())

# Parse a date string
parsed = arrow.get("2025-12-31", "YYYY-MM-DD")
print("Parsed:", parsed)
print("Days until end of 2025:", (parsed - now).days)

Output:

UTC now: 2026-07-16T04:23:11.241819+00:00
Sydney time: 2026-07-16 14:23:11 AEST
3 days from now: in 3 days
Parsed: 2025-12-31T00:00:00+00:00
Days until end of 2025: -197

In six lines we accomplished what would normally require importing datetime, timedelta, and pytz and wiring them together manually. The to() method handles timezone conversion, shift() handles relative date arithmetic, and humanize() produces reader-friendly output. We will explore each of these capabilities in depth below.

What Is arrow and Why Use It?

The arrow library, created by Chris Smith, was designed with one goal: make working with dates and times in Python as painless as possible. It provides a single Arrow object type that always carries timezone information (defaulting to UTC), which eliminates the infamous “naive vs. aware datetime” class of bugs that plagues production applications.

Under the hood, every Arrow object wraps a standard Python datetime and the two are fully interoperable — you can convert between them at any point. This means you can adopt arrow incrementally in an existing codebase without rewriting everything at once.

Here is a direct comparison of how common tasks look with the standard library versus arrow:

TaskStandard Libraryarrow
Current UTC timedatetime.utcnow() (naive)arrow.utcnow() (tz-aware)
Add 3 daysdt + timedelta(days=3)dt.shift(days=3)
Timezone conversiondt.astimezone(pytz.timezone(...))dt.to("US/Eastern")
Parse a stringdatetime.strptime(s, "%Y-%m-%d")arrow.get(s, "YYYY-MM-DD")
Format a datedt.strftime("%B %d, %Y")dt.format("MMMM DD, YYYY")
Human-readable diffWrite it yourselfdt.humanize()
Date rangeWrite it yourselfarrow.Arrow.range(...)

The pattern is consistent: arrow replaces strftime/strptime format codes with the more readable YYYY-MM-DD token style (shared with Moment.js and many other libraries), bundles timezone support into the core object, and adds missing utilities like humanize() and range() that you would otherwise have to write from scratch. Let us look at each capability in detail.

Python programmer working with arrow datetime library at a desk with clocks
One import. Infinite timezones. Zero pytz imports.

Installing arrow

Arrow is available on PyPI and requires Python 3.6 or later. There are no heavy dependencies — it uses Python-dateutil under the hood, which is installed automatically.

# terminal
pip install arrow

Output:

Successfully installed arrow-1.3.0 python-dateutil-2.9.0

Verify the installation worked by running a quick import check:

# verify_install.py
import arrow
print(arrow.__version__)
print(arrow.now())

Output:

1.3.0
2026-07-16T14:23:11.241819+10:00

Creating Arrow Objects

There are several ways to create an Arrow object, depending on where your date data is coming from. The most common entry points are arrow.now(), arrow.utcnow(), arrow.get(), and arrow.Arrow().

now() and utcnow()

Use arrow.now() to get the current local time (it detects your system timezone automatically) and arrow.utcnow() to get the current time in UTC. Both return timezone-aware objects, unlike Python’s built-in datetime.now() which returns a naive (timezone-free) object.

# creating_objects.py
import arrow

# Local time (uses your system timezone)
local = arrow.now()
print("Local:", local)
print("Timezone:", local.tzname())

# UTC time
utc = arrow.utcnow()
print("UTC:", utc)
print("Timezone:", utc.tzname())

# Specify a timezone explicitly
tokyo = arrow.now("Asia/Tokyo")
print("Tokyo:", tokyo)

Output:

Local: 2026-07-16T14:23:11.241819+10:00
Timezone: AEST
UTC: 2026-07-16T04:23:11.241819+00:00
Timezone: UTC
Tokyo: 2026-07-16T13:23:11.241819+09:00

Constructing from Values or Strings

The arrow.get() function is the general-purpose factory. It accepts integers (Unix timestamps), datetime objects, or date strings with a matching format token. This flexibility means you rarely need more than one function name to bring data into arrow’s type system.

# get_examples.py
import arrow
import datetime

# From a Unix timestamp
from_ts = arrow.get(1736000000)
print("From timestamp:", from_ts)

# From a Python datetime (naive -- arrow assumes UTC)
native_dt = datetime.datetime(2026, 6, 15, 12, 0, 0)
from_dt = arrow.get(native_dt)
print("From datetime:", from_dt)

# From a date string with explicit format
from_str = arrow.get("16/07/2026 09:30", "DD/MM/YYYY HH:mm")
print("From string:", from_str)

# From ISO 8601 (arrow detects this automatically)
from_iso = arrow.get("2026-07-16T09:30:00+05:30")
print("From ISO:", from_iso)

# Explicit construction (year, month, day, ...)
explicit = arrow.Arrow(2026, 12, 31, 23, 59, 59)
print("Explicit:", explicit)

Output:

From timestamp: 2025-01-04T19:33:20+00:00
From datetime: 2026-06-15T12:00:00+00:00
From string: 2026-07-16T09:30:00+00:00
From ISO: 2026-07-16T09:30:00+05:30
Explicit: 2026-12-31T23:59:59+00:00

Notice that when you pass a naive datetime to arrow.get(), arrow assumes UTC. This is a deliberate design choice — it is always safer to be explicit, and UTC is the most common default in server-side code. If your naive datetime is actually in a local timezone, convert it first with arrow.get(dt, tzinfo=local_tz).

Examining arrow.get() factory function for creating datetime objects
arrow.get() accepts timestamps, strings, and datetime objects. Pick your poison.

Formatting and Parsing Dates

Arrow uses the same token system for both formatting and parsing, which means you only need to learn one set of tokens. The tokens are easier to read than Python’s %Y-%m-%d strftime codes because they use the full unit name instead of a percent-letter code.

Common Format Tokens

TokenOutputDescription
YYYY20264-digit year
YY262-digit year
MM07Zero-padded month
MMMMJulyFull month name
MMMJulShort month name
DD16Zero-padded day
HH1424-hour hour
hh0212-hour hour
mm23Minutes
ss11Seconds
ZZZAESTTimezone abbreviation
Z+10:00UTC offset
# formatting.py
import arrow

dt = arrow.Arrow(2026, 7, 16, 14, 23, 11, tzinfo="Australia/Sydney")

# Format with various patterns
print(dt.format("YYYY-MM-DD"))
print(dt.format("DD MMMM YYYY"))
print(dt.format("MMMM D, YYYY [at] h:mm A"))
print(dt.format("ddd, DD MMM YYYY HH:mm:ss Z"))
print(dt.isoformat())

Output:

2026-07-16
16 July 2026
July 16, 2026 at 2:23 PM
Thu, 16 Jul 2026 14:23:11 +10:00
2026-07-16T14:23:11+10:00

The square brackets in MMMM D, YYYY [at] h:mm A are an escape mechanism — any text inside [...] is treated as a literal string rather than a format token, which is how we include the word “at” without it being interpreted as a token.

Parsing Date Strings

Parsing uses the same tokens in the format argument of arrow.get(). If your input is ISO 8601, you do not need a format argument at all — arrow detects it automatically.

# parsing.py
import arrow

# Automatic ISO 8601 detection
iso = arrow.get("2026-07-16T14:23:11+10:00")
print("ISO:", iso)

# Custom format
custom = arrow.get("July 16, 2026", "MMMM D, YYYY")
print("Custom:", custom)

# European date format
european = arrow.get("16/07/2026", "DD/MM/YYYY")
print("European:", european)

# With timezone in the string
with_tz = arrow.get("2026-07-16 14:23:11 Australia/Sydney", "YYYY-MM-DD HH:mm:ss ZZZ")
print("With TZ:", with_tz)

# Multiple possible formats (arrow tries each)
for date_str in ["2026-07-16", "07/16/2026", "July 16 2026"]:
    formats = ["YYYY-MM-DD", "MM/DD/YYYY", "MMMM D YYYY"]
    for fmt in formats:
        try:
            parsed = arrow.get(date_str, fmt)
            print(f"Parsed '{date_str}' as {parsed.date()}")
            break
        except Exception:
            continue

Output:

ISO: 2026-07-16T14:23:11+10:00
Custom: 2026-07-16T00:00:00+00:00
European: 2026-07-16T00:00:00+00:00
With TZ: 2026-07-16T14:23:11+10:00
Parsed '2026-07-16' as 2026-07-16
Parsed '07/16/2026' as 2026-07-16
Parsed 'July 16 2026' as 2026-07-16

Shifting and Manipulating Dates

The shift() method is arguably the most useful feature in arrow. Instead of importing timedelta and doing manual arithmetic, you pass keyword arguments for the time units you want to add or subtract. Negative values shift backward in time.

# shifting.py
import arrow

now = arrow.Arrow(2026, 7, 16, 14, 0, 0, tzinfo="UTC")
print("Base:", now)

# Shift forward
print("+3 days:", now.shift(days=3))
print("+2 weeks:", now.shift(weeks=2))
print("+6 months:", now.shift(months=6))
print("+1 year:", now.shift(years=1))

# Shift backward
print("-30 minutes:", now.shift(minutes=-30))
print("-1 month:", now.shift(months=-1))

# Combine multiple units
print("+1 year 3 months 5 days:", now.shift(years=1, months=3, days=5))

# Chaining is also possible
result = now.shift(months=1).shift(days=-5).shift(hours=8)
print("Chained:", result)

Output:

Base: 2026-07-16T14:00:00+00:00
+3 days: 2026-07-19T14:00:00+00:00
+2 weeks: 2026-07-30T14:00:00+00:00
+6 months: 2027-01-16T14:00:00+00:00
+1 year: 2027-07-16T14:00:00+00:00
-30 minutes: 2026-07-16T13:30:00+00:00
-1 month: 2026-06-16T14:00:00+00:00
+1 year 3 months 5 days: 2027-10-21T14:00:00+00:00
Chained: 2026-08-11T22:00:00+00:00

One thing to note about shift(months=1): arrow handles month boundaries correctly. If you are on January 31 and shift by one month, you get February 28 (or 29 in a leap year) rather than an error. This is a common edge case that catches developers off guard when using raw timedelta arithmetic, which only works in days and cannot express “one calendar month.”

Arrow library shift method moving along a date timeline
shift(months=1) on Jan 31 lands on Feb 28. timedelta doesn’t know what a month is.

Floor, Ceil, and Span

Arrow provides floor() and ceil() methods to snap a datetime to the start or end of a time period (day, week, month, year, etc.). The span() method returns both the floor and ceil as a tuple, which is useful for building date range queries.

# floor_ceil.py
import arrow

dt = arrow.Arrow(2026, 7, 16, 14, 23, 45, tzinfo="UTC")
print("Original:", dt)

# Floor -- snap to start of period
print("Start of day:", dt.floor("day"))
print("Start of month:", dt.floor("month"))
print("Start of year:", dt.floor("year"))
print("Start of hour:", dt.floor("hour"))

# Ceil -- snap to end of period
print("End of day:", dt.ceil("day"))
print("End of month:", dt.ceil("month"))

# Span -- returns (floor, ceil) tuple
start, end = dt.span("month")
print(f"This month: {start} to {end}")

start, end = dt.span("week")
print(f"This week: {start} to {end}")

Output:

Original: 2026-07-16T14:23:45+00:00
Start of day: 2026-07-16T00:00:00+00:00
Start of month: 2026-07-01T00:00:00+00:00
Start of year: 2026-01-01T00:00:00+00:00
Start of hour: 2026-07-16T14:00:00+00:00
End of day: 2026-07-16T23:59:59.999999+00:00
End of month: 2026-07-31T23:59:59.999999+00:00
This month: 2026-07-01T00:00:00+00:00 to 2026-07-31T23:59:59.999999+00:00
This week: 2026-07-13T00:00:00+00:00 to 2026-07-19T23:59:59.999999+00:00

These methods are invaluable when writing database queries. A pattern like start, end = arrow.now().span("month") gives you the exact UTC timestamps for “this calendar month” without manually calculating month lengths or worrying about leap years.

Timezone Handling

Timezone conversions in arrow are a single method call. The to() method accepts any IANA timezone name (the same names used by pytz) and returns a new Arrow object representing the same moment in the target timezone.

# timezones.py
import arrow

# Start in UTC
utc = arrow.utcnow()
print("UTC:         ", utc.format("YYYY-MM-DD HH:mm:ss ZZZ"))

# Convert to multiple zones
zones = [
    "US/Eastern",
    "US/Pacific",
    "Europe/London",
    "Europe/Paris",
    "Asia/Kolkata",
    "Asia/Tokyo",
    "Australia/Sydney",
]

for zone in zones:
    local = utc.to(zone)
    print(f"{zone:<20} {local.format('YYYY-MM-DD HH:mm:ss ZZZ')}")

Output:

UTC:          2026-07-16T04:23:11+00:00 UTC
US/Eastern           2026-07-16 00:23:11 EDT
US/Pacific           2026-07-15 21:23:11 PDT
Europe/London        2026-07-16 05:23:11 BST
Europe/Paris         2026-07-16 06:23:11 CEST
Asia/Kolkata         2026-07-16 09:53:11 IST
Asia/Tokyo           2026-07-16 13:23:11 JST
Australia/Sydney     2026-07-16 14:23:11 AEST

Arrow also handles Daylight Saving Time transitions automatically. If you store a UTC timestamp and convert it later, arrow will use the correct offset for that specific date -- so a timestamp from January will correctly show EST (-5) while a timestamp from July will show EDT (-4) for the US/Eastern zone. You never have to think about DST offset tables.

Humanizing Dates

The humanize() method converts a time difference into plain language. It is the feature most requested by developers who use dates in user-facing applications -- displaying "3 hours ago" or "in 2 days" instead of a raw timestamp is almost always better for readability.

# humanize.py
import arrow

now = arrow.utcnow()

# Relative to now (default)
moments = [
    now.shift(seconds=-30),
    now.shift(minutes=-5),
    now.shift(hours=-2),
    now.shift(days=-1),
    now.shift(weeks=-2),
    now.shift(months=-3),
    now.shift(years=-1),
    now.shift(minutes=5),
    now.shift(hours=3),
    now.shift(days=2),
]

for moment in moments:
    print(moment.humanize())

print()
# Humanize relative to a specific reference time
reference = arrow.Arrow(2026, 1, 1, tzinfo="UTC")
print(now.humanize(reference))          # relative to Jan 1
print(reference.humanize(now))          # relative to now

Output:

30 seconds ago
5 minutes ago
2 hours ago
a day ago
2 weeks ago
3 months ago
a year ago
in 5 minutes
in 3 hours
in 2 days

in 6 months
6 months ago

You can also request a specific granularity level using the granularity parameter, which accepts a list of units. For example, humanize(granularity=["hour", "minute"]) will produce output like "2 hours and 15 minutes ago" instead of the default single-unit output.

Arrow humanize method converting timestamps to human-readable text
.humanize() -- because 2026-07-14T04:23:11+00:00 means nothing to your users.

Generating Date Ranges

Arrow's Arrow.range() class method generates a sequence of evenly spaced dates between a start and end point. This is useful for generating report periods, calendar views, or any situation where you need to iterate over a regular sequence of dates.

# date_ranges.py
import arrow

start = arrow.Arrow(2026, 7, 1, tzinfo="UTC")
end = arrow.Arrow(2026, 7, 10, tzinfo="UTC")

# Daily range
print("Daily range (July 1-10):")
for dt in arrow.Arrow.range("day", start, end):
    print(" ", dt.format("YYYY-MM-DD dddd"))

print()
# Weekly range for a quarter
q3_start = arrow.Arrow(2026, 7, 1, tzinfo="UTC")
q3_end = arrow.Arrow(2026, 9, 30, tzinfo="UTC")

print("Weekly intervals in Q3 2026:")
for week_start in arrow.Arrow.range("week", q3_start, q3_end):
    week_end = week_start.shift(days=6)
    print(f"  {week_start.format('MMM D')} - {week_end.format('MMM D')}")

Output:

Daily range (July 1-10):
  2026-07-01 Wednesday
  2026-07-02 Thursday
  2026-07-03 Friday
  2026-07-04 Saturday
  2026-07-05 Sunday
  2026-07-06 Monday
  2026-07-07 Tuesday
  2026-07-08 Wednesday
  2026-07-09 Thursday
  2026-07-10 Friday

Weekly intervals in Q3 2026:
  Jul 1 - Jul 7
  Jul 8 - Jul 14
  Jul 15 - Jul 21
  Jul 22 - Jul 28
  Jul 29 - Aug 4
  Aug 5 - Aug 11
  Aug 12 - Aug 18
  Aug 19 - Aug 25
  Aug 26 - Sep 1
  Sep 2 - Sep 8
  Sep 9 - Sep 15
  Sep 16 - Sep 22
  Sep 23 - Sep 29

The first argument to range() can be any of: "year", "month", "week", "day", "hour", "minute", or "second". Arrow also provides Arrow.span_range() if you need both the start and end of each interval returned as a tuple instead of just the start.

Interoperability with the Standard Library

Because arrow wraps Python's standard datetime, converting between the two is trivial. You will often need this when passing dates to libraries that do not know about arrow (like most database drivers, Django ORM fields, or pandas).

# interop.py
import arrow
import datetime

# Arrow to standard datetime
a = arrow.Arrow(2026, 7, 16, 14, 0, 0, tzinfo="UTC")
native = a.datetime          # timezone-aware datetime
naive = a.naive              # timezone-naive datetime (UTC values, no tzinfo)
date_only = a.date()         # datetime.date object
ts = a.timestamp()           # Unix timestamp (float)

print("Arrow:    ", a)
print("datetime: ", native)
print("naive:    ", naive)
print("date:     ", date_only)
print("timestamp:", ts)

# Standard datetime to arrow
now_dt = datetime.datetime.now(datetime.timezone.utc)
back_to_arrow = arrow.get(now_dt)
print("Back to arrow:", back_to_arrow)

# Arithmetic with timedelta still works
delta = datetime.timedelta(hours=5)
shifted = a + delta
print("Arrow + timedelta:", shifted)

Output:

Arrow:     2026-07-16T14:00:00+00:00
datetime:  2026-07-16 14:00:00+00:00
naive:     2026-07-16 14:00:00
date:      2026-07-16
timestamp: 1752674400.0
Back to arrow: 2026-07-16T04:23:11.241819+00:00
Arrow + timedelta: 2026-07-16T19:00:00+00:00

Real-Life Example: Project Deadline Tracker

Let us build a small command-line deadline tracker that stores project milestones and reports their status in human-friendly language. This pulls together creation, shifting, timezone conversion, humanizing, and formatting into a single practical application.

Deadline tracking with Python arrow library
Tracking deadlines: where arrow.humanize() earns its keep.
# deadline_tracker.py
import arrow

# Milestones stored as dicts with ISO 8601 deadline strings
milestones = [
    {"name": "API spec review",       "deadline": "2026-07-10T17:00:00+10:00"},
    {"name": "Backend integration",   "deadline": "2026-07-25T17:00:00+10:00"},
    {"name": "Frontend handoff",      "deadline": "2026-08-01T17:00:00+10:00"},
    {"name": "QA testing complete",   "deadline": "2026-08-15T17:00:00+10:00"},
    {"name": "Production deployment", "deadline": "2026-08-20T17:00:00+10:00"},
]

def status_label(deadline_arrow, now):
    """Return a status label based on how far away the deadline is."""
    diff_days = (deadline_arrow - now).days
    if diff_days < 0:
        return "OVERDUE"
    elif diff_days <= 3:
        return "URGENT"
    elif diff_days <= 7:
        return "THIS WEEK"
    else:
        return "ON TRACK"

def print_milestone_report(milestones, viewer_tz="Australia/Sydney"):
    now = arrow.utcnow()
    print(f"Project Milestone Report")
    print(f"Generated: {now.to(viewer_tz).format('MMMM D, YYYY [at] h:mm A ZZZ')}")
    print(f"{'=' * 60}")

    for m in milestones:
        deadline = arrow.get(m["deadline"])
        local_deadline = deadline.to(viewer_tz)
        label = status_label(deadline, now)

        print(f"\n{m['name']}")
        print(f"  Deadline: {local_deadline.format('ddd, MMMM D YYYY [at] h:mm A ZZZ')}")
        print(f"  Status:   [{label}] {deadline.humanize()}")

        # If overdue, show how long it has been
        if label == "OVERDUE":
            overdue_hours = abs((deadline - now).total_seconds()) / 3600
            print(f"  Overdue by: {overdue_hours:.1f} hours")

    # Summary
    total = len(milestones)
    overdue = sum(1 for m in milestones if (arrow.get(m["deadline"]) - now).days < 0)
    print(f"\n{'=' * 60}")
    print(f"Summary: {total - overdue}/{total} milestones on track, {overdue} overdue")

print_milestone_report(milestones)

Output:

Project Milestone Report
Generated: July 16, 2026 at 2:23 PM AEST
============================================================

API spec review
  Deadline: Thu, July 10 2026 at 5:00 PM AEST
  Status:   [OVERDUE] 6 days ago
  Overdue by: 141.4 hours

Backend integration
  Deadline: Sat, July 25 2026 at 5:00 PM AEST
  Status:   [ON TRACK] in 9 days

Frontend handoff
  Deadline: Sat, August 01 2026 at 5:00 PM AEST
  Status:   [ON TRACK] in 16 days

QA testing complete
  Deadline: Sat, August 15 2026 at 5:00 PM AEST
  Status:   [ON TRACK] in a month

Production deployment
  Deadline: Thu, August 20 2026 at 5:00 PM AEST
  Status:   [ON TRACK] in a month

============================================================
Summary: 4/5 milestones on track, 1 overdue

This script reads deadline strings directly from your data source (API response, config file, database), converts them to the viewer's local timezone for display, uses humanize() for the status column, and calculates numeric differences for the overdue alert. Extending it to load milestones from a JSON file or database query would take fewer than 10 additional lines.

Frequently Asked Questions

What is the difference between arrow and Pendulum?

Both arrow and Pendulum aim to improve Python's datetime API, but they make different trade-offs. Arrow is lighter-weight and has been around since 2013 with a simple API focused on the most common tasks. Pendulum is more opinionated -- it is a full drop-in replacement for datetime (meaning Pendulum objects behave like datetime objects at the type level), provides duration arithmetic with DST handling built in, and produces more precise human-readable intervals. For most applications, arrow is sufficient and easier to integrate. If you need Pendulum's stricter type compatibility or precise DST-aware duration math, use Pendulum instead.

How do I convert a naive datetime to an arrow object with the correct timezone?

If you have a naive datetime that is in a known timezone (not UTC), do not pass it directly to arrow.get() because arrow will assume UTC. Instead, use arrow.get(dt, tzinfo) where tzinfo is a timezone string or pytz object: arrow.get(naive_dt, "America/New_York"). This tells arrow "this naive datetime represents a moment in New York time" and produces a properly localized Arrow object.

How should I store arrow datetimes in a database?

Always store dates in UTC. Use a.datetime to get a timezone-aware datetime and pass that to your ORM or database driver. When reading back, wrap the retrieved value in arrow.get(db_value) and then call .to("your/timezone") for display. This pattern is timezone-safe and avoids the common bug of storing local times that become ambiguous during DST changes.

Can I compare two Arrow objects directly?

Yes. Arrow objects support all standard comparison operators: ==, !=, <, >, <=, >=. Comparisons are timezone-aware -- two Arrow objects representing the same moment in different timezones will compare as equal. You can also subtract two Arrow objects to get a timedelta: (deadline - now).days gives you the integer number of days between them.

How do I serialize an Arrow object to JSON?

Arrow objects are not directly JSON-serializable, but converting them is one line. Use a.isoformat() to get an ISO 8601 string for storage or API responses. When reading back from JSON, use arrow.get(json_string) -- arrow automatically parses ISO 8601 strings without needing a format argument. For Django REST Framework, Pydantic, or similar frameworks, add a custom serializer that calls .isoformat() on Arrow objects.

Does arrow support localized (non-English) output?

Yes. The humanize() method accepts a locale parameter: arrow.now().shift(days=-3).humanize(locale="fr") returns "il y a 3 jours". Arrow ships with locale support for over 50 languages. For formatting month and day names in a specific locale (like "Juli" in German instead of "July"), combine arrow's formatting with Python's locale module -- arrow itself uses English names in format() regardless of locale.

Conclusion

The arrow library removes most of the friction from working with dates and times in Python. We covered the full workflow: creating Arrow objects from timestamps, strings, and native datetimes; formatting and parsing with readable token patterns; shifting dates by months and weeks without manual timedelta math; converting between timezones in a single method call; producing human-readable time descriptions with humanize(); and generating evenly spaced date ranges. The deadline tracker project tied all of these together in a pattern you can adapt to any application that works with time-sensitive data.

The best next step is to replace one place in your existing code where you import datetime and pytz together -- that is arrow's strongest use case. The official documentation at arrow.readthedocs.io covers additional features including custom factory classes, token extension, and the full list of supported locales.

How To Use Python Decouple for Environment Variable Config

How To Use Python Decouple for Environment Variable Config

Intermediate

You write a FastAPI app that connects to a database and a third-party payment gateway. You hardcode the credentials during development and tell yourself you will fix it before deploying. A month later the repo is on GitHub and so is your production database password. This is not a hypothetical — it happens constantly, and it happens because os.environ.get() is clunky enough that developers avoid it until it is too late.

Python’s python-decouple library makes the right approach easier than the wrong one. It reads configuration from .env files or .ini files, applies type casting automatically, handles missing values with sensible defaults, and keeps your code clean. One pip install python-decouple is all it takes, and it works with any Python project — Flask, FastAPI, Django, or a plain script.

This article walks through everything you need to use python-decouple confidently: reading values, type casting, setting defaults, working with booleans, using .env vs .ini files, integrating with Django settings, and building a real-world config loader. By the end you will have a pattern you can drop into any project to keep secrets out of your source code for good.

Python Decouple: Quick Example

Here is a minimal working example that shows the core pattern — reading a string, an integer, and a boolean from a .env file — so you can see exactly what decouple does before we explore each feature in depth.

First, create a file named .env in your project root:

# .env
DATABASE_URL=postgresql://localhost:5432/myapp
PORT=8000
DEBUG=True
SECRET_KEY=my-local-dev-secret-key-change-in-prod

Now read those values in Python:

# quick_decouple.py
from decouple import config

# String (default type)
database_url = config('DATABASE_URL')

# Integer -- decouple casts automatically
port = config('PORT', cast=int)

# Boolean -- handles 'True', 'true', '1', 'yes', etc.
debug = config('DEBUG', cast=bool)

# String with a fallback default
secret_key = config('SECRET_KEY', default='fallback-dev-key')

print(f"DB:    {database_url}")
print(f"Port:  {port} (type: {type(port).__name__})")
print(f"Debug: {debug} (type: {type(debug).__name__})")
print(f"Key:   {secret_key[:10]}...")

Output:

DB:    postgresql://localhost:5432/myapp
Port:  8000 (type: int)
Debug: True (type: bool)
Key:   my-local-d...

Notice that port comes back as a real Python int and debug as a real bool — not strings. With os.environ you would need to write int(os.environ['PORT']) and handle the conversion yourself every time. Decouple does that work once, at the point of reading, so the rest of your code receives properly typed values.

Read on to see how decouple handles missing values, search paths, .ini files, and real-world project layouts.

Python developer managing environment variable secrets with decouple
Secrets stay in the safe. Your code gets a typed value through the slot.

What Is Python Decouple and Why Use It?

Python decouple is a library that implements the Twelve-Factor App principle of strict separation between configuration and code. Configuration here means anything that is likely to vary between deployment environments: database URLs, API keys, feature flags, port numbers, and debug settings. The idea is that these values live in the environment (a .env file locally, environment variables in production), not in the source code that gets committed to a repository.

Think of it like a restaurant kitchen. The recipes (your code) are written down and shared. The ingredients (your config values) change depending on what the supplier has that day — and the head chef does not write the supplier’s phone number into every recipe card. They keep it in a separate contact file. Decouple is that contact file system for your Python app.

Decouple vs os.environ

Here is how python-decouple compares to using os.environ directly:

Featureos.environpython-decouple
Read string valueos.environ['KEY'] — raises KeyError if missingconfig('KEY') — raises UndefinedValueError with clear message
Default valueos.environ.get('KEY', 'default')config('KEY', default='default')
Integer castingint(os.environ.get('PORT', '8000'))config('PORT', default=8000, cast=int)
Boolean castingManual: 'True' == os.environ.get('DEBUG')config('DEBUG', cast=bool) handles True/true/1/yes
Read from .env fileRequires python-dotenv or manual parsingBuilt in — searches parent directories automatically
Support .ini filesNoYes — useful for projects with existing .ini configs
Test overridesMust monkeypatch os.environCan pass values directly in code during tests

The bottom line: os.environ is built-in and requires no extra dependency, but every type conversion is manual boilerplate. Decouple pays for itself the moment you have more than two or three config values that need casting.

Installing python-decouple

Install it with pip in your virtual environment:

# install_decouple.py (run this in your terminal, not as a script)
pip install python-decouple

Output:

Successfully installed python-decouple-3.8

There is one important naming note: the library is called python-decouple on PyPI (what you install), but the import name is decouple (what you use in code). Do not confuse it with decouple on PyPI — that is a different package for Django-specific use. Always install python-decouple.

The .env File: Format and Best Practices

A .env file is a plain text file with one KEY=value pair per line. Decouple searches for it starting in the directory of the script being run, then walks up to parent directories. This means you can place it at the root of your project and it will be found regardless of which subdirectory you run from.

# .env  (place this in your project root)

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/myapp_dev

# Server
PORT=8000
HOST=0.0.0.0

# Feature flags
DEBUG=True
ENABLE_CACHING=False

# Third-party APIs
STRIPE_SECRET_KEY=sk_test_abc123
SENDGRID_API_KEY=SG.xyz789

# Email
EMAIL_BACKEND=console
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587

There are a few formatting rules to know. Values do not need quotes — DEBUG=True works fine. If your value contains spaces or special characters, wrap it in single or double quotes: FULL_NAME='Ada Lovelace'. Lines starting with # are comments and are ignored. Empty lines are also ignored.

The most important rule: add .env to your .gitignore immediately. Create a .env.example file with the same keys but dummy values, and commit that instead. New developers clone the repo, copy .env.example to .env, fill in their local values, and they are ready to go.

Python developer pointing at .gitignore file to protect .env secrets
.env in your repo means your secrets are in everyone’s repo.

Type Casting with cast=

Every value in a .env file is stored as a string. Decouple’s cast parameter converts the string to the type you need before returning it, so the rest of your code never sees a string where it expects an integer or boolean.

Integers and Floats

Pass cast=int or cast=float to convert numeric config values. This is far cleaner than wrapping every read in a manual conversion.

# cast_examples.py
from decouple import config

# These values exist in .env:
# PORT=8000
# WORKERS=4
# TIMEOUT=30.5

port = config('PORT', default=8000, cast=int)
workers = config('WORKERS', default=2, cast=int)
timeout = config('TIMEOUT', default=30.0, cast=float)

print(f"Port:    {port}  -- {type(port).__name__}")
print(f"Workers: {workers}  -- {type(workers).__name__}")
print(f"Timeout: {timeout}  -- {type(timeout).__name__}")

Output:

Port:    8000  -- int
Workers: 4  -- int
Timeout: 30.5  -- float

If the .env value cannot be cast to the requested type — for example, PORT=eight_thousand — decouple raises a ValueError with a clear message pointing to the offending key. You get the error at startup when reading config, not somewhere deep in your app when the value is used.

Booleans

Boolean config values are tricky with os.environ because every string is truthy. "False" evaluates to True in Python because it is a non-empty string. Decouple’s boolean cast handles this correctly by recognizing a set of canonical true and false values.

# cast_bool.py
from decouple import config

# .env contains:
# DEBUG=True
# ENABLE_CACHING=False
# USE_SSL=yes
# MAINTENANCE_MODE=0

debug = config('DEBUG', cast=bool)
caching = config('ENABLE_CACHING', cast=bool)
ssl = config('USE_SSL', cast=bool)
maintenance = config('MAINTENANCE_MODE', cast=bool)

print(f"DEBUG:            {debug}")
print(f"ENABLE_CACHING:   {caching}")
print(f"USE_SSL:          {ssl}")
print(f"MAINTENANCE_MODE: {maintenance}")

Output:

DEBUG:            True
ENABLE_CACHING:   False
USE_SSL:          True
MAINTENANCE_MODE: False

The recognized truthy values are True, true, 1, yes, on. The recognized falsy values are False, false, 0, no, off. Anything else raises a ValueError. This strict set prevents the bug where DEBUG=False still evaluates to True because you forgot to cast.

Comma-Separated Lists

Decouple does not have a built-in list type, but you can pass any callable as the cast argument — including a lambda that splits a string into a list.

# cast_list.py
from decouple import config, Csv

# .env contains:
# ALLOWED_HOSTS=localhost,127.0.0.1,myapp.com
# CORS_ORIGINS=http://localhost:3000,https://app.example.com

# Option 1: built-in Csv helper (strips whitespace, handles quoting)
allowed_hosts = config('ALLOWED_HOSTS', cast=Csv())

# Option 2: lambda for simple cases
cors_origins = config('CORS_ORIGINS', default='', cast=lambda v: [s.strip() for s in v.split(',')])

print(f"Allowed hosts: {allowed_hosts}")
print(f"CORS origins:  {cors_origins}")

Output:

Allowed hosts: ['localhost', '127.0.0.1', 'myapp.com']
CORS origins:  ['http://localhost:3000', 'https://app.example.com']

The Csv() helper from decouple is the cleaner option for comma-separated values. It handles edge cases like extra whitespace and quoted values with commas inside them. The lambda approach works fine for simple cases where you control the format.

Python decouple type casting int bool float str config values
config(‘PORT’, cast=int) — your last line of defense before NoneType has no attribute ‘listen’.

Defaults and Missing Values

When a key is missing from both the .env file and the actual environment, decouple’s behavior depends on whether you provided a default.

# defaults_demo.py
from decouple import config, UndefinedValueError

# KEY_WITH_DEFAULT is not in .env -- returns the default
log_level = config('LOG_LEVEL', default='INFO')
print(f"Log level: {log_level}")

# KEY_WITH_NONE_DEFAULT is not in .env -- returns None
cache_url = config('CACHE_URL', default=None)
print(f"Cache URL: {cache_url}")

# KEY_REQUIRED is not in .env and has no default -- raises UndefinedValueError
try:
    api_key = config('REQUIRED_API_KEY')
except UndefinedValueError as e:
    print(f"Missing required config: {e}")

Output:

Log level: INFO
Cache URL: None
Missing required config: REQUIRED_API_KEY not found. Declare it as envvar or define a default value.

This behavior is intentional and useful. Required values — things your app absolutely cannot run without — should have no default. That way decouple raises a clear error at startup rather than letting the app start in a broken state and fail later with a cryptic message. Optional values should have a sensible default so the app can run in a minimal configuration without a full .env file in place.

.ini File Support

In addition to .env files, decouple can read from .ini files using the AutoConfig or explicit RepositoryIni approach. This is useful when your project already has a settings.ini or setup.cfg and you do not want to introduce a second config file.

# settings.ini
[settings]
DATABASE_URL=postgresql://localhost:5432/myapp
PORT=8000
DEBUG=True
# read_ini.py
from decouple import Config, RepositoryIni

# Explicitly read from a .ini file instead of .env
config = Config(RepositoryIni('settings.ini'))

database_url = config('DATABASE_URL')
port = config('PORT', cast=int)
debug = config('DEBUG', cast=bool)

print(f"DB:    {database_url}")
print(f"Port:  {port}")
print(f"Debug: {debug}")

Output:

DB:    postgresql://localhost:5432/myapp
Port:  8000
Debug: True

The default config object (imported directly from decouple) uses AutoConfig, which searches for .env first, then .ini, then falls back to actual environment variables. You only need to use RepositoryIni explicitly when you want to force a specific file rather than letting decouple search.

Python decouple AutoConfig reading from .env and .ini files
AutoConfig: checks .env, then .ini, then the actual environment. In that order. Every time.

Django Integration

Django’s settings.py is the most common place developers accidentally commit secrets. Decouple is designed to slot in cleanly as a drop-in replacement for hardcoded settings.

# settings.py (Django)
from decouple import config, Csv

# Core Django settings
SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', cast=bool, default=False)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv(), default='localhost')

# Database -- dj-database-url makes this even cleaner
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME', default='myapp'),
        'USER': config('DB_USER', default='postgres'),
        'PASSWORD': config('DB_PASSWORD', default=''),
        'HOST': config('DB_HOST', default='localhost'),
        'PORT': config('DB_PORT', default=5432, cast=int),
    }
}

# Email
EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend')
EMAIL_HOST = config('EMAIL_HOST', default='localhost')
EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', cast=bool, default=False)

# Stripe
STRIPE_PUBLIC_KEY = config('STRIPE_PUBLIC_KEY', default='')
STRIPE_SECRET_KEY = config('STRIPE_SECRET_KEY', default='')

The pattern is consistent throughout: use config('KEY') for required values that must exist in production, and config('KEY', default=...) for optional values with safe development defaults. The entire settings.py file becomes safe to commit because it contains no actual secrets — just the names of the keys and their defaults.

Real-Life Example: Environment-Aware FastAPI App

Here is a realistic FastAPI application config module that uses decouple to manage all its settings. This pattern — a dedicated config.py module that gathers all config into a dataclass — scales cleanly as the project grows.

# config.py
from dataclasses import dataclass
from decouple import config, Csv, UndefinedValueError

@dataclass
class AppConfig:
    # Server
    host: str
    port: int
    debug: bool
    workers: int

    # Database
    database_url: str

    # Security
    secret_key: str
    allowed_origins: list

    # External APIs
    stripe_secret_key: str
    sendgrid_api_key: str
    slack_webhook_url: str

    # Feature flags
    enable_caching: bool
    enable_email: bool

def load_config() -> AppConfig:
    """Load and validate all application configuration at startup."""
    return AppConfig(
        # Server
        host=config('HOST', default='0.0.0.0'),
        port=config('PORT', default=8000, cast=int),
        debug=config('DEBUG', default=False, cast=bool),
        workers=config('WORKERS', default=1, cast=int),

        # Database -- required in production, no default
        database_url=config('DATABASE_URL'),

        # Security -- required always
        secret_key=config('SECRET_KEY'),
        allowed_origins=config('ALLOWED_ORIGINS', cast=Csv(), default='http://localhost:3000'),

        # External APIs -- optional with empty defaults (check before use)
        stripe_secret_key=config('STRIPE_SECRET_KEY', default=''),
        sendgrid_api_key=config('SENDGRID_API_KEY', default=''),
        slack_webhook_url=config('SLACK_WEBHOOK_URL', default=''),

        # Feature flags
        enable_caching=config('ENABLE_CACHING', default=False, cast=bool),
        enable_email=config('ENABLE_EMAIL', default=False, cast=bool),
    )

# main.py
from fastapi import FastAPI
from config import load_config, AppConfig

cfg: AppConfig = load_config()  # Fails fast at startup if required vars missing
app = FastAPI(debug=cfg.debug)

@app.get("/health")
def health():
    return {
        "status": "ok",
        "debug": cfg.debug,
        "caching": cfg.enable_caching,
        "port": cfg.port,
    }

if __name__ == "__main__":
    import uvicorn
    print(f"Starting on {cfg.host}:{cfg.port} (debug={cfg.debug})")
    uvicorn.run(app, host=cfg.host, port=cfg.port, workers=cfg.workers)

Output (with example .env values):

Starting on 0.0.0.0:8000 (debug=False)

The key design choice here is that load_config() is called once at module level, so any missing required variable raises UndefinedValueError the moment the app starts — not on the first request five minutes into a production deploy. The dataclass gives you IDE autocomplete throughout the rest of the codebase and makes it obvious what configuration the app expects without opening the .env file.

FastAPI application loading config at startup with python-decouple
load_config() at startup. Not at request time. Never at request time.

Testing with Decouple

Config management and testability often conflict — tests need predictable values, but decouple reads from files. There are two clean approaches: use a test-specific .env file, or monkeypatch os.environ.

# test_config.py
import os
import pytest
from unittest.mock import patch

# Approach 1: patch os.environ before importing config
def test_debug_defaults_to_false():
    with patch.dict(os.environ, {'DATABASE_URL': 'sqlite:///test.db', 'SECRET_KEY': 'test-key'}, clear=True):
        # Reimport or reload config within the patch context
        from decouple import config
        # config() reads os.environ after checking .env
        debug = config('DEBUG', default=False, cast=bool)
        assert debug is False

def test_port_casting():
    with patch.dict(os.environ, {'PORT': '9000'}):
        from decouple import config
        port = config('PORT', cast=int)
        assert port == 9000
        assert isinstance(port, int)

def test_missing_required_raises():
    from decouple import config, UndefinedValueError
    with patch.dict(os.environ, {}, clear=True):
        # Remove DATABASE_URL from the environment
        env_without_db = {k: v for k, v in os.environ.items() if k != 'DATABASE_URL'}
        with patch.dict(os.environ, env_without_db, clear=True):
            with pytest.raises(UndefinedValueError):
                config('DATABASE_URL')

Output (pytest):

...
3 passed in 0.12s

The recommended approach for larger projects is to create a .env.test file and use a fixture that temporarily swaps the decouple search path to point at it. This gives you a full, realistic config setup for tests without polluting your development .env. The patch.dict(os.environ, ...)` approach shown above works well for unit tests of individual config values.

Frequently Asked Questions

Does decouple replace os.environ entirely?

Not entirely -- decouple reads from .env files first, then falls back to actual environment variables set in the shell or by the deployment platform. In production you typically do not deploy a .env file; instead, environment variables are set by the platform (Heroku config vars, Docker environment, Kubernetes secrets). Decouple reads those just fine through the os.environ fallback. The .env file is a development convenience, not a production requirement.

Where should I put my .env file?

Place it in the root of your project -- the same directory as manage.py (Django), main.py (FastAPI/Flask), or your top-level package. Decouple uses AutoConfig which walks up from the running script's directory until it finds a .env or .ini file, so as long as it is somewhere in the directory tree above your code, it will be found. Do not commit it to version control -- add it to .gitignore and commit a .env.example instead.

How do I handle multiple environments (dev, staging, prod)?

The cleanest approach is one .env per environment, kept out of the repo. Your CI/CD pipeline injects the appropriate values as environment variables for staging and production. Locally you maintain a .env with development values. You can also use a tool like direnv to switch .env files automatically when you change directories. Never create .env.production files and commit them -- that defeats the entire purpose.

What is the difference between python-decouple and python-dotenv?

Both libraries load .env files, but they take different approaches. python-dotenv loads values into os.environ as a side effect, making them available to os.environ.get() and any other code that reads the environment. python-decouple does not modify os.environ -- instead it provides a config() function that reads directly from the file or the real environment. Decouple is preferable when you want typed values, sensible defaults, and a clean API. Dotenv is useful when you need the values to land in os.environ for libraries that read from there directly.

Should I use .env files in production?

Generally no. Deploying a .env file to a server creates a file on disk containing secrets, which is a security risk if the server is ever compromised. In production, use your platform's secret management: Heroku config vars, AWS Secrets Manager, Docker secrets, Kubernetes secrets, or environment variables set in your deployment config. Decouple reads all of these through its os.environ fallback, so no code changes are needed between development (using .env) and production (using platform secrets).

How do I manage a large number of config values?

Group related config into separate config objects or dataclasses, as shown in the FastAPI example above. A single config.py module that defines everything in one place makes it easy to see what the app needs at a glance. Avoid calling config() scattered throughout your codebase -- centralizing config reads means you only have one place to look when a value is wrong, and one place to update when a key changes.

Conclusion

Python decouple solves a problem that trips up almost every developer at some point: configuration that leaks into source code. The library gives you config('KEY') with type casting, defaults, and clear errors for missing required values -- all reading from a .env file that stays out of your repository. We covered reading strings, integers, booleans, and lists; comparing decouple to raw os.environ; using .ini files; integrating with Django settings; and building a real FastAPI config module with a dataclass pattern.

The next step is to take the config module pattern from the real-life example and adapt it to your own project. Start by identifying every hardcoded string in your settings that could change between environments -- database URLs, API keys, debug flags, port numbers -- and move them behind config() calls. Your future self, and anyone else who has to deploy your app, will thank you.

Full documentation for python-decouple is at github.com/HBNetwork/python-decouple. The Twelve-Factor App methodology that inspired it is documented at 12factor.net/config. For a complementary approach using type-validated settings objects, see our guide on Pydantic Settings for Configuration Management. If you prefer a more flexible multi-environment solution, dynaconf is worth exploring. To keep your secrets extra safe, pair decouple with the built-in Python secrets module for generating tokens and keys.

How To Use Python Box for Dot-Notation Dict Access

How To Use Python Box for Dot-Notation Dict Access

Beginner

You have a config dictionary with three levels of nesting. To read one value you write config["database"]["connection"]["host"]. Then you do it again in the next function. And the one after that. Every bracket and every quoted string key is another thing to mistype, and when you do get one wrong, Python hands you a KeyError with no idea which level failed. There is a better way to navigate nested dicts in Python, and it is called python-box.

python-box is a third-party library that wraps ordinary Python dicts in a Box object, letting you access nested values with dot-notation — the same clean syntax you already use for object attributes. It is a single pip install python-box, it has no heavy dependencies, and a Box object still behaves like a normal dict whenever you need it to. You can pass it to any function that expects a dict, serialize it back to JSON or YAML, and even configure it to return safe default values instead of raising errors on missing keys.

In this article we will walk through everything you need to make the most of python-box. We will cover basic dot-notation access, deep nested lookups, camel-case key handling, loading Box objects directly from JSON and YAML files, building a BoxList, and creating a practical application-config system that ties it all together. By the end you will be able to replace messy bracket-chain lookups throughout your codebase with readable, attribute-style access.

Reading Config with Box: Quick Example

Install the library first, then run this self-contained script to see the core idea in action:

pip install python-box
# quick_example.py
from box import Box

config = Box({
    "database": {
        "host": "db.internal",
        "port": 5432,
        "name": "myapp"
    }
})

# Dot-notation beats bracket chains
print(config.database.host)
print(config.database.port)
print(config.database.name)

# It still works as a plain dict
print(config["database"]["host"])

Output:

db.internal
5432
myapp
db.internal

We pass an ordinary Python dict to Box() and get back an object where every nested dict is automatically wrapped in its own Box. That means config.database is itself a Box, so config.database.host works in one clean chain. Notice the last line — because Box is a dict subclass, every bracket-notation lookup still works exactly as before, which means migrating an existing codebase is safe and gradual.

The sections below dive into the features you will reach for most: default values, key sanitization, loading from files, and building a real config system.

Developer overwhelmed by nested bracket access chains in Python
config[‘db’][‘conn’][‘host’] — three brackets to mistype, zero mercy on typos.

What Is python-box and Why Use It?

A plain Python dict requires string keys inside square brackets. When a dict is three levels deep you end up with a wall of punctuation that obscures the actual data you are trying to read. python-box solves this by acting as a recursive attribute proxy over your dict: it recursively converts every nested dict into a Box at creation time, so every level of nesting is reachable via attributes.

Here is how Box compares to the alternatives Python developers typically reach for:

ApproachDot-notationStays a dictDeep nestingDefault on missingFile loading
Plain dictNoYesBracket chainsNo (KeyError)Manual
types.SimpleNamespaceYesNoManual nestingNo (AttributeError)Manual
dataclassesYesNoVerbose schemaSet in classManual
BoxYesYesAutomaticOptional (default_box)Built-in JSON/YAML/TOML

The key advantage of Box over SimpleNamespace or dataclasses is that it remains a genuine dict subclass. You can pass a Box to json.dumps(), to any function expecting a Mapping, or to Pydantic validators without any conversion. You get the ergonomics of attribute access and the interoperability of a dict.

Safe Access with default_box

The biggest source of runtime crashes when reading config dicts is a missing key. By default Box raises KeyError (just like a dict), but you can flip the default_box=True flag to get a “safe” mode where missing keys return an empty Box instead of raising an error. This is useful when your config schema is optional in places.

# default_box.py
from box import Box

config = Box(
    {
        "server": {
            "host": "localhost",
            "port": 8080,
        }
    },
    default_box=True,
)

# Key that exists
print(config.server.host)

# Key that does NOT exist -- returns empty Box, not KeyError
print(config.server.timeout)

# Chaining through multiple missing levels is also safe
print(config.cache.redis.url)

# Truthiness: empty Box is falsy
if not config.cache.redis.url:
    print("Redis not configured -- using in-memory cache")

Output:

localhost
Box: {}
Box: {}
Redis not configured -- using in-memory cache

When default_box=True is set, every attribute access on a missing key returns a new empty Box object rather than raising. That empty Box evaluates to False in a boolean context, so a simple if not config.cache.redis.url guard is all you need. One important detail: default_box mode does not auto-create keys when you write to them; it only suppresses errors on reads.

Confident developer navigating nested Box objects with default_box safety
default_box=True — KeyError? Never heard of it.

Camel-Case and Special-Character Keys

Real-world JSON often uses camelCase keys or keys with hyphens and spaces — none of which are valid Python attribute names. Box handles this with two flags: camel_killer_box, which converts camelCase keys to snake_case automatically, and box_dots, which lets you navigate dot-separated key names as a single attribute lookup.

# camel_keys.py
from box import Box

# camelCase keys from a third-party API response
api_response = Box(
    {
        "userId": 1,
        "firstName": "Alice",
        "contactInfo": {
            "emailAddress": "alice@example.com",
            "phoneNumber": "555-0100",
        },
    },
    camel_killer_box=True,  # converts camelCase -> snake_case
)

print(api_response.user_id)
print(api_response.first_name)
print(api_response.contact_info.email_address)

Output:

1
Alice
alice@example.com

camel_killer_box=True walks through every key at every level and converts it from camelCase to snake_case at Box construction time. This is especially useful when your Python code follows PEP 8 naming conventions but you are consuming JSON from a JavaScript API. The original dict keys are preserved in the underlying data — Box just builds snake_case attribute aliases on top of them so your code reads cleanly.

Loading Box Directly from JSON and YAML Files

Manually reading a file and calling json.load() or yaml.safe_load() before wrapping the result in Box() works, but Box ships with class methods that do it all in one call. Box.from_json() and Box.from_yaml() accept a file path or a raw string, parse it, and return a fully nested Box object.

# load_from_file.py
import json
import tempfile
import os
from box import Box

# Simulate a JSON config file on disk
config_data = {
    "app": {
        "name": "DataPipeline",
        "version": "2.1.0",
        "debug": False,
    },
    "database": {
        "host": "db.internal",
        "port": 5432,
        "pool_size": 10,
    },
    "logging": {
        "level": "INFO",
        "file": "/var/log/pipeline.log",
    }
}

# Write it to a temp file so the example is self-contained
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
    json.dump(config_data, f)
    tmp_path = f.name

# Load directly into a Box
config = Box.from_json(filename=tmp_path)

print(config.app.name)
print(config.app.version)
print(config.database.pool_size)
print(config.logging.level)

os.unlink(tmp_path)  # clean up

Output:

DataPipeline
2.1.0
10
INFO

Box.from_json(filename=path) opens the file, parses the JSON, and recursively wraps every nested dict into a Box. The equivalent for YAML is Box.from_yaml(filename=path) — it requires pip install python-box[yaml] to include the PyYAML dependency. You can also pass a raw string with Box.from_json(json_string=...) if you have fetched JSON from a network call and want to wrap it without saving to disk first.

Developer loading a JSON file directly into a Box object in one step
Box.from_json() — one call from file to dot-notation.

Working with BoxList

When a JSON or dict value is a list of dicts, Box automatically wraps each element of that list in a Box as well. You interact with the list via BoxList — a list subclass that ensures every dict element inside it is a Box, so dot-notation works on list items too.

# boxlist_example.py
from box import Box

data = Box({
    "users": [
        {"id": 1, "name": "Alice", "role": "admin"},
        {"id": 2, "name": "Bob",   "role": "editor"},
        {"id": 3, "name": "Carol", "role": "viewer"},
    ]
})

# Each list element is a Box -- dot-notation works
for user in data.users:
    print(f"{user.id}: {user.name} ({user.role})")

# Filter using attribute access
admins = [u for u in data.users if u.role == "admin"]
print(f"\nAdmins: {[a.name for a in admins]}")

Output:

1: Alice (admin)
2: Bob (editor)
3: Carol (viewer)

Admins: ['Alice']

You do not need to create a BoxList yourself — Box does it automatically whenever a list of dicts is encountered during construction. The resulting list supports all normal list operations (append, extend, pop, slicing) and any dict you append to a BoxList is automatically wrapped into a Box. This means you can keep adding new user objects to data.users with plain dicts and read them back with dot-notation immediately.

Converting Box Back to a Plain Dict

Because Box is a dict subclass, most libraries that accept dicts will handle a Box natively. When you need a guaranteed plain dict — for example, when serializing to JSON with json.dumps(), passing to a strict type checker, or writing to a database — use box.to_dict(). This recursively unwraps every nested Box and BoxList into ordinary Python types.

# convert_back.py
import json
from box import Box

config = Box({
    "service": "auth",
    "settings": {
        "timeout": 30,
        "retries": 3,
    },
    "tags": ["production", "v2"],
})

# Convert the whole structure back to a plain dict
plain = config.to_dict()
print(type(plain))
print(type(plain["settings"]))

# Now it serializes normally
print(json.dumps(plain, indent=2))

Output:

<class 'dict'>
<class 'dict'>
{
  "service": "auth",
  "settings": {
    "timeout": 30,
    "retries": 3
  },
  "tags": [
    "production",
    "v2"
  ]
}

After calling to_dict(), every nested object is a plain Python dict or list — no trace of Box remains. The reverse path is just as simple: if you need to re-wrap a plain dict as a Box later, call Box(plain).

Organized developer converting a Box back to a plain Python dictionary
to_dict() — because sometimes you need to hand it off to the outside world.

Real-Life Example: Application Config Loader

Here is a practical config system that uses several Box features together: loading from a JSON file, providing safe defaults for optional sections, and exposing a clean interface to the rest of the application. This pattern works well for any project that has a JSON or YAML config file alongside the code.

# config_loader.py
import json
import os
import tempfile
from box import Box

# --- Simulated config file (in a real project, this lives on disk) ---
CONFIG_DATA = {
    "app": {
        "name": "InventoryService",
        "version": "1.0.0",
        "debug": False,
    },
    "database": {
        "host": "db.internal",
        "port": 5432,
        "name": "inventory",
        "pool_size": 5,
    },
    "cache": {
        "backend": "redis",
        "host": "cache.internal",
        "ttl_seconds": 300,
    },
    "features": {
        "enable_notifications": True,
        "max_batch_size": 1000,
    },
}

class AppConfig:
    """Thin config wrapper built on Box."""

    def __init__(self, config_path: str):
        with open(config_path) as f:
            raw = json.load(f)
        # default_box=True so optional keys never raise KeyError
        self._box = Box(raw, default_box=True)

    # -- Convenience properties --

    @property
    def app_name(self) -> str:
        return self._box.app.name

    @property
    def db_dsn(self) -> str:
        db = self._box.database
        return f"postgresql://{db.host}:{db.port}/{db.name}"

    @property
    def cache_ttl(self) -> int:
        # Returns empty Box (falsy) if key is absent; fall back to 60
        return self._box.cache.ttl_seconds or 60

    @property
    def notifications_enabled(self) -> bool:
        return bool(self._box.features.enable_notifications)

    def get(self, *keys, default=None):
        """Navigate arbitrary key path: config.get('database', 'host')."""
        node = self._box
        for key in keys:
            node = getattr(node, key, None)
            if node is None:
                return default
        return node

def main():
    # Write the simulated config to a temp file
    with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
        json.dump(CONFIG_DATA, f)
        path = f.name

    cfg = AppConfig(path)

    print(f"Service   : {cfg.app_name}")
    print(f"DB DSN    : {cfg.db_dsn}")
    print(f"Cache TTL : {cfg.cache_ttl}s")
    print(f"Notify    : {cfg.notifications_enabled}")
    print(f"Max batch : {cfg.get('features', 'max_batch_size')}")
    print(f"Missing   : {cfg.get('sentry', 'dsn', default='not configured')}")

    os.unlink(path)

if __name__ == "__main__":
    main()

Output:

Service   : InventoryService
DB DSN    : postgresql://db.internal:5432/inventory
Cache TTL : 300s
Notify    : True
Max batch : 1000
Missing   : not configured

The AppConfig class keeps Box as an implementation detail, exposing clean Python properties to the rest of the application. The default_box=True flag means that reading self._box.sentry.dsn returns an empty (falsy) Box instead of raising when the key is absent. The get() helper demonstrates how to navigate an arbitrary key path safely while supplying a fallback default. To extend this pattern, add a reload() method that re-reads the file and rebuilds the Box — useful for long-running services that support live config updates.

Configuration control panel with organized labeled settings -- AppConfig pattern
One class, zero KeyErrors, infinite extensibility.

Frequently Asked Questions

How do I install python-box?

Run pip install python-box. For YAML support add the extra: pip install python-box[yaml]. For TOML support use pip install python-box[toml]. The base install has no required dependencies beyond the Python standard library, keeping your dependency tree small.

What happens if a key contains a dot or a hyphen?

A key like "content-type" or "api.version" cannot be accessed as a Python attribute directly because hyphens are subtraction and dots are attribute separators. Box handles hyphens by letting you use bracket notation as usual: box["content-type"]. For dot-containing keys, enable box_dots=True when constructing the Box and access them with the full dotted name: box["api.version"]. Alternatively, use safe_attr=True to have Box replace unsafe characters with underscores automatically.

Can I make a Box read-only?

Yes. Pass frozen_box=True to the constructor and the resulting object is immutable — any attempt to set or delete a key raises a BoxError. This is useful for config objects that should not be modified at runtime. It pairs well with default_box=False (the default) so you also get immediate KeyError feedback for any typos in key names.

How does Box compare to addict or munch?

addict and munch also provide dot-notation dict access, but Box goes further. Box ships with direct file-loading methods (from_json, from_yaml, from_toml), a frozen_box immutability mode, a camel_killer_box flag for converting camelCase keys, and the BoxList type that auto-wraps list items. If you need only basic dot-notation on a flat dict, any of the three works. If you are building a config system that reads files and handles mixed key styles, Box’s built-in features save significant boilerplate.

What type does a missing key return in default_box mode?

An empty Box instance (Box({})). That empty Box evaluates to False in a boolean context, has a length of 0, and converts to an empty dict via to_dict(). If you need a specific default type — for example, an empty string or zero — compare against the falsy empty Box and supply your own fallback: value = config.optional.key or "default string".

Is Box slower than a plain dict?

Yes, slightly. Box adds a thin attribute-resolution layer on top of dict lookups and recurses into nested dicts at construction time. For config objects that are built once and read many times, this overhead is negligible — microseconds per lookup. For hot paths that perform tens of thousands of lookups per second, read the value into a local variable once rather than traversing the Box repeatedly in a tight loop.

Conclusion

python-box is a small library with a focused purpose: make nested Python dicts easier to navigate. We covered the core use cases — creating a Box from a plain dict, enabling default_box=True for safe access, converting camelCase API responses with camel_killer_box, loading directly from JSON and YAML files with Box.from_json() and Box.from_yaml(), working with BoxList for lists of dicts, and converting back to plain dicts with to_dict(). The real-life config loader showed how these features compose into a maintainable, testable config system.

The best next step is to take a config dict from one of your own projects and wrap it in a Box. Start with default_box=True and frozen_box=True if the config should not change at runtime. Once you see how much bracket noise disappears, you will find it hard to go back. For the full API — including TOML loading, BoxKeyError, and merge utilities — see the official documentation at https://github.com/cdgriffith/Box.

A natural companion to python-box for config management is Pydantic Settings, which adds type validation and environment variable loading on top of your config schema. If you need to manage complex config across multiple environments, combining Box for dot-notation file loading with Pydantic Settings for validation gives you the best of both worlds.

How To Use Python pyzmq for Messaging with ZeroMQ

How To Use Python pyzmq for Messaging with ZeroMQ

Intermediate

You have two Python processes that need to talk to each other. Maybe a data pipeline producer that scrapes data and a consumer that saves it to a database. Maybe a task queue where a coordinator dishes out jobs to a pool of workers. You try Python’s built-in multiprocessing.Queue and it works — until you need workers on different machines, or you want to add a third process in the middle, or the coordinator crashes and the whole system freezes. This is the wall that pyzmq helps you get past.

pyzmq is the Python binding for ZeroMQ, a high-performance asynchronous messaging library. ZeroMQ gives you sockets that handle connection management, message framing, retries, and backpressure — without requiring a central message broker like RabbitMQ or Redis. You install one package, import it, and you get production-grade inter-process communication that works across threads, processes, and machines on a network.

In this article we will cover what ZeroMQ is and how its socket types work, how to use the three most important messaging patterns — request/reply, push/pull, and publish/subscribe — how to handle multiple sockets at once with a poller, how to send structured data like JSON and Python objects, and how to build a real task-dispatching system. By the end you will have a solid grasp of pyzmq and know which pattern to reach for in your own projects.

pyzmq in Python: Quick Example

Here is a self-contained request/reply example — the most common ZeroMQ pattern. Run the server script first in one terminal, then the client in a second terminal:

# zmq_server.py
import zmq

context = zmq.Context()
socket = context.socket(zmq.REP)   # REP = reply socket
socket.bind("tcp://127.0.0.1:5555")

print("Server listening on port 5555...")
while True:
    message = socket.recv_string()
    print(f"Received: {message}")
    socket.send_string(f"Echo: {message}")
# zmq_client.py
import zmq

context = zmq.Context()
socket = context.socket(zmq.REQ)   # REQ = request socket
socket.connect("tcp://127.0.0.1:5555")

for i in range(3):
    socket.send_string(f"Hello {i}")
    reply = socket.recv_string()
    print(f"Reply: {reply}")

socket.close()
context.term()

Output (client terminal):

Reply: Echo: Hello 0
Reply: Echo: Hello 1
Reply: Echo: Hello 2

The key things happening here: context = zmq.Context() creates a ZeroMQ context — this manages all the sockets and background threads, and you only ever need one per process. The server calls socket.bind() while the client calls socket.connect() — this is the standard ZeroMQ convention. REP and REQ sockets are strictly paired and must alternate send/recv calls in lockstep.

The sections below go deeper into each pattern, including how to break out of the request/reply lockstep when you need more flexibility.

ZeroMQ socket.bind and socket.connect illustration
socket.bind() said ‘I’ll wait.’ socket.connect() said ‘I’ll find you.’

What Is ZeroMQ and Why Use It?

ZeroMQ (often written as 0MQ or ZMQ) is a messaging library — not a message broker. This is an important distinction. Tools like RabbitMQ or Kafka are brokers: they run as separate services that store and route messages. ZeroMQ is a library that gives your existing processes smart sockets. There is no broker to install, configure, or maintain. The sockets handle queuing, reconnection, and message framing internally.

Think of a regular TCP socket as a garden hose — reliable for moving water, but you have to manage the pressure, connections, and flow yourself. ZeroMQ sockets are more like a pneumatic tube system: you push an object in one end and it arrives intact at the other end, already framed, buffered, and ready to use. ZeroMQ also handles the case where the other end is temporarily unavailable — it queues messages and reconnects automatically.

FeatureRaw TCP / multiprocessing.QueueZeroMQ (pyzmq)
Message framingManual (you parse byte boundaries)Automatic
Multiple patternsOnly point-to-pointREQ/REP, PUB/SUB, PUSH/PULL, DEALER/ROUTER
Cross-machineYes (TCP), No (Queue)Yes (TCP, IPC, inproc)
Broker requiredNoNo
BackpressureManualBuilt-in (HWM)
Fan-out / fan-inHard to implementNative with right socket type

pyzmq is the official Python binding for ZeroMQ. It exposes the full ZeroMQ API and adds Python conveniences like send_string(), recv_json(), and async support via asyncio. Install it with pip install pyzmq — the package bundles the ZeroMQ C library so there is nothing extra to install.

The PUSH/PULL Pattern: Task Distribution

PUSH/PULL is the right pattern when you have one process producing work and multiple workers consuming it. The producer connects a PUSH socket and sends tasks; each worker connects a PULL socket and receives tasks. ZeroMQ distributes tasks round-robin across all connected workers automatically.

This example simulates a URL-checking pipeline. Run the producer in one terminal and one or more workers in separate terminals:

# push_producer.py
import zmq
import time

context = zmq.Context()
sender = context.socket(zmq.PUSH)
sender.bind("tcp://127.0.0.1:5556")

urls = [
    "https://httpbin.org/get",
    "https://httpbin.org/status/200",
    "https://httpbin.org/status/404",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/json",
]

print(f"Sending {len(urls)} tasks to workers...")
time.sleep(1)   # give workers time to connect

for url in urls:
    sender.send_string(url)
    print(f"Dispatched: {url}")

# Send poison pills to stop each worker
for _ in range(3):   # adjust to match worker count
    sender.send_string("STOP")

sender.close()
context.term()
# pull_worker.py
import zmq

context = zmq.Context()
receiver = context.socket(zmq.PULL)
receiver.connect("tcp://127.0.0.1:5556")

print("Worker ready, waiting for tasks...")
while True:
    task = receiver.recv_string()
    if task == "STOP":
        print("Received stop signal, shutting down.")
        break
    print(f"Worker processing: {task}")

receiver.close()
context.term()

Output (one worker terminal):

Worker ready, waiting for tasks...
Worker processing: https://httpbin.org/get
Worker processing: https://httpbin.org/status/404
Worker processing: https://httpbin.org/json
Received stop signal, shutting down.

Notice that the producer calls bind() and the workers call connect() — the stable endpoint (the one that others connect to) should be the one that binds. The time.sleep(1) in the producer is a ZeroMQ gotcha: the first few milliseconds after connect() the socket is still handshaking, so messages sent immediately can be lost. A short sleep (or, better, a synchronization step) prevents the “slow joiner” problem.

ZeroMQ PUSH PULL pattern task distribution
Round-robin load balancing: the feature you used to write 200 lines of threading code for.

The PUB/SUB Pattern: Broadcasting Events

PUB/SUB is for broadcast scenarios: one publisher sends messages and any number of subscribers receive them. Subscribers can filter by topic prefix, so a single publisher can serve many different audiences without sending irrelevant data to each one.

# pub_publisher.py
import zmq
import time
import random

context = zmq.Context()
publisher = context.socket(zmq.PUB)
publisher.bind("tcp://127.0.0.1:5557")

topics = ["BTC", "ETH", "SOL"]
print("Publisher broadcasting prices...")
time.sleep(0.5)   # allow subscribers to connect

for _ in range(10):
    topic = random.choice(topics)
    price = round(random.uniform(1000, 60000), 2)
    message = f"{topic} {price}"
    publisher.send_string(message)
    print(f"Published: {message}")
    time.sleep(0.3)

publisher.close()
context.term()
# sub_subscriber.py
import zmq
import sys

topic_filter = sys.argv[1] if len(sys.argv) > 1 else "BTC"

context = zmq.Context()
subscriber = context.socket(zmq.SUB)
subscriber.connect("tcp://127.0.0.1:5557")
subscriber.setsockopt_string(zmq.SUBSCRIBE, topic_filter)

print(f"Subscribed to topic: {topic_filter}")
for _ in range(5):
    message = subscriber.recv_string()
    topic, price = message.split()
    print(f"Received -- {topic}: ${price}")

subscriber.close()
context.term()

Output (subscriber filtering for BTC):

Subscribed to topic: BTC
Received -- BTC: $42318.76
Received -- BTC: $41900.12
Received -- BTC: $43001.55
Received -- BTC: $42750.00
Received -- BTC: $41200.88

The critical line is subscriber.setsockopt_string(zmq.SUBSCRIBE, topic_filter) — without this, subscribers receive nothing. Setting the filter to an empty string "" subscribes to all messages. ZeroMQ filters are prefix-based: subscribing to "BTC" will match "BTC 42000", "BTCUSDT 42000", and anything else starting with those three characters. Design your topic strings accordingly.

Sending JSON and Python Objects

Real applications rarely send plain strings. pyzmq makes it easy to send JSON, bytes, or Python objects directly with a small set of convenience methods. Here is how to use them:

# zmq_json_example.py
import zmq
import json

# --- SERVER (in one process) ---
def run_server():
    context = zmq.Context()
    socket = context.socket(zmq.REP)
    socket.bind("tcp://127.0.0.1:5558")

    raw = socket.recv_json()           # deserializes JSON automatically
    print(f"Server received: {raw}")   # raw is already a Python dict

    response = {"status": "ok", "echo": raw["command"], "code": 200}
    socket.send_json(response)         # serializes dict to JSON automatically

    socket.close()
    context.term()

# --- CLIENT (in another process) ---
def run_client():
    context = zmq.Context()
    socket = context.socket(zmq.REQ)
    socket.connect("tcp://127.0.0.1:5558")

    payload = {"command": "ping", "args": [1, 2, 3]}
    socket.send_json(payload)          # dict -> JSON -> bytes -> socket

    reply = socket.recv_json()         # bytes -> JSON -> dict
    print(f"Client received: {reply}")

    socket.close()
    context.term()

Output:

Server received: {'command': 'ping', 'args': [1, 2, 3]}
Client received: {'status': 'ok', 'echo': 'ping', 'code': 200}

send_json() and recv_json() handle serialization automatically — they call json.dumps() and json.loads() under the hood. For even better performance with large payloads, consider sending msgpack-encoded bytes via send() and recv(), since msgpack is faster and more compact than JSON for binary data.

ZeroMQ send_json recv_json structured data
send_json() — because nobody should serialize dicts by hand in 2026.

Handling Multiple Sockets with a Poller

Sometimes a single process needs to listen on more than one socket at once — for example, a monitor that receives metrics from multiple sources. Blocking on one socket means you miss messages from the others. ZeroMQ’s Poller solves this: it watches multiple sockets simultaneously and returns whichever ones have messages ready.

# zmq_poller.py
import zmq

context = zmq.Context()

# Two sockets receiving data from different sources
metrics_socket = context.socket(zmq.PULL)
metrics_socket.bind("tcp://127.0.0.1:5559")

alerts_socket = context.socket(zmq.PULL)
alerts_socket.bind("tcp://127.0.0.1:5560")

poller = zmq.Poller()
poller.register(metrics_socket, zmq.POLLIN)
poller.register(alerts_socket, zmq.POLLIN)

print("Monitor listening on both sockets...")
message_count = 0
while message_count < 10:
    ready = dict(poller.poll(timeout=2000))   # wait up to 2 seconds

    if metrics_socket in ready:
        data = metrics_socket.recv_string()
        print(f"[METRIC]  {data}")
        message_count += 1

    if alerts_socket in ready:
        data = alerts_socket.recv_string()
        print(f"[ALERT]   {data}")
        message_count += 1

    if not ready:
        print("No messages received in 2 seconds -- still waiting.")

metrics_socket.close()
alerts_socket.close()
context.term()

Output (when fed by two separate sender scripts):

Monitor listening on both sockets...
[METRIC]  cpu=42% mem=61%
[ALERT]   Disk usage above 80% on /dev/sda1
[METRIC]  cpu=38% mem=59%
[METRIC]  cpu=51% mem=63%
[ALERT]   High latency detected: 320ms

poller.poll(timeout=2000) blocks for up to 2000 milliseconds and returns a dict mapping socket to event flags. If the returned dict is empty, the timeout elapsed with no activity. This pattern lets one process efficiently monitor many message sources without spinning in a busy loop or spawning threads for each socket.

Real-Life Example: Distributed Log Aggregator

Let us build a realistic system: multiple application processes push log messages to a central aggregator using the PUSH/PULL pattern. The aggregator writes them to a file and prints a running count every 5 messages.

# log_aggregator.py -- run this first
import zmq
import datetime

context = zmq.Context()
receiver = context.socket(zmq.PULL)
receiver.bind("tcp://127.0.0.1:5561")

log_file = "aggregated.log"
count = 0

print(f"Log aggregator started. Writing to {log_file}")
with open(log_file, "a") as f:
    while True:
        try:
            entry = receiver.recv_json()
            timestamp = datetime.datetime.now().isoformat()
            line = f"[{timestamp}] [{entry['level']}] {entry['service']}: {entry['message']}\n"
            f.write(line)
            f.flush()
            count += 1
            if count % 5 == 0:
                print(f"Aggregated {count} log entries so far.")
        except KeyboardInterrupt:
            print(f"Shutting down. Total entries written: {count}")
            break

receiver.close()
context.term()
# log_sender.py -- run multiple instances of this
import zmq
import time
import random
import sys

service_name = sys.argv[1] if len(sys.argv) > 1 else "app"
levels = ["INFO", "WARNING", "ERROR"]
messages = [
    "Request processed in 45ms",
    "Cache miss on user profile",
    "Database connection pool at 80%",
    "Payment gateway timeout -- retrying",
    "Health check passed",
]

context = zmq.Context()
sender = context.socket(zmq.PUSH)
sender.connect("tcp://127.0.0.1:5561")

print(f"{service_name} sending logs...")
for _ in range(8):
    entry = {
        "service": service_name,
        "level": random.choice(levels),
        "message": random.choice(messages),
    }
    sender.send_json(entry)
    print(f"Sent: {entry['level']} -- {entry['message']}")
    time.sleep(random.uniform(0.2, 0.8))

sender.close()
context.term()

Aggregator output after two senders run:

Log aggregator started. Writing to aggregated.log
Aggregated 5 log entries so far.
Aggregated 10 log entries so far.
Aggregated 15 log entries so far.
^C
Shutting down. Total entries written: 16

This pattern scales horizontally: you can run 10 or 100 sender instances without changing the aggregator. ZeroMQ's PULL socket handles fair queuing automatically, so no single sender can flood the aggregator while others wait. You can extend this by adding a second PUSH/PULL stage that routes entries by log level to different downstream handlers, or by switching to PUB/SUB if you want multiple aggregators to each receive all entries.

ZeroMQ log aggregator PULL socket pattern
PULL socket: the inbox that never says 'I'll get to it later.'

Frequently Asked Questions

Does ZeroMQ need a broker like RabbitMQ?

No -- this is one of ZeroMQ's biggest selling points. ZeroMQ is a brokerless messaging library. The sockets themselves handle queuing, connection management, and message delivery without any separate server process. This makes deployment much simpler: you just install pyzmq, run your processes, and they talk to each other directly. The tradeoff is that there is no built-in message persistence or dead-letter queue -- if your consumer is down when a message is sent and the high-water mark is reached, messages can be dropped. If you need guaranteed delivery with persistence, consider adding a Redis or SQLite store alongside ZeroMQ.

What happens when a consumer is too slow?

ZeroMQ has a high-water mark (HWM) setting on each socket that limits the in-memory message queue. When the queue fills up (because a consumer is too slow), ZeroMQ's behavior depends on the socket type: PUSH sockets will block the sender until the consumer catches up, while PUB sockets will silently drop the oldest messages. You can tune the HWM with socket.set_hwm(1000) to control this. For production systems, monitor your queue depth and add more consumers before it approaches the HWM.

Are ZeroMQ sockets thread-safe?

No -- ZeroMQ sockets are not thread-safe. The rule is: one socket per thread, or use explicit locking. The zmq.Context() object is thread-safe and can be shared, but never pass a socket between threads without a lock. The idiomatic way to communicate between threads is to create a socket pair using the inproc:// transport, which avoids the network stack entirely and is extremely fast for same-process communication. You can also use zmq.devices.ThreadDevice to bridge sockets across threads safely.

Can I use pyzmq with asyncio?

Yes -- pyzmq has a dedicated zmq.asyncio module that provides async-compatible versions of the Context and Socket classes. You import it as import zmq.asyncio, create a context with zmq.asyncio.Context(), and then use await socket.recv_string() instead of socket.recv_string(). This integrates cleanly with asyncio.gather() and other async patterns, letting you handle ZeroMQ messages alongside HTTP requests, database calls, or other coroutines in a single event loop.

What are multipart messages and when do I use them?

ZeroMQ supports sending multiple message frames in one logical send operation using socket.send_multipart([frame1, frame2]) and receiving them with socket.recv_multipart(). This is used heavily by the DEALER/ROUTER pattern (a more advanced version of REQ/REP that adds routing envelopes) and is also useful when you want to send a message header separately from its body without serializing them into one structure. Each frame arrives atomically -- either all frames arrive or none do. For simple use cases, single-frame messages are simpler and sufficient.

Conclusion

We covered the three most important pyzmq patterns: request/reply (REQ/REP) for synchronous back-and-forth communication, push/pull (PUSH/PULL) for distributing work across multiple consumers, and publish/subscribe (PUB/SUB) for broadcasting events to many subscribers. We also saw how to send structured data with send_json()/recv_json(), how to monitor multiple sockets simultaneously with zmq.Poller, and how to build a real distributed log aggregator that scales horizontally without any broker.

The log aggregator example is a good starting point to extend. Try adding a second aggregation tier that routes ERROR entries to a separate file, or convert the PUSH/PULL senders to PUB sockets so you can tap into the log stream without modifying the senders. You could also swap the TCP transport for IPC (ipc:///tmp/logs.ipc) if all processes run on the same machine -- it is faster and avoids the network stack.

The full pyzmq documentation and a comprehensive guide to all ZeroMQ socket patterns are available at pyzmq.readthedocs.io and the excellent free book The ZeroMQ Guide (zguide.zeromq.org).

How To Use Python cloudpickle for Serializing Complex Objects

How To Use Python cloudpickle for Serializing Complex Objects

Intermediate

You write a clean lambda to filter records, wrap it in a multiprocessing pool, and Python throws a PicklingError: Can't pickle <function <lambda>>. It’s one of the most frustrating walls in parallel Python — your logic is right, but the serialization layer refuses to cooperate. The standard pickle module can serialize most objects, but it hard-codes a rule that functions must be importable by name. Lambdas, closures, and dynamically-created classes don’t have importable names, so pickle simply gives up.

cloudpickle solves this by serializing the actual bytecode and closure variables of a function, not just its module path. It’s the serialization engine behind Dask, Ray, PySpark, and joblib — any time Python ships a function to another process or machine, cloudpickle is usually doing the heavy lifting. Installing it takes one command (pip install cloudpickle), and the API is identical to pickle, so adopting it requires changing almost nothing in your code.

In this article we will cover how cloudpickle differs from standard pickle, how to serialize lambdas and closures, how to handle dynamically-created classes, and how to use cloudpickle as a drop-in upgrade for multiprocessing. We will finish with a real-life data transformation pipeline that uses cloudpickle to ship custom processors to a worker pool. By the end you will know exactly when to reach for cloudpickle and how to use it safely.

cloudpickle in Python: Quick Example

The fastest way to see cloudpickle in action is to serialize a lambda — something that trips up standard pickle immediately. The code below serializes a lambda to bytes, deserializes it back, and calls it, all in a handful of lines.

# quick_cloudpickle.py
import cloudpickle
import pickle

# This crashes with standard pickle:
# pickle.dumps(lambda x: x * 2)  -->  PicklingError

# cloudpickle handles it without complaint
double = lambda x: x * 2

serialized = cloudpickle.dumps(double)
print("Serialized bytes:", len(serialized), "bytes")

restored = cloudpickle.loads(serialized)
print("Result:", restored(21))

Output:

Serialized bytes: 62 bytes
Result: 42

The API is identical to pickledumps and loads work the same way, and you can swap the import and nothing else changes. The key difference is that cloudpickle.dumps serializes the function’s bytecode directly, while pickle.dumps tries to look up the function by its module and attribute name and fails when it can’t find one.

The sections below dig into why this matters, what else cloudpickle handles that pickle cannot, and how to integrate it into real parallel workflows.

Sudo Sam holding a glowing jar as a PicklingError wall crumbles behind him
pickle.dumps(lambda): AttributeError. cloudpickle.dumps(lambda): 62 bytes.

What Is cloudpickle and Why Use It?

cloudpickle is an open-source Python library, originally developed at PiCloud and now maintained by the Dask community, that extends Python’s built-in pickle protocol to handle objects that depend on runtime state rather than importable definitions. The standard pickle serializer works by recording the module path and attribute name of an object (e.g., mymodule.MyClass) and replays that lookup on the other side. This works for anything defined at the top level of an importable module — regular functions, classes, and most built-ins.

What breaks is anything whose identity depends on where and how it was created at runtime: a lambda defined in a script, a closure that captures a local variable, a class built with type() dynamically, or a function defined inside another function. For all of these, there is no importable address to record, so pickle raises an error. cloudpickle works around this by serializing the actual CPython bytecode objects (__code__), the closure cells (__closure__), and global references the function uses — essentially packaging the function itself, not just its address.

Object TypeStandard picklecloudpickle
Regular module-level functionYesYes
Lambda functionNoYes
Closure (captures local vars)NoYes
Nested / inner functionNoYes
Dynamically created class (type())NoYes
Class defined in __main__SometimesYes
Built-in types (int, list, dict)YesYes

The practical use case for cloudpickle is any time you need to move a callable from one Python process to another without sharing a common importable module. This covers multiprocessing pools, concurrent.futures process executors, distributed computing frameworks (Dask, Ray, PySpark), and job queues that serialize tasks for background workers.

Installing cloudpickle

cloudpickle is on PyPI and has no dependencies beyond the Python standard library.

# install_cloudpickle.sh
pip install cloudpickle

Output:

Successfully installed cloudpickle-3.1.1

Once installed, import it exactly like pickle. The module exposes dumps, loads, dump, and load with the same signatures, so most existing code only needs the import line changed.

Debug Dee watching binary rain fall into a pickle jar from a cloud
One pip install. No dependencies. Your lambda problem just became a solved problem.

Serializing Lambda Functions

Lambdas are the most common trigger for reaching for cloudpickle. They appear constantly in data pipelines — as sort keys, filter predicates, and map transforms — but the moment you try to move them to a worker process, standard pickle refuses. Here is the exact error you get with standard pickle and how cloudpickle resolves it:

# lambda_comparison.py
import pickle
import cloudpickle

square = lambda x: x ** 2

# Attempt 1: standard pickle (will raise)
try:
    data = pickle.dumps(square)
    print("pickle succeeded")
except AttributeError as e:
    print("pickle failed:", e)

# Attempt 2: cloudpickle (works)
data = cloudpickle.dumps(square)
restored = cloudpickle.loads(data)
print("cloudpickle succeeded, result:", restored(9))

Output:

pickle failed: Can't pickle <function <lambda> at 0x...>: attribute lookup <lambda> on __main__ failed
cloudpickle succeeded, result: 81

The failure message from pickle is telling: it’s trying to look up an attribute named <lambda> on the __main__ module and finding nothing there, because lambda names are not real attribute names. cloudpickle bypasses this entirely by capturing the bytecode. The serialized blob is self-contained — it carries everything needed to reconstruct and call the function on the other side.

Serializing Closures

A closure is a function that captures variables from its enclosing scope. They’re useful for building configurable callables without writing a full class, but they’re another category that standard pickle refuses to serialize. cloudpickle handles closures correctly, including capturing the current values of the enclosed variables:

# closure_serialization.py
import cloudpickle

def make_multiplier(factor):
    """Returns a closure that multiplies its argument by factor."""
    def multiplier(x):
        return x * factor
    return multiplier

triple = make_multiplier(3)
print("Before serialization:", triple(10))

# Serialize the closure -- factor=3 is captured inside
serialized = cloudpickle.dumps(triple)

# Simulate sending to another process by loading from bytes
restored = cloudpickle.loads(serialized)
print("After deserialization:", restored(10))
print("Same result?", triple(10) == restored(10))

Output:

Before serialization: 30
After deserialization: 30
Same result? True

The captured variable factor=3 travels inside the serialized bytes. When the closure is reconstructed on the other side, it has its own independent copy of factor — changes to the original variable in the sending process do not affect the deserialized closure. This snapshot-at-serialization behaviour is important to keep in mind: cloudpickle captures the state of closed-over variables at the moment dumps is called, not at the moment the closure is called.

Serializing Dynamically-Created Classes

When you create a class at runtime using the three-argument form of type(), or define a class inside a function, standard pickle cannot find it by name and fails. cloudpickle serializes the entire class definition — its name, bases, methods, and attributes — so it reconstructs cleanly on the receiving end:

# dynamic_class.py
import cloudpickle

# Build a class dynamically at runtime
def make_record_class(fields):
    """Factory that produces a lightweight record class for the given fields."""
    def __init__(self, *values):
        for name, value in zip(fields, values):
            setattr(self, name, value)

    def __repr__(self):
        parts = ", ".join(f"{f}={getattr(self, f)!r}" for f in fields)
        return f"Record({parts})"

    return type("Record", (), {"__init__": __init__, "__repr__": __repr__})

# Create a class with two fields -- this class has no importable name
SensorRecord = make_record_class(["sensor_id", "temperature"])

reading = SensorRecord("sensor_07", 22.4)
print("Original:", reading)

# Serialize the class itself (not just an instance)
serialized_class = cloudpickle.dumps(SensorRecord)
RestoredClass = cloudpickle.loads(serialized_class)

new_reading = RestoredClass("sensor_07", 22.4)
print("Restored:", new_reading)

Output:

Original: Record(sensor_id='sensor_07', temperature=22.4)
Restored: Record(sensor_id='sensor_07', temperature=22.4)

This pattern is common in frameworks that generate specialized data-handling classes from configuration or schema at runtime. cloudpickle is what allows those classes to travel to worker nodes without pre-compiling the framework on every machine.

Loop Larry juggling glowing blueprint sheets with a conveyor belt behind him
type(‘MyClass’, (), {}) — born in __main__, shipped to the worker pool. cloudpickle handles the handoff.

Using cloudpickle with multiprocessing

The most immediate practical use of cloudpickle is making multiprocessing work with callables that standard pickle rejects. The multiprocessing module uses pickle internally when sending tasks to worker processes, so any function you pass to Pool.map must be picklable. Swapping in cloudpickle for the serialization step removes this restriction.

The cleanest integration is via a helper that serializes the function with cloudpickle before handing it to the pool, and a wrapper on the worker side that deserializes it:

# cloudpickle_pool.py
import cloudpickle
import multiprocessing

def worker_dispatch(payload):
    """Deserialize and call a cloudpickle-serialized (func, args) pair."""
    func, args = cloudpickle.loads(payload)
    return func(*args)

def parallel_map(func, items, processes=4):
    """
    A drop-in replacement for Pool.map that supports lambdas and closures.
    Serializes `func` with cloudpickle, then distributes work across `processes`.
    """
    payloads = [cloudpickle.dumps((func, (item,))) for item in items]
    with multiprocessing.Pool(processes=processes) as pool:
        return pool.map(worker_dispatch, payloads)

if __name__ == "__main__":
    # These would all crash with a standard Pool.map call
    prices = [10.0, 25.5, 8.75, 100.0, 42.0]
    tax_rate = 0.1   # captured by closure below

    apply_tax = lambda p: round(p * (1 + tax_rate), 2)
    results = parallel_map(apply_tax, prices)
    print("Prices with tax:", results)

Output:

Prices with tax: [11.0, 28.05, 9.62, 110.0, 46.2]

The key pattern here is cloudpickle.dumps((func, (item,))) — we pack the function and its arguments together into a single bytes object, send that to the worker, and unpack it there with cloudpickle.loads. The worker_dispatch function itself is a plain module-level function that standard pickle can handle, so the pool setup works normally. Only the task payload uses cloudpickle.

Saving and Loading Functions to Disk

cloudpickle also works with file objects via dump and load, the same way standard pickle does. This lets you cache functions or trained model pipelines that include non-importable callables:

# save_load_function.py
import cloudpickle

def build_pipeline(threshold, label):
    """Returns a data-processing closure configured at build time."""
    def process(records):
        filtered = [r for r in records if r["score"] >= threshold]
        return [{"id": r["id"], "tag": label, "score": r["score"]} for r in filtered]
    return process

# Build a configured pipeline
high_value = build_pipeline(threshold=80, label="HIGH")

# Save to disk
with open("pipeline.pkl", "wb") as f:
    cloudpickle.dump(high_value, f)

print("Pipeline saved to pipeline.pkl")

# Load it back (simulates another script or session)
with open("pipeline.pkl", "rb") as f:
    loaded_pipeline = cloudpickle.load(f)

records = [
    {"id": 1, "score": 92},
    {"id": 2, "score": 65},
    {"id": 3, "score": 88},
]
print("Processed:", loaded_pipeline(records))

Output:

Pipeline saved to pipeline.pkl
Processed: [{'id': 1, 'tag': 'HIGH', 'score': 92}, {'id': 3, 'tag': 'HIGH', 'score': 88}]

One important caveat when saving to disk: the serialized file is tied to the Python version and cloudpickle version that created it. Loading a cloudpickle file on a different Python version may fail silently or raise an unpickling error. For long-term storage across environments, prefer protocol-agnostic formats like JSON or a dedicated model serialization format (ONNX, safetensors). cloudpickle shines for short-lived serialization within a deployment — process-to-process or session-to-session on the same machine.

API Alice standing in front of a server rack filled with glowing jars
Your pipeline.pkl from last Tuesday. Written on Python 3.11. Now running on 3.13. Good luck.

Real-Life Example: Parallel Data Cleaning Pipeline

This project builds a multi-stage data cleaning pipeline where each stage is defined as a lambda or closure configured at runtime. Using cloudpickle, all stages can be distributed to a worker pool without any refactoring into named module-level functions.

# parallel_cleaning_pipeline.py
import cloudpickle
import multiprocessing
import math

# --- Stage definitions (closures and lambdas -- not picklable with standard pickle) ---

def make_clamp(min_val, max_val):
    """Clamp numeric values to [min_val, max_val]."""
    return lambda x: max(min_val, min(max_val, x))

def make_log_transform(base):
    """Apply a log transform (guards against non-positive values)."""
    import math
    return lambda x: math.log(x, base) if x > 0 else 0.0

normalize = lambda x, lo, hi: (x - lo) / (hi - lo) if hi != lo else 0.0

# --- Worker infrastructure (module-level so standard pickle can reach it) ---

def run_stage(payload):
    func, value = cloudpickle.loads(payload)
    return func(value)

def apply_stage_to_batch(stage_func, batch):
    """Distribute one stage across a worker pool using cloudpickle."""
    payloads = [cloudpickle.dumps((stage_func, item)) for item in batch]
    with multiprocessing.Pool(processes=4) as pool:
        return pool.map(run_stage, payloads)

if __name__ == "__main__":
    # Raw sensor readings with noise and outliers
    raw = [0, -5, 12, 250, 78, 45, 301, 9, 63, 0.1]

    print("Raw data:      ", raw)

    # Stage 1: Clamp to [1, 200] (also lifts zeros to 1 for log transform)
    clamped = apply_stage_to_batch(make_clamp(1, 200), raw)
    print("After clamp:   ", clamped)

    # Stage 2: Log2 transform
    logged = apply_stage_to_batch(make_log_transform(2), clamped)
    logged = [round(v, 3) for v in logged]
    print("After log2:    ", logged)

    # Stage 3: Normalize to [0, 1]
    lo, hi = min(logged), max(logged)
    normed = apply_stage_to_batch(lambda x: normalize(x, lo, hi), logged)
    normed = [round(v, 3) for v in normed]
    print("Normalized:    ", normed)

Output:

Raw data:       [0, -5, 12, 250, 78, 45, 301, 9, 63, 0.1]
After clamp:    [1, 1, 12, 200, 78, 45, 200, 9, 63, 1]
After log2:     [0.0, 0.0, 3.585, 7.644, 6.285, 5.492, 7.644, 3.17, 5.977, 0.0]
Normalized:     [0.0, 0.0, 0.469, 1.0, 0.822, 0.718, 1.0, 0.414, 0.782, 0.0]

Every stage — the clamp factory, the log transform factory, and the inline normalize lambda — is a closure or lambda that standard pickle would reject. cloudpickle makes them all first-class distributable callables. To extend this pipeline, add new factory functions, configure them with different parameters, and pass them to apply_stage_to_batch without touching any of the worker infrastructure. Each stage is independently testable and composable.

Pyro Pete at a control panel with glowing conveyor belt lanes
lambda x: max(min_val, min(max_val, x)) — three worker processes, zero PicklingErrors.

Frequently Asked Questions

When should I use cloudpickle instead of standard pickle?

Use cloudpickle any time you need to serialize a lambda, closure, nested function, or dynamically-created class. For everything else — plain classes, module-level functions, built-in types — standard pickle works fine and is slightly faster because it records a reference rather than serializing bytecode. A good rule of thumb: start with pickle and switch to cloudpickle the moment you hit a PicklingError or AttributeError during serialization.

Is cloudpickle safe to use with untrusted data?

No, and neither is standard pickle. Both can execute arbitrary code during deserialization, so you should never load pickle data from an untrusted source. cloudpickle does not change the security model — it only extends what can be serialized, not what is safe to deserialize. For cross-service communication with untrusted input, use a type-safe format like JSON or Protocol Buffers instead.

How does cloudpickle relate to Dask and Ray?

Both Dask and Ray use cloudpickle as their default serializer for task functions. When you write dask.delayed(my_lambda)(args) or ray.remote(lambda: ...), the framework calls cloudpickle.dumps under the hood before sending the task to a worker. You rarely need to call cloudpickle directly when using these frameworks — but understanding that it’s there explains why closures and lambdas work in Dask/Ray but fail in raw multiprocessing without the extra wrapper.

Can I load a cloudpickle file on a different Python version?

Generally not reliably. cloudpickle serializes CPython bytecode objects, and the bytecode format changes between Python minor versions (3.11 vs 3.12 vs 3.13). A file pickled with Python 3.11 will usually fail to load on 3.13. cloudpickle is designed for in-session or same-environment communication — between processes on the same machine or within a single cluster where all nodes share the same Python version. For durable cross-version storage, serialize to a stable format and reconstruct the callable from data rather than serializing the callable itself.

Is cloudpickle slower than standard pickle?

Yes, slightly, because it does more work: inspecting the function’s bytecode, closure cells, and referenced globals. For most use cases the difference is negligible — serializing a function takes microseconds, and the cost is paid once per function, not per data item. If you’re serializing millions of tiny functions per second, benchmark both options. For the common pattern of serializing a handful of stage functions once and then distributing many data items, the overhead is invisible.

What happens if my closure captures a very large object?

The captured object gets serialized along with the closure, which means it travels in every copy sent to every worker. If you close over a 500MB dataset, each worker process receives its own 500MB copy. The fix is to pass large data separately — ship the function (small) and the data (sent once via shared memory or a distributed store) independently, and keep closures to capturing lightweight config values like thresholds, labels, and flags.

Conclusion

cloudpickle extends Python’s serialization layer to cover the objects that standard pickle refuses: lambdas, closures, nested functions, and dynamically-created classes. By serializing bytecode and closure state rather than module paths, it makes any callable first-class in a parallel or distributed pipeline. We covered the core API (dumps / loads / dump / load), compared cloudpickle against standard pickle across object types, walked through serializing lambdas, closures, and dynamic classes, and built a complete multi-stage parallel cleaning pipeline that chains all three.

The most valuable extension to the real-life example above is adding a registry of named stages so pipelines can be saved to disk as configurations (lists of stage names and parameters) and reconstructed on load without storing bytecode at all. This gives you the best of both worlds: the flexibility of closures during development and the durability of declarative configs in production.

For further reading, the official cloudpickle repository is at https://github.com/cloudpipe/cloudpickle, and the Python documentation for the pickle protocol is at https://docs.python.org/3/library/pickle.html. The Dask serialization documentation at https://distributed.dask.org/en/stable/serialization.html also covers how cloudpickle fits into a real distributed system.

How To Use Python cloudpickle for Serializing Complex Objects

How To Use Python cloudpickle for Serializing Complex Objects

Intermediate

You write a clean lambda to filter records, wrap it in a multiprocessing pool, and Python throws a PicklingError: Can't pickle <function <lambda>>. It’s one of the most frustrating walls in parallel Python — your logic is right, but the serialization layer refuses to cooperate. The standard pickle module can serialize most objects, but it hard-codes a rule that functions must be importable by name. Lambdas, closures, and dynamically-created classes don’t have importable names, so pickle simply gives up.

cloudpickle solves this by serializing the actual bytecode and closure variables of a function, not just its module path. It’s the serialization engine behind Dask, Ray, PySpark, and joblib — any time Python ships a function to another process or machine, cloudpickle is usually doing the heavy lifting. Installing it takes one command (pip install cloudpickle), and the API is identical to pickle, so adopting it requires changing almost nothing in your code.

In this article we will cover how cloudpickle differs from standard pickle, how to serialize lambdas and closures, how to handle dynamically-created classes, and how to use cloudpickle as a drop-in upgrade for multiprocessing. We will finish with a real-life data transformation pipeline that uses cloudpickle to ship custom processors to a worker pool. By the end you will know exactly when to reach for cloudpickle and how to use it safely.

cloudpickle in Python: Quick Example

The fastest way to see cloudpickle in action is to serialize a lambda — something that trips up standard pickle immediately. The code below serializes a lambda to bytes, deserializes it back, and calls it, all in a handful of lines.

# quick_cloudpickle.py
import cloudpickle
import pickle

# This crashes with standard pickle:
# pickle.dumps(lambda x: x * 2)  -->  PicklingError

# cloudpickle handles it without complaint
double = lambda x: x * 2

serialized = cloudpickle.dumps(double)
print("Serialized bytes:", len(serialized), "bytes")

restored = cloudpickle.loads(serialized)
print("Result:", restored(21))

Output:

Serialized bytes: 62 bytes
Result: 42

The API is identical to pickledumps and loads work the same way, and you can swap the import and nothing else changes. The key difference is that cloudpickle.dumps serializes the function’s bytecode directly, while pickle.dumps tries to look up the function by its module and attribute name and fails when it can’t find one.

The sections below dig into why this matters, what else cloudpickle handles that pickle cannot, and how to integrate it into real parallel workflows.

What Is cloudpickle and Why Use It?

cloudpickle is an open-source Python library, originally developed at PiCloud and now maintained by the Dask community, that extends Python’s built-in pickle protocol to handle objects that depend on runtime state rather than importable definitions. The standard pickle serializer works by recording the module path and attribute name of an object (e.g., mymodule.MyClass) and replays that lookup on the other side. This works for anything defined at the top level of an importable module — regular functions, classes, and most built-ins.

What breaks is anything whose identity depends on where and how it was created at runtime: a lambda defined in a script, a closure that captures a local variable, a class built with type() dynamically, or a function defined inside another function. For all of these, there is no importable address to record, so pickle raises an error. cloudpickle works around this by serializing the actual CPython bytecode objects (__code__), the closure cells (__closure__), and global references the function uses — essentially packaging the function itself, not just its address.

Object TypeStandard picklecloudpickle
Regular module-level functionYesYes
Lambda functionNoYes
Closure (captures local vars)NoYes
Nested / inner functionNoYes
Dynamically created class (type())NoYes
Class defined in __main__SometimesYes
Built-in types (int, list, dict)YesYes

The practical use case for cloudpickle is any time you need to move a callable from one Python process to another without sharing a common importable module. This covers multiprocessing pools, concurrent.futures process executors, distributed computing frameworks (Dask, Ray, PySpark), and job queues that serialize tasks for background workers.

Installing cloudpickle

cloudpickle is on PyPI and has no dependencies beyond the Python standard library.

# install_cloudpickle.sh
pip install cloudpickle

Output:

Successfully installed cloudpickle-3.1.1

Once installed, import it exactly like pickle. The module exposes dumps, loads, dump, and load with the same signatures, so most existing code only needs the import line changed.

Serializing Lambda Functions

Lambdas are the most common trigger for reaching for cloudpickle. They appear constantly in data pipelines — as sort keys, filter predicates, and map transforms — but the moment you try to move them to a worker process, standard pickle refuses. Here is the exact error you get with standard pickle and how cloudpickle resolves it:

# lambda_comparison.py
import pickle
import cloudpickle

square = lambda x: x ** 2

# Attempt 1: standard pickle (will raise)
try:
    data = pickle.dumps(square)
    print("pickle succeeded")
except AttributeError as e:
    print("pickle failed:", e)

# Attempt 2: cloudpickle (works)
data = cloudpickle.dumps(square)
restored = cloudpickle.loads(data)
print("cloudpickle succeeded, result:", restored(9))

Output:

pickle failed: Can't pickle <function <lambda> at 0x...>: attribute lookup <lambda> on __main__ failed
cloudpickle succeeded, result: 81

The failure message from pickle is telling: it’s trying to look up an attribute named <lambda> on the __main__ module and finding nothing there, because lambda names are not real attribute names. cloudpickle bypasses this entirely by capturing the bytecode. The serialized blob is self-contained — it carries everything needed to reconstruct and call the function on the other side.

Serializing Closures

A closure is a function that captures variables from its enclosing scope. They’re useful for building configurable callables without writing a full class, but they’re another category that standard pickle refuses to serialize. cloudpickle handles closures correctly, including capturing the current values of the enclosed variables:

# closure_serialization.py
import cloudpickle

def make_multiplier(factor):
    """Returns a closure that multiplies its argument by factor."""
    def multiplier(x):
        return x * factor
    return multiplier

triple = make_multiplier(3)
print("Before serialization:", triple(10))

# Serialize the closure -- factor=3 is captured inside
serialized = cloudpickle.dumps(triple)

# Simulate sending to another process by loading from bytes
restored = cloudpickle.loads(serialized)
print("After deserialization:", restored(10))
print("Same result?", triple(10) == restored(10))

Output:

Before serialization: 30
After deserialization: 30
Same result? True

The captured variable factor=3 travels inside the serialized bytes. When the closure is reconstructed on the other side, it has its own independent copy of factor — changes to the original variable in the sending process do not affect the deserialized closure. This snapshot-at-serialization behaviour is important to keep in mind: cloudpickle captures the state of closed-over variables at the moment dumps is called, not at the moment the closure is called.

Serializing Dynamically-Created Classes

When you create a class at runtime using the three-argument form of type(), or define a class inside a function, standard pickle cannot find it by name and fails. cloudpickle serializes the entire class definition — its name, bases, methods, and attributes — so it reconstructs cleanly on the receiving end:

# dynamic_class.py
import cloudpickle

# Build a class dynamically at runtime
def make_record_class(fields):
    """Factory that produces a lightweight record class for the given fields."""
    def __init__(self, *values):
        for name, value in zip(fields, values):
            setattr(self, name, value)

    def __repr__(self):
        parts = ", ".join(f"{f}={getattr(self, f)!r}" for f in fields)
        return f"Record({parts})"

    return type("Record", (), {"__init__": __init__, "__repr__": __repr__})

# Create a class with two fields -- this class has no importable name
SensorRecord = make_record_class(["sensor_id", "temperature"])

reading = SensorRecord("sensor_07", 22.4)
print("Original:", reading)

# Serialize the class itself (not just an instance)
serialized_class = cloudpickle.dumps(SensorRecord)
RestoredClass = cloudpickle.loads(serialized_class)

new_reading = RestoredClass("sensor_07", 22.4)
print("Restored:", new_reading)

Output:

Original: Record(sensor_id='sensor_07', temperature=22.4)
Restored: Record(sensor_id='sensor_07', temperature=22.4)

This pattern is common in frameworks that generate specialized data-handling classes from configuration or schema at runtime. cloudpickle is what allows those classes to travel to worker nodes without pre-compiling the framework on every machine.

Using cloudpickle with multiprocessing

The most immediate practical use of cloudpickle is making multiprocessing work with callables that standard pickle rejects. The multiprocessing module uses pickle internally when sending tasks to worker processes, so any function you pass to Pool.map must be picklable. Swapping in cloudpickle for the serialization step removes this restriction.

The cleanest integration is via a helper that serializes the function with cloudpickle before handing it to the pool, and a wrapper on the worker side that deserializes it:

# cloudpickle_pool.py
import cloudpickle
import multiprocessing

def worker_dispatch(payload):
    """Deserialize and call a cloudpickle-serialized (func, args) pair."""
    func, args = cloudpickle.loads(payload)
    return func(*args)

def parallel_map(func, items, processes=4):
    """
    A drop-in replacement for Pool.map that supports lambdas and closures.
    Serializes `func` with cloudpickle, then distributes work across `processes`.
    """
    payloads = [cloudpickle.dumps((func, (item,))) for item in items]
    with multiprocessing.Pool(processes=processes) as pool:
        return pool.map(worker_dispatch, payloads)

if __name__ == "__main__":
    # These would all crash with a standard Pool.map call
    prices = [10.0, 25.5, 8.75, 100.0, 42.0]
    tax_rate = 0.1   # captured by closure below

    apply_tax = lambda p: round(p * (1 + tax_rate), 2)
    results = parallel_map(apply_tax, prices)
    print("Prices with tax:", results)

Output:

Prices with tax: [11.0, 28.05, 9.62, 110.0, 46.2]

The key pattern here is cloudpickle.dumps((func, (item,))) — we pack the function and its arguments together into a single bytes object, send that to the worker, and unpack it there with cloudpickle.loads. The worker_dispatch function itself is a plain module-level function that standard pickle can handle, so the pool setup works normally. Only the task payload uses cloudpickle.

Saving and Loading Functions to Disk

cloudpickle also works with file objects via dump and load, the same way standard pickle does. This lets you cache functions or trained model pipelines that include non-importable callables:

# save_load_function.py
import cloudpickle

def build_pipeline(threshold, label):
    """Returns a data-processing closure configured at build time."""
    def process(records):
        filtered = [r for r in records if r["score"] >= threshold]
        return [{"id": r["id"], "tag": label, "score": r["score"]} for r in filtered]
    return process

# Build a configured pipeline
high_value = build_pipeline(threshold=80, label="HIGH")

# Save to disk
with open("pipeline.pkl", "wb") as f:
    cloudpickle.dump(high_value, f)

print("Pipeline saved to pipeline.pkl")

# Load it back (simulates another script or session)
with open("pipeline.pkl", "rb") as f:
    loaded_pipeline = cloudpickle.load(f)

records = [
    {"id": 1, "score": 92},
    {"id": 2, "score": 65},
    {"id": 3, "score": 88},
]
print("Processed:", loaded_pipeline(records))

Output:

Pipeline saved to pipeline.pkl
Processed: [{'id': 1, 'tag': 'HIGH', 'score': 92}, {'id': 3, 'tag': 'HIGH', 'score': 88}]

One important caveat when saving to disk: the serialized file is tied to the Python version and cloudpickle version that created it. Loading a cloudpickle file on a different Python version may fail silently or raise an unpickling error. For long-term storage across environments, prefer protocol-agnostic formats like JSON or a dedicated model serialization format (ONNX, safetensors). cloudpickle shines for short-lived serialization within a deployment — process-to-process or session-to-session on the same machine.

Real-Life Example: Parallel Data Cleaning Pipeline

This project builds a multi-stage data cleaning pipeline where each stage is defined as a lambda or closure configured at runtime. Using cloudpickle, all stages can be distributed to a worker pool without any refactoring into named module-level functions.

# parallel_cleaning_pipeline.py
import cloudpickle
import multiprocessing
import math

# --- Stage definitions (closures and lambdas -- not picklable with standard pickle) ---

def make_clamp(min_val, max_val):
    """Clamp numeric values to [min_val, max_val]."""
    return lambda x: max(min_val, min(max_val, x))

def make_log_transform(base):
    """Apply a log transform (guards against non-positive values)."""
    import math
    return lambda x: math.log(x, base) if x > 0 else 0.0

normalize = lambda x, lo, hi: (x - lo) / (hi - lo) if hi != lo else 0.0

# --- Worker infrastructure (module-level so standard pickle can reach it) ---

def run_stage(payload):
    func, value = cloudpickle.loads(payload)
    return func(value)

def apply_stage_to_batch(stage_func, batch):
    """Distribute one stage across a worker pool using cloudpickle."""
    payloads = [cloudpickle.dumps((stage_func, item)) for item in batch]
    with multiprocessing.Pool(processes=4) as pool:
        return pool.map(run_stage, payloads)

if __name__ == "__main__":
    # Raw sensor readings with noise and outliers
    raw = [0, -5, 12, 250, 78, 45, 301, 9, 63, 0.1]

    print("Raw data:      ", raw)

    # Stage 1: Clamp to [1, 200] (also lifts zeros to 1 for log transform)
    clamped = apply_stage_to_batch(make_clamp(1, 200), raw)
    print("After clamp:   ", clamped)

    # Stage 2: Log2 transform
    logged = apply_stage_to_batch(make_log_transform(2), clamped)
    logged = [round(v, 3) for v in logged]
    print("After log2:    ", logged)

    # Stage 3: Normalize to [0, 1]
    lo, hi = min(logged), max(logged)
    normed = apply_stage_to_batch(lambda x: normalize(x, lo, hi), logged)
    normed = [round(v, 3) for v in normed]
    print("Normalized:    ", normed)

Output:

Raw data:       [0, -5, 12, 250, 78, 45, 301, 9, 63, 0.1]
After clamp:    [1, 1, 12, 200, 78, 45, 200, 9, 63, 1]
After log2:     [0.0, 0.0, 3.585, 7.644, 6.285, 5.492, 7.644, 3.17, 5.977, 0.0]
Normalized:     [0.0, 0.0, 0.469, 1.0, 0.822, 0.718, 1.0, 0.414, 0.782, 0.0]

Every stage — the clamp factory, the log transform factory, and the inline normalize lambda — is a closure or lambda that standard pickle would reject. cloudpickle makes them all first-class distributable callables. To extend this pipeline, add new factory functions, configure them with different parameters, and pass them to apply_stage_to_batch without touching any of the worker infrastructure. Each stage is independently testable and composable.

Frequently Asked Questions

When should I use cloudpickle instead of standard pickle?

Use cloudpickle any time you need to serialize a lambda, closure, nested function, or dynamically-created class. For everything else — plain classes, module-level functions, built-in types — standard pickle works fine and is slightly faster because it records a reference rather than serializing bytecode. A good rule of thumb: start with pickle and switch to cloudpickle the moment you hit a PicklingError or AttributeError during serialization.

Is cloudpickle safe to use with untrusted data?

No, and neither is standard pickle. Both can execute arbitrary code during deserialization, so you should never load pickle data from an untrusted source. cloudpickle does not change the security model — it only extends what can be serialized, not what is safe to deserialize. For cross-service communication with untrusted input, use a type-safe format like JSON or Protocol Buffers instead.

How does cloudpickle relate to Dask and Ray?

Both Dask and Ray use cloudpickle as their default serializer for task functions. When you write dask.delayed(my_lambda)(args) or ray.remote(lambda: ...), the framework calls cloudpickle.dumps under the hood before sending the task to a worker. You rarely need to call cloudpickle directly when using these frameworks — but understanding that it’s there explains why closures and lambdas work in Dask/Ray but fail in raw multiprocessing without the extra wrapper.

Can I load a cloudpickle file on a different Python version?

Generally not reliably. cloudpickle serializes CPython bytecode objects, and the bytecode format changes between Python minor versions (3.11 vs 3.12 vs 3.13). A file pickled with Python 3.11 will usually fail to load on 3.13. cloudpickle is designed for in-session or same-environment communication — between processes on the same machine or within a single cluster where all nodes share the same Python version. For durable cross-version storage, serialize to a stable format and reconstruct the callable from data rather than serializing the callable itself.

Is cloudpickle slower than standard pickle?

Yes, slightly, because it does more work: inspecting the function’s bytecode, closure cells, and referenced globals. For most use cases the difference is negligible — serializing a function takes microseconds, and the cost is paid once per function, not per data item. If you’re serializing millions of tiny functions per second, benchmark both options. For the common pattern of serializing a handful of stage functions once and then distributing many data items, the overhead is invisible.

What happens if my closure captures a very large object?

The captured object gets serialized along with the closure, which means it travels in every copy sent to every worker. If you close over a 500MB dataset, each worker process receives its own 500MB copy. The fix is to pass large data separately — ship the function (small) and the data (sent once via shared memory or a distributed store) independently, and keep closures to capturing lightweight config values like thresholds, labels, and flags.

Conclusion

cloudpickle extends Python’s serialization layer to cover the objects that standard pickle refuses: lambdas, closures, nested functions, and dynamically-created classes. By serializing bytecode and closure state rather than module paths, it makes any callable first-class in a parallel or distributed pipeline. We covered the core API (dumps / loads / dump / load), compared cloudpickle against standard pickle across object types, walked through serializing lambdas, closures, and dynamic classes, and built a complete multi-stage parallel cleaning pipeline that chains all three.

The most valuable extension to the real-life example above is adding a registry of named stages so pipelines can be saved to disk as configurations (lists of stage names and parameters) and reconstructed on load without storing bytecode at all. This gives you the best of both worlds: the flexibility of closures during development and the durability of declarative configs in production.

For further reading, the official cloudpickle repository is at https://github.com/cloudpipe/cloudpickle, and the Python documentation for the pickle protocol is at https://docs.python.org/3/library/pickle.html. The Dask serialization documentation at https://distributed.dask.org/en/stable/serialization.html also covers how cloudpickle fits into a real distributed system.

How To Use Python Prefect for Data Pipeline Orchestration

How To Use Python Prefect for Data Pipeline Orchestration

Intermediate

You’ve got a data pipeline that works perfectly — until it doesn’t. The API returns a 503 at 3am, your CSV has a malformed row on line 4,712, and the entire pipeline crashes silently. By morning you have stale data, confused dashboards, and no clue where things went wrong. Stitching together retries, logging, and scheduling with raw Python is possible, but it quickly becomes a second job on top of your actual job.

Prefect is a workflow orchestration framework for Python that handles all of this for you. You decorate your existing functions with @task and @flow, and suddenly you get automatic retries, structured logging, result caching, scheduling, and a real-time UI — without rewriting your business logic. Prefect 3.x (the current stable release) is pip-installable and works entirely locally; you don’t need a managed cloud service unless you want one.

In this article we will cover installing Prefect, decorating functions into tasks and flows, adding retries and caching, inspecting runs in the Prefect UI, scheduling automated runs, and building a full ETL pipeline as a real-life project. By the end you will be able to turn any ad-hoc Python script into a monitored, fault-tolerant pipeline in under an hour.

Prefect in Python: Quick Example

The fastest way to see Prefect in action is to build a tiny pipeline with two tasks chained inside a flow. The example below fetches a post from a public API and prints its title — with automatic logging at every step.

# quick_pipeline.py
import requests
from prefect import flow, task

@task
def fetch_post(post_id: int) -> dict:
    url = f"https://jsonplaceholder.typicode.com/posts/{post_id}"
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

@task
def print_title(post: dict) -> None:
    print(f"Post title: {post['title']}")

@flow(name="fetch-post-flow")
def run_pipeline(post_id: int = 1) -> None:
    post = fetch_post(post_id)
    print_title(post)

if __name__ == "__main__":
    run_pipeline(post_id=3)

Output:

13:42:01.123 | INFO    | prefect.engine - Created flow run 'cerulean-wolf' for flow 'fetch-post-flow'
13:42:01.201 | INFO    | Task run 'fetch_post-0' - Finished in state Completed()
Post title: ea molestias quasi exercitationem repellat qui ipsa sit aut
13:42:01.340 | INFO    | Task run 'print_title-0' - Finished in state Completed()
13:42:01.341 | INFO    | Flow run 'cerulean-wolf' - Finished in state Completed()

Each @task decorator wraps a regular function and gives it Prefect’s observability: state tracking, structured logs, and retry capability. The @flow decorator marks the entry point that orchestrates those tasks. Prefect generates a unique run name (like cerulean-wolf) automatically, which you can look up in the UI later.

The rest of this article digs into what happens when tasks fail, how to cache expensive results so they don’t rerun needlessly, and how to schedule the whole pipeline to run on a cron-style interval.

What Is Prefect and Why Use It?

Prefect is a Python-native workflow orchestration tool. At its core it answers one question: “When a step in my pipeline fails, what should happen?” The naive answer is “crash and let me deal with it.” Prefect’s answer is “retry it, log exactly why it failed, keep track of what succeeded, and notify me if it ultimately can’t recover.”

Think of Prefect like a project manager for your code. You define the tasks (individual units of work) and the flow (the order and dependencies between them). Prefect handles the coordination: running tasks in order, passing results between them, catching failures, and reporting status. You still write all the business logic in plain Python — Prefect just wraps it with supervision.

FeatureRaw Python ScriptPrefect Flow
Automatic retriesManual try/except loopsBuilt-in: retries=3
Structured loggingprint() or custom loggerAuto-captured per task run
Run historyNone (or log files)Searchable UI with state history
Result cachingManual file/pickle logicBuilt-in: cache_key_fn
Schedulingcron + bare scriptsNative deployments + schedules
Parallelismthreading/multiprocessingTask runners (concurrent, Dask)

Prefect is not the only orchestration option — Airflow and Luigi are popular alternatives. The key difference is the developer experience: Prefect works with plain Python functions, needs zero XML or DAG configuration files, and starts a local server in one command. Airflow is a better fit for teams that already have it deployed and need its plugin ecosystem; Prefect is faster to get off the ground for Python-first workflows.

Installing Prefect

Prefect 3.x installs from PyPI like any other package. Using a virtual environment keeps your orchestration dependencies separate from your project’s other libraries.

# install_prefect.sh
python -m venv prefect-env
source prefect-env/bin/activate   # Windows: prefect-env\Scripts\activate
pip install prefect requests

Output:

Successfully installed prefect-3.x.x ...

Verify the install by checking the version:

# verify_prefect.py
import prefect
print(prefect.__version__)

Output:

3.4.1

To launch the local Prefect server (which gives you the monitoring UI), open a second terminal and run:

# in a separate terminal
prefect server start

The server starts on http://127.0.0.1:4200 by default. Keep it running while you execute flows so run history gets captured. You don’t need the server to run flows — they work locally without it — but it is well worth having for debugging.

Python Prefect pipeline nodes diagram showing connected tasks and data flow
pip install prefect. Your pipeline just got a nervous system.

Creating Tasks and Flows

The building blocks of every Prefect pipeline are tasks and flows. A task is a single unit of work — typically one function that does one thing. A flow is the container that calls those tasks in the right order and passes results between them.

Tasks and flows are created by decorating regular Python functions. The decorators accept configuration arguments that control behavior without changing the underlying logic.

# tasks_and_flows.py
from prefect import flow, task
import requests

@task(name="fetch-users", log_prints=True)
def fetch_users(limit: int) -> list:
    """Fetch user records from a public API."""
    url = "https://jsonplaceholder.typicode.com/users"
    response = requests.get(url, params={"_limit": limit}, timeout=10)
    response.raise_for_status()
    users = response.json()
    print(f"Fetched {len(users)} users")
    return users

@task(name="extract-emails", log_prints=True)
def extract_emails(users: list) -> list:
    """Pull email addresses from a list of user records."""
    emails = [u["email"] for u in users if "email" in u]
    print(f"Extracted {len(emails)} email addresses")
    return emails

@flow(name="user-email-pipeline", log_prints=True)
def user_email_pipeline(limit: int = 5) -> list:
    users = fetch_users(limit)
    emails = extract_emails(users)
    return emails

if __name__ == "__main__":
    result = user_email_pipeline(limit=3)
    print("Result:", result)

Output:

Fetched 3 users
Extracted 3 email addresses
Result: ['Sincere@april.biz', 'Shanna@melissa.tv', 'Nathan@yesenia.net']

The log_prints=True argument tells Prefect to capture every print() call inside that task or flow and route it through Prefect’s logger so it appears in the UI’s run history. Without it, print() still works but the output is not linked to the run record.

Notice that extract_emails receives the return value of fetch_users directly. Prefect passes results between tasks as regular Python objects — there is no serialization ceremony or special data store required for basic in-memory use.

Adding Retries and Error Handling

The most valuable feature for production pipelines is retry logic. Real-world APIs return 503s, database connections time out, and file systems occasionally misbehave. Rather than wrapping every task in try/except loops, you declare the retry policy at decoration time.

# retries_example.py
import requests
import random
from prefect import flow, task

@task(
    name="unreliable-api-call",
    retries=3,
    retry_delay_seconds=2,
    log_prints=True
)
def fetch_with_retries(post_id: int) -> dict:
    """Simulate an unreliable API call that randomly fails."""
    # Simulate 50% failure rate for demonstration
    if random.random() < 0.5:
        raise requests.exceptions.ConnectionError("Simulated network error")
    url = f"https://jsonplaceholder.typicode.com/posts/{post_id}"
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

@flow(name="retry-demo-flow")
def retry_demo() -> None:
    post = fetch_with_retries(1)
    print(f"Retrieved: {post['title'][:50]}")

if __name__ == "__main__":
    retry_demo()

Output (when a retry occurs):

13:52:01.101 | INFO    | Task run 'unreliable-api-call-0' - Retrying in 2.0 seconds...
13:52:03.205 | INFO    | Task run 'unreliable-api-call-0' - Retrying in 2.0 seconds...
13:52:05.311 | INFO    | Task run 'unreliable-api-call-0' - Finished in state Completed()
Retrieved: sunt aut facere repellat provident occaecati

With retries=3 and retry_delay_seconds=2, Prefect automatically attempts the task up to three more times with a two-second pause between each try. If all retries are exhausted, the task transitions to a Failed state, the flow marks the run as failed, and the failure is recorded in the UI with the full traceback — no extra code required.

For exponential backoff (doubling the wait time on each retry, which is gentler on flaky APIs), use a list:

# exponential_backoff.py
from prefect import task

@task(retries=4, retry_delay_seconds=[1, 2, 4, 8])
def fetch_with_backoff(url: str) -> dict:
    import requests
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

The retry delays list maps directly to each retry attempt: first retry waits 1 second, second waits 2, third waits 4, fourth waits 8. This pattern prevents thundering-herd situations where every retrying client hammers a recovering service simultaneously.

Developer monitoring Prefect task retry status board with green checkmarks
Task failed. Retried. Retried again. Succeeded. You slept through all of it.

Caching Task Results

Some tasks are expensive to run — calling a paid API, running a slow database query, or processing a large file. If those tasks produce the same output for the same input, re-running them on every pipeline execution is wasteful. Prefect’s result caching lets you skip redundant work automatically.

# caching_example.py
import requests
from datetime import timedelta
from prefect import flow, task
from prefect.tasks import task_input_hash

@task(
    name="cached-api-fetch",
    cache_key_fn=task_input_hash,
    cache_expiration=timedelta(minutes=10),
    log_prints=True
)
def fetch_post_cached(post_id: int) -> dict:
    """Fetch a post -- result is cached per post_id for 10 minutes."""
    print(f"Making real API call for post {post_id}...")
    url = f"https://jsonplaceholder.typicode.com/posts/{post_id}"
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

@flow(name="caching-demo")
def caching_demo() -> None:
    # First call -- makes the real API request
    post = fetch_post_cached(1)
    print(f"First run: {post['title'][:40]}")

    # Second call with same argument -- returns cached result
    post_again = fetch_post_cached(1)
    print(f"Second run (cached): {post_again['title'][:40]}")

if __name__ == "__main__":
    caching_demo()

Output:

Making real API call for post 1...
First run: sunt aut facere repellat provident occae
13:55:12.001 | INFO    | Task run 'cached-api-fetch-1' - Finished in state Cached(type=COMPLETED)
Second run (cached): sunt aut facere repellat provident occae

The cache_key_fn=task_input_hash argument tells Prefect to generate a cache key from the task’s inputs (in this case post_id). When the same task runs with the same arguments within the cache expiration window, Prefect returns the stored result immediately — notice the Cached(type=COMPLETED) state in the output, and the absence of the “Making real API call” print statement on the second run.

Set cache_expiration based on how frequently the underlying data actually changes. For API data that refreshes hourly, timedelta(hours=1) is appropriate. For reference data that rarely changes, timedelta(days=1) keeps things responsive without hammering the source.

Structured Logging with Prefect

Prefect captures structured log output per task run, making it easy to trace exactly what each step did without sifting through a monolithic log file. The preferred approach is to use Prefect’s built-in logger via get_run_logger().

# logging_example.py
import requests
from prefect import flow, task, get_run_logger

@task(name="fetch-comments")
def fetch_comments(post_id: int) -> list:
    logger = get_run_logger()
    logger.info(f"Fetching comments for post {post_id}")
    url = f"https://jsonplaceholder.typicode.com/posts/{post_id}/comments"
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        comments = response.json()
        logger.info(f"Retrieved {len(comments)} comments")
        return comments
    except requests.exceptions.RequestException as err:
        logger.error(f"Failed to fetch comments: {err}")
        raise

@task(name="count-emails")
def count_unique_emails(comments: list) -> int:
    logger = get_run_logger()
    emails = {c["email"] for c in comments if c.get("email")}
    logger.info(f"Found {len(emails)} unique commenter emails")
    return len(emails)

@flow(name="comment-analysis")
def comment_analysis(post_id: int = 1) -> int:
    comments = fetch_comments(post_id)
    count = count_unique_emails(comments)
    return count

if __name__ == "__main__":
    total = comment_analysis(post_id=1)
    print(f"Unique commenters: {total}")

Output:

14:01:03.021 | INFO    | Task run 'fetch-comments-0' - Fetching comments for post 1
14:01:03.412 | INFO    | Task run 'fetch-comments-0' - Retrieved 5 comments
14:01:03.413 | INFO    | Task run 'count-emails-0' - Found 5 unique commenter emails
14:01:03.414 | INFO    | Flow run 'amber-falcon' - Finished in state Completed()
Unique commenters: 5

get_run_logger() returns a standard Python logger that Prefect automatically associates with the current task or flow run. Every message is timestamped, tagged with the run name, and visible in the Prefect UI’s log panel for that specific run. Use logger.info() for routine progress, logger.warning() for unexpected-but-handled situations, and logger.error() before re-raising exceptions so the failure has context in the UI.

Developer reviewing structured Prefect log output in terminal with timestamps
grep is not a monitoring strategy. get_run_logger() is.

Scheduling Flows with Deployments

Running a flow on demand with python my_flow.py is useful for development. In production you typically want it to run on a schedule — every hour, once a day, or on a cron expression. Prefect handles this through deployments.

A deployment is a named configuration that links a flow to a schedule and an execution environment. The simplest way to create one is to call .serve() on your flow, which registers the deployment and starts a worker that listens for scheduled runs.

# scheduled_flow.py
import requests
from prefect import flow, task

@task(retries=2, retry_delay_seconds=5)
def fetch_latest_post() -> dict:
    """Fetch the most recently created post."""
    response = requests.get(
        "https://jsonplaceholder.typicode.com/posts",
        params={"_sort": "id", "_order": "desc", "_limit": 1},
        timeout=10
    )
    response.raise_for_status()
    return response.json()[0]

@flow(name="hourly-post-check", log_prints=True)
def hourly_post_check() -> None:
    post = fetch_latest_post()
    print(f"Latest post ID {post['id']}: {post['title'][:50]}")

if __name__ == "__main__":
    # Run on an interval (every 60 seconds for demo purposes)
    from prefect.schedules import IntervalSchedule
    from datetime import timedelta

    hourly_post_check.serve(
        name="hourly-post-deployment",
        interval=timedelta(seconds=60)  # Change to timedelta(hours=1) in production
    )

Output (runs every 60 seconds while the script is active):

Your deployment 'hourly-post-deployment' is running!
Press Ctrl+C to stop.
14:10:00.001 | INFO    | Flow run 'crimson-lynx' - Latest post ID 100: at nam consequatur ea labore ea harum
14:11:00.023 | INFO    | Flow run 'lilac-crane' - Latest post ID 100: at nam consequatur ea labore ea harum

The .serve() method blocks, keeps the script alive, and triggers the flow on the configured schedule. For cron-based schedules — such as running at 6am every weekday — use cron="0 6 * * 1-5" instead of interval. The Prefect UI at http://127.0.0.1:4200 shows upcoming scheduled runs, past run history, and any failures that need attention.

Prefect scheduled deployment running automatically on a cron interval
cron + bare script = hoping nobody changes the server. Prefect deployment = knowing it will run.

Real-Life Example: ETL Pipeline for User Data

Let’s put everything together into a practical ETL (Extract, Transform, Load) pipeline. This flow fetches user records from a public API, transforms them into a clean format, and writes the results to a local SQLite database — a pattern you can adapt for real data sources and destinations.

# etl_pipeline.py
import sqlite3
import requests
from datetime import timedelta
from prefect import flow, task, get_run_logger
from prefect.tasks import task_input_hash

# -------------------------------------------------------
# EXTRACT
# -------------------------------------------------------
@task(
    name="extract-users",
    retries=3,
    retry_delay_seconds=[2, 4, 8],
    cache_key_fn=task_input_hash,
    cache_expiration=timedelta(hours=1)
)
def extract_users(limit: int) -> list:
    logger = get_run_logger()
    logger.info(f"Extracting {limit} users from API")
    response = requests.get(
        "https://jsonplaceholder.typicode.com/users",
        params={"_limit": limit},
        timeout=15
    )
    response.raise_for_status()
    users = response.json()
    logger.info(f"Extracted {len(users)} user records")
    return users

# -------------------------------------------------------
# TRANSFORM
# -------------------------------------------------------
@task(name="transform-users")
def transform_users(users: list) -> list:
    logger = get_run_logger()
    cleaned = []
    for user in users:
        name_parts = user.get("name", "").split(" ", 1)
        first = name_parts[0] if name_parts else "Unknown"
        last = name_parts[1] if len(name_parts) > 1 else ""
        city = user.get("address", {}).get("city", "Unknown")
        cleaned.append({
            "user_id": user["id"],
            "first_name": first,
            "last_name": last,
            "email": user.get("email", "").lower().strip(),
            "city": city,
            "company": user.get("company", {}).get("name", "Unknown")
        })
    logger.info(f"Transformed {len(cleaned)} records")
    return cleaned

# -------------------------------------------------------
# LOAD
# -------------------------------------------------------
@task(name="load-to-sqlite")
def load_to_sqlite(records: list, db_path: str) -> int:
    logger = get_run_logger()
    conn = sqlite3.connect(db_path)
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS users (
            user_id INTEGER PRIMARY KEY,
            first_name TEXT,
            last_name TEXT,
            email TEXT UNIQUE,
            city TEXT,
            company TEXT
        )
    """)
    inserted = 0
    for rec in records:
        try:
            cur.execute("""
                INSERT OR REPLACE INTO users
                (user_id, first_name, last_name, email, city, company)
                VALUES (:user_id, :first_name, :last_name, :email, :city, :company)
            """, rec)
            inserted += 1
        except sqlite3.Error as err:
            logger.warning(f"Skipping record {rec['user_id']}: {err}")
    conn.commit()
    conn.close()
    logger.info(f"Loaded {inserted} records into {db_path}")
    return inserted

# -------------------------------------------------------
# FLOW
# -------------------------------------------------------
@flow(name="user-etl-pipeline", log_prints=True)
def user_etl_pipeline(
    limit: int = 10,
    db_path: str = "users.db"
) -> dict:
    raw_users = extract_users(limit)
    clean_users = transform_users(raw_users)
    count = load_to_sqlite(clean_users, db_path)
    summary = {"extracted": len(raw_users), "loaded": count, "db": db_path}
    print(f"Pipeline complete: {summary}")
    return summary

if __name__ == "__main__":
    result = user_etl_pipeline(limit=5, db_path="users.db")

Output:

14:20:01.101 | INFO    | Task run 'extract-users-0' - Extracting 5 users from API
14:20:01.512 | INFO    | Task run 'extract-users-0' - Extracted 5 user records
14:20:01.513 | INFO    | Task run 'transform-users-0' - Transformed 5 records
14:20:01.515 | INFO    | Task run 'load-to-sqlite-0' - Loaded 5 records into users.db
Pipeline complete: {'extracted': 5, 'loaded': 5, 'db': 'users.db'}

This pipeline demonstrates all the key Prefect patterns in one place: extraction with retries and caching (so re-running the pipeline within an hour skips the API call), defensive transformation (handling missing keys with .get() defaults), structured loading with per-record error handling, and a flow-level summary printed at the end. To extend it, replace the jsonplaceholder URL with your real data source, add a notification task at the end (email or Slack), and wrap it in a .serve() call to run it nightly.

Python ETL pipeline with Extract Transform Load stages running via Prefect
Extract. Transform. Load. Sleep. Repeat. Prefect handles the last three.

Frequently Asked Questions

When should I use Prefect instead of Airflow?

Choose Prefect when you want to orchestrate Python workflows quickly without setting up a dedicated server or writing DAG configuration files. Prefect flows are plain Python functions decorated with @flow and @task, so the learning curve is minimal if you already know Python. Airflow is a better fit when your organization already runs it as a shared platform, or when you need its broad ecosystem of pre-built operators for Hadoop, Spark, and similar tools.

Can I run Prefect flows without starting the server?

Yes. Flows run locally without the server — just call your flow function like a normal Python function. The server at http://127.0.0.1:4200 is optional; it adds run history, log browsing, and scheduling visibility, but flows are fully functional without it. Start with local runs during development, then add the server when you want to monitor scheduled production runs.

How do I run tasks in parallel?

Submit multiple tasks concurrently using a ConcurrentTaskRunner or by calling tasks with .submit() inside a with block. By default, Prefect tasks run sequentially in the order they are called. For CPU-bound work, use a DaskTaskRunner from the prefect-dask package; for I/O-bound work, ConcurrentTaskRunner (the default when using .submit()) is usually enough and requires no extra dependencies.

Where does Prefect store cached results?

By default, Prefect stores task results in a local file-system cache in a folder called .prefect in the project directory. You can configure this to use cloud storage (AWS S3, GCS, Azure Blob) by setting a result storage block in the Prefect server settings. For most local pipelines the default storage works well; switch to cloud storage when multiple machines need to share results or when cached data should survive a machine restart.

How do I get notified when a flow fails?

Add an on_failure hook to your flow: @flow(on_failure=[send_alert]), where send_alert is a regular Python function that receives the flow run context. Inside that function you can call any notification library — send an email with smtplib, post to Slack via a webhook, or trigger a PagerDuty alert. The prefect-slack and prefect-email integrations in the Prefect extras provide ready-made notification blocks.

How do I pass secrets (API keys, passwords) to flows?

Never hardcode secrets in your flow code. For local development, use environment variables accessed via os.environ["API_KEY"]. For production Prefect deployments, use Prefect Blocks — encrypted secret stores that are created in the UI and referenced in code with Secret.load("my-api-key").get(). Blocks are stored encrypted in the Prefect backend and are never logged or exposed in run output.

Conclusion

Prefect transforms raw Python scripts into supervised, fault-tolerant pipelines with minimal changes to your existing code. In this article we covered the core building blocks: decorating functions with @task and @flow, configuring retries with exponential backoff, caching expensive task results with task_input_hash, capturing structured per-task logs with get_run_logger(), and scheduling automated runs with .serve(). The real-life ETL example showed how all these features combine into a production-ready pipeline.

A good next step is to extend the ETL pipeline: add a notification task at the end, swap jsonplaceholder.typicode.com for your real data source, and point the load step at a PostgreSQL or Snowflake target. Once that is working locally, wrap the flow in a .serve() call with a cron schedule and let it run unattended. The Prefect UI will show you every run, every log line, and every retry — turning what used to be a fragile cron job into something you can actually trust.

The official Prefect documentation at docs.prefect.io covers advanced features including deployments on Prefect Cloud, work pools for distributed execution, and integrations with AWS, GCP, and dbt. The concepts from this article transfer directly to those guides.

How To Use Python celery-beat for Periodic Task Scheduling

How To Use Python celery-beat for Periodic Task Scheduling

Intermediate

You have a Python function that needs to run every hour. Maybe it fetches data from an API, sends a digest email, or clears out expired records in your database. Right now you are probably doing one of two things: running it manually whenever you remember, or wrestling with a cron job that works on your server but silently fails when your app restarts. There is a better way — one that lives inside your Python app, restarts with it, and gives you visibility into whether tasks actually ran.

That better way is celery-beat, the periodic task scheduler built on top of Celery. If you have already used Celery for background jobs, adding beat is straightforward: you define schedules in Python code, start the beat process alongside your worker, and never think about cron syntax again. If Celery is new to you, this article walks you through everything from installation to a working scheduler. You will need Python 3.8+, Redis (the simplest broker to set up locally), and about 15 minutes.

This article covers installation and configuration, defining tasks with @shared_task, setting schedules with crontab and timedelta, running the beat and worker processes, dynamic schedules stored in a database, a real-life example that ties it all together, and common pitfalls to avoid. By the end you will have a working periodic task pipeline you can adapt for any automation need.

celery-beat Quick Example

Here is the minimal setup that prints a timestamp every 30 seconds. We will go deep on each piece afterward — this is just to give you a win fast.

# celery_app.py
from celery import Celery
from celery.schedules import timedelta

app = Celery("quickstart", broker="redis://localhost:6379/0")

app.conf.beat_schedule = {
    "print-every-30s": {
        "task": "celery_app.print_timestamp",
        "schedule": timedelta(seconds=30),
    },
}

@app.task
def print_timestamp():
    from datetime import datetime
    print(f"[beat] tick at {datetime.now().isoformat()}")

Start Redis, then open two terminals:

# Terminal 1 -- the worker that runs tasks
celery -A celery_app worker --loglevel=info

# Terminal 2 -- the beat scheduler that sends tasks to the worker
celery -A celery_app beat --loglevel=info

Output (in the worker terminal, every 30 seconds):

[beat] tick at 2026-07-10T08:00:00.123456
[beat] tick at 2026-07-10T08:00:30.124512
[beat] tick at 2026-07-10T08:01:00.125103

The two key things here: beat_schedule is a dict where each entry names a scheduled job, and the beat process is separate from the worker. Beat is a clock — it sends tasks to the queue. The worker receives them and executes them. This separation is intentional and matters when you scale.

The beat process sends tasks; the worker executes them
Two processes. One sends. One runs. This is not a bug.

What Is celery-beat and Why Use It?

Celery is a distributed task queue — you push work onto a queue (backed by Redis or RabbitMQ) and worker processes pick it up and execute it asynchronously. Celery is for “do this now, in the background.” celery-beat adds “do this on a schedule.” Think of beat as the cron daemon for your Celery workers: it wakes up at the right time, publishes a task message to the queue, and the worker picks it up exactly as if you had triggered it manually.

Why choose celery-beat over cron?

Featurecroncelery-beat
Schedule defined incrontab file (server-specific)Python code (version-controlled)
Restarts with appNo (server-level)Yes (process alongside worker)
Dynamic schedulesNoYes (via database-backed scheduler)
Task result trackingNoYes (via Celery result backend)
Retry on failureNoYes (Celery retry mechanism)
Works across multiple serversRequires coordinationYes (one beat + many workers)

The trade-off is setup cost: you need a broker (Redis or RabbitMQ) running. For single-server scripts that rarely change, cron is perfectly fine. For anything you are deploying as an application — especially if it already uses Celery for async tasks — celery-beat is the obvious choice.

Installation and Setup

Install Celery with the Redis transport. The [redis] extra pulls in the redis Python client:

# install_celery.sh -- run in your terminal
pip install "celery[redis]"

Output:

Successfully installed celery-5.4.0 redis-5.0.4 kombu-5.3.7 ...

Start Redis locally. If you have Docker:

# start_redis.sh
docker run -d -p 6379:6379 redis:7-alpine

Verify Redis is reachable:

# check_redis.py
import redis

r = redis.Redis(host="localhost", port=6379, db=0)
print(r.ping())   # True if Redis is up

Output:

True

Good. Redis is now ready to act as the Celery broker — the message bus that carries tasks from beat to worker.

Configuring Celery for a Real Project

In a real project you want your Celery configuration in a dedicated file, not scattered across scripts. The conventional layout uses a celery.py file at the same level as your app package. Here is a production-style config:

# myproject/celery.py
import os
from celery import Celery
from celery.schedules import crontab, timedelta

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")

app = Celery(
    "myproject",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",  # stores task results
    include=["myproject.tasks"],          # modules to scan for tasks
)

app.conf.update(
    task_serializer="json",
    result_serializer="json",
    accept_content=["json"],
    timezone="Australia/Melbourne",
    enable_utc=True,
    beat_schedule_filename="celerybeat-schedule",
)

app.conf.beat_schedule = {
    # Run every 5 minutes
    "cleanup-temp-files": {
        "task": "myproject.tasks.cleanup_temp_files",
        "schedule": timedelta(minutes=5),
        "args": ("/tmp/uploads",),
    },
    # Run at 7:00 AM Melbourne time, Monday through Friday
    "send-morning-digest": {
        "task": "myproject.tasks.send_digest_email",
        "schedule": crontab(hour=7, minute=0, day_of_week="mon-fri"),
    },
    # Run on the 1st of every month at midnight
    "monthly-report": {
        "task": "myproject.tasks.generate_monthly_report",
        "schedule": crontab(day_of_month=1, hour=0, minute=0),
    },
}

Output (no output — this is a configuration module imported by Celery):

(module loaded successfully when imported by Celery CLI)

Notice beat_schedule_filename: beat writes a small file to track when each task last ran. This lets it survive restarts without re-firing tasks that already ran. Delete this file if you want beat to reset its schedule state.

Two Celery processes must run simultaneously
Two processes. Run both or run neither. The queue does not care about your confusion.

Defining Periodic Tasks

Tasks are plain Python functions decorated with @app.task or @shared_task. Use @shared_task when your tasks live in a reusable app and should not import the Celery instance directly — this avoids circular import issues in larger projects:

# myproject/tasks.py
import os
import glob
from celery import shared_task
from datetime import datetime

@shared_task(name="myproject.tasks.cleanup_temp_files")
def cleanup_temp_files(directory):
    now = datetime.now().timestamp()
    removed = 0
    pattern = os.path.join(directory, "*")
    for path in glob.glob(pattern):
        try:
            age_seconds = now - os.path.getmtime(path)
            if age_seconds > 3600:  # older than 1 hour
                os.remove(path)
                removed += 1
        except (OSError, PermissionError) as e:
            print(f"Skipping {path}: {e}")
    print(f"[cleanup] Removed {removed} old files from {directory}")
    return removed

@shared_task(name="myproject.tasks.send_digest_email")
def send_digest_email():
    print(f"[digest] Sending morning digest at {datetime.now():%Y-%m-%d %H:%M}")
    # In production: connect to SMTP or use SendGrid/SES here
    return "sent"

@shared_task(name="myproject.tasks.generate_monthly_report")
def generate_monthly_report():
    month = datetime.now().strftime("%B %Y")
    print(f"[report] Generating report for {month}")
    return f"report-{month}.pdf"

Output (tasks run when triggered by beat):

[cleanup] Removed 3 old files from /tmp/uploads
[digest] Sending morning digest at 2026-07-10 07:00
[report] Generating report for July 2026

The name parameter in @shared_task is important: it must exactly match the string you put in beat_schedule. If they do not match, beat will silently queue tasks that never get picked up because no worker recognizes the task name.

Schedule Types: timedelta vs crontab

celery-beat offers two ways to express schedules. Here is when to use each:

# schedule_examples.py
from celery.schedules import crontab, timedelta

# timedelta -- interval-based ("every N units")
every_10_seconds = timedelta(seconds=10)
every_2_hours    = timedelta(hours=2)
every_3_days     = timedelta(days=3)

# crontab -- calendar-based ("at specific times")
every_minute     = crontab()                              # * * * * *
every_hour       = crontab(minute=0)                     # 0 * * * *
weekdays_9am     = crontab(hour=9, minute=0,
                            day_of_week="mon-fri")        # 0 9 * * 1-5
first_of_month   = crontab(day_of_month=1,
                            hour=0, minute=0)             # 0 0 1 * *

print("timedelta example:", every_2_hours)
print("crontab example:", weekdays_9am)

Output:

timedelta example: 2:00:00
crontab example: <crontab: 0 9 * * 1-5 (m/h/dM/MY/d)>

Use timedelta when you care about frequency (“run every hour”) and crontab when you care about calendar time (“run at 9 AM on weekdays”). A gotcha: timedelta(hours=1) fires every 60 minutes from when beat last sent the task — it drifts slightly. crontab(minute=0) fires on the hour, every hour, regardless of drift.

timedelta vs crontab schedule comparison
timedelta drifts. crontab does not. Choose based on whether your boss reads timestamps.

Running Beat and Worker

You need two processes running simultaneously. The worker handles task execution; beat handles scheduling. A common mistake is trying to run them together — celery-beat supports an embedded mode (-B flag) for development convenience, but it is not safe for production because a crashed worker also kills your scheduler.

Development (embedded beat, one terminal):

# dev_start.sh -- convenient for development, NOT for production
celery -A myproject.celery worker --beat --loglevel=info

Production (two separate processes):

# prod_worker.sh -- run in Terminal 1
celery -A myproject.celery worker --loglevel=info --concurrency=4

# prod_beat.sh -- run in Terminal 2
celery -A myproject.celery beat --loglevel=info --scheduler celery.beat.PersistentScheduler

Output (beat terminal):

celery beat v5.4.0 is starting.
LocalTime -> 2026-07-10 08:00:00
Configuration ->
    . broker -> redis://localhost:6379/0
    . scheduler -> celery.beat.PersistentScheduler
    . db -> celerybeat-schedule
    . maxinterval -> 5.00 minutes (300s)
beat: Starting...

The maxinterval (5 minutes by default) is how often beat wakes up to check if any task is due. If you have tasks scheduled every 10 seconds, lower this: --max-interval=10. Otherwise beat might sleep for 5 minutes and miss triggers.

Dynamic Schedules with django-celery-beat

So far our schedule is defined in Python code — change it and you have to restart beat. For schedules that need to change at runtime (user-defined reminders, configurable intervals), use django-celery-beat, which stores the schedule in your database and checks for changes dynamically.

# Install the extension
# pip install django-celery-beat

# settings.py -- add to INSTALLED_APPS
INSTALLED_APPS = [
    "django_celery_beat",  # add this line
    # ... your other apps
]

# Run migrations to create the schedule tables
# python manage.py migrate
# create_schedule.py -- run from Django shell or management command
from django_celery_beat.models import PeriodicTask, CrontabSchedule
import json

# Create a crontab schedule: every day at 3:00 AM
schedule, _ = CrontabSchedule.objects.get_or_create(
    minute="0",
    hour="3",
    day_of_week="*",
    day_of_month="*",
    month_of_year="*",
)

# Create the periodic task record
task, created = PeriodicTask.objects.get_or_create(
    name="nightly-data-sync",
    defaults={
        "task": "myproject.tasks.sync_data",
        "crontab": schedule,
        "args": json.dumps([]),
        "kwargs": json.dumps({"source": "production_db"}),
        "enabled": True,
    },
)
print(f"Task {'created' if created else 'already exists'}: {task.name}")
print(f"Schedule: {task.crontab}")

Output:

Task created: nightly-data-sync
Schedule: <CrontabSchedule: 0 3 * * * (m/h/dM/MY/d)>

Update the celery.py to use the database scheduler:

# myproject/celery.py -- update the scheduler setting
app.conf.update(
    beat_scheduler="django_celery_beat.schedulers:DatabaseScheduler",
)

With the DatabaseScheduler, beat polls the database every few seconds and picks up schedule changes without a restart. Django admin shows a clean UI for your periodic tasks out of the box — no code deploy needed to add or disable a scheduled job.

django-celery-beat database-backed dynamic schedules
Database-backed schedules: change intervals at runtime without restarting beat.

Real-Life Example: Automated API Price Monitor

This example polls a public API every minute, logs the latest data, and sends a simple alert if a value crosses a threshold. It uses jsonplaceholder.typicode.com as the data source so you can run it right now without any API keys. Swap in a real finance or monitoring API endpoint for production use.

# monitor/tasks.py
import requests
from celery import shared_task
from datetime import datetime

# Simple in-memory alert tracker; use Redis or a DB in production
_alert_sent = {}

@shared_task(name="monitor.tasks.check_metric",
             bind=True,
             max_retries=3,
             default_retry_delay=10)
def check_metric(self, record_id, threshold):
    try:
        response = requests.get(
            f"https://jsonplaceholder.typicode.com/posts/{record_id}",
            timeout=5
        )
        response.raise_for_status()
        data = response.json()
        # Simulate a metric from the post ID field
        metric_value = data.get("id", 0) * 2.75
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] Record #{record_id}: value={metric_value:.2f}")
        # Fire alert once if value crosses threshold
        if metric_value > threshold and not _alert_sent.get(record_id):
            print(f"  ALERT: {metric_value:.2f} crossed threshold {threshold:.2f}")
            _alert_sent[record_id] = True
        elif metric_value <= threshold:
            _alert_sent[record_id] = False  # reset when value drops
        return {"id": record_id, "value": metric_value, "ts": timestamp}
    except requests.RequestException as exc:
        print(f"[check_metric] Request failed: {exc}")
        raise self.retry(exc=exc)
# monitor/celery.py
from celery import Celery
from celery.schedules import timedelta

app = Celery("monitor", broker="redis://localhost:6379/0")
app.conf.include = ["monitor.tasks"]

app.conf.beat_schedule = {
    "check-record-42-every-minute": {
        "task": "monitor.tasks.check_metric",
        "schedule": timedelta(minutes=1),
        "args": (42, 50.0),   # record_id=42, threshold=50.0
        "options": {"expires": 55},  # drop if worker down >55s
    },
    "check-record-7-every-5min": {
        "task": "monitor.tasks.check_metric",
        "schedule": timedelta(minutes=5),
        "args": (7, 25.0),
    },
}

Output (worker terminal, running for 3 minutes):

[2026-07-10 08:00:01] Record #42: value=115.50
  ALERT: 115.50 crossed threshold 50.00
[2026-07-10 08:01:01] Record #42: value=115.50
[2026-07-10 08:05:01] Record #7: value=19.25
[2026-07-10 08:05:01] Record #42: value=115.50

Key patterns worth keeping from this example: bind=True gives the task access to self for retries; max_retries=3 with default_retry_delay=10 automatically retries on network failure; options: {expires: 55} prevents a pile-up of stale tasks if your worker goes offline. Extend this by writing values to a PostgreSQL table, sending real alerts via Twilio or SendGrid, or plotting a rolling chart with matplotlib.

Celery periodic task alert monitor in action
max_retries=3. Because the API will go down. Your boss will not care why.

Frequently Asked Questions

Can I run multiple beat instances?

No -- run exactly one beat process per Celery application. Running two beat instances causes duplicate tasks: both instances send the same task at the same time and your workers execute it twice. If you need high availability, use django-celery-beat with a database-backed scheduler and a distributed lock (such as celery-redlock) to ensure only one beat fires at a time. For most applications, a single beat process managed by supervisord or systemd is sufficient and reliable.

What happens if beat was offline and missed a scheduled run?

celery-beat does not backfill missed runs by default. If beat was offline for 2 hours and you have an hourly task, those 2 missed runs are skipped -- beat resumes from the current time when it restarts. If you need catch-up behavior (the task must run even if it was missed), implement it inside the task itself: check the last successful run timestamp from your database and re-run for each missed interval. The beat_schedule_filename pickle file tracks last-run times but does not trigger catch-up execution automatically.

How do I prevent a task from running again before the previous run finishes?

celery-beat does not know when a task finishes -- it only knows when to send it. Use a distributed lock to prevent overlap. The simplest approach is celery-singleton, which raises Ignore() if the same task is already running. Alternatively, use Redis directly: if not redis_client.set("lock:my-task", 1, nx=True, ex=300): raise Ignore(). The ex=300 sets a 5-minute TTL so the lock auto-expires if the task crashes without releasing it.

My crontab task fires at the wrong time. What is wrong?

Set timezone and enable_utc=True explicitly in your Celery config. By default Celery uses UTC; if your crontab(hour=9) is meant for 9 AM local time, it will fire at 9 AM UTC instead. Set app.conf.timezone = "Australia/Melbourne" (or your local timezone) and Celery will interpret crontab times in that timezone. Verify with celery -A myapp inspect scheduled -- it shows the next scheduled run time in UTC so you can sanity-check the conversion.

My periodic task fails occasionally. How do I add retries?

Decorate with bind=True and call self.retry(exc=exc) inside the exception handler, as shown in the real-life example above. Set max_retries and default_retry_delay on the decorator. For exponential backoff, pass countdown=2 ** self.request.retries to self.retry(): this waits 1s, then 2s, then 4s between retries. Be careful: if a 1-minute task fails and retries 3 times with increasing delays, the final retry could land after the next scheduled run has already started.

How do I monitor which tasks ran and whether they succeeded?

Set a result backend (backend="redis://localhost:6379/1") and Celery stores task results keyed by task ID. Use celery -A myapp events for a live event stream, or run celery -A myapp flower (install flower separately) for a web dashboard that shows task history, run times, and failure rates. For production, integrate with your monitoring stack: Celery can emit metrics to Datadog, Prometheus (via celery-prometheus-exporter), or any StatsD-compatible collector.

Conclusion

celery-beat turns Celery from a "do this now" system into a "do this now and also every night at 3 AM" system. The core concepts are straightforward: configure a broker, define tasks with @shared_task, declare a beat_schedule using timedelta or crontab, and run the beat and worker processes separately. For schedules that need to change without a deployment, django-celery-beat's database scheduler gives you admin-editable periodic tasks with zero code changes.

Extend the API monitor from this article by wiring in a real data source (the yfinance library is a free option for stock prices), storing readings in a PostgreSQL table via SQLAlchemy, and sending real alerts via SMTP or a service like SendGrid. Once that is running you will have a complete scheduled data pipeline -- periodic fetch, persist, alert -- all managed by celery-beat with automatic retries and failure visibility.

For more detail on Celery configuration and advanced task options, see the official celery-beat documentation and the Celery task guide.

How To Use Python celery-beat for Periodic Task Scheduling

How To Use Python celery-beat for Periodic Task Scheduling

Intermediate

You have a Python function that needs to run every hour. Maybe it fetches data from an API, sends a digest email, or clears out expired records in your database. Right now you are probably doing one of two things: running it manually whenever you remember, or wrestling with a cron job that works on your server but silently fails when your app restarts. There is a better way — one that lives inside your Python app, restarts with it, and gives you visibility into whether tasks actually ran.

That better way is celery-beat, the periodic task scheduler built on top of Celery. If you have already used Celery for background jobs, adding beat is straightforward: you define schedules in Python code, start the beat process alongside your worker, and never think about cron syntax again. If Celery is new to you, this article walks you through everything from installation to a working scheduler. You will need Python 3.8+, Redis (the simplest broker to set up locally), and about 15 minutes.

This article covers installation and configuration, defining tasks with @shared_task, setting schedules with crontab and timedelta, running the beat and worker processes, dynamic schedules stored in a database, a real-life example that ties it all together, and common pitfalls to avoid. By the end you will have a working periodic task pipeline you can adapt for any automation need.

celery-beat Quick Example

Here is the minimal setup that prints a timestamp every 30 seconds. We will go deep on each piece afterward — this is just to give you a win fast.

# celery_app.py
from celery import Celery
from celery.schedules import timedelta

app = Celery("quickstart", broker="redis://localhost:6379/0")

app.conf.beat_schedule = {
    "print-every-30s": {
        "task": "celery_app.print_timestamp",
        "schedule": timedelta(seconds=30),
    },
}

@app.task
def print_timestamp():
    from datetime import datetime
    print(f"[beat] tick at {datetime.now().isoformat()}")

Start Redis, then open two terminals:

# Terminal 1 -- the worker that runs tasks
celery -A celery_app worker --loglevel=info

# Terminal 2 -- the beat scheduler that sends tasks to the worker
celery -A celery_app beat --loglevel=info

Output (in the worker terminal, every 30 seconds):

[beat] tick at 2026-07-10T08:00:00.123456
[beat] tick at 2026-07-10T08:00:30.124512
[beat] tick at 2026-07-10T08:01:00.125103

The two key things here: beat_schedule is a dict where each entry names a scheduled job, and the beat process is separate from the worker. Beat is a clock — it sends tasks to the queue. The worker receives them and executes them. This separation is intentional and matters when you scale.

What Is celery-beat and Why Use It?

Celery is a distributed task queue — you push work onto a queue (backed by Redis or RabbitMQ) and worker processes pick it up and execute it asynchronously. Celery is for “do this now, in the background.” celery-beat adds “do this on a schedule.” Think of beat as the cron daemon for your Celery workers: it wakes up at the right time, publishes a task message to the queue, and the worker picks it up exactly as if you had triggered it manually.

Why choose celery-beat over cron?

Featurecroncelery-beat
Schedule defined incrontab file (server-specific)Python code (version-controlled)
Restarts with appNo (server-level)Yes (process alongside worker)
Dynamic schedulesNoYes (via database-backed scheduler)
Task result trackingNoYes (via Celery result backend)
Retry on failureNoYes (Celery retry mechanism)
Works across multiple serversRequires coordinationYes (one beat + many workers)

The trade-off is setup cost: you need a broker (Redis or RabbitMQ) running. For single-server scripts that rarely change, cron is perfectly fine. For anything you are deploying as an application — especially if it already uses Celery for async tasks — celery-beat is the obvious choice.

Installation and Setup

Install Celery with the Redis transport. The [redis] extra pulls in the redis Python client:

# install_celery.sh -- run in your terminal
pip install "celery[redis]"

Output:

Successfully installed celery-5.4.0 redis-5.0.4 kombu-5.3.7 ...

Start Redis locally. If you have Docker:

# start_redis.sh
docker run -d -p 6379:6379 redis:7-alpine

Verify Redis is reachable:

# check_redis.py
import redis

r = redis.Redis(host="localhost", port=6379, db=0)
print(r.ping())   # True if Redis is up

Output:

True

Good. Redis is now ready to act as the Celery broker — the message bus that carries tasks from beat to worker.

Configuring Celery for a Real Project

In a real project you want your Celery configuration in a dedicated file, not scattered across scripts. The conventional layout uses a celery.py file at the same level as your app package. Here is a production-style config:

# myproject/celery.py
import os
from celery import Celery
from celery.schedules import crontab, timedelta

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")

app = Celery(
    "myproject",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",  # stores task results
    include=["myproject.tasks"],          # modules to scan for tasks
)

app.conf.update(
    task_serializer="json",
    result_serializer="json",
    accept_content=["json"],
    timezone="Australia/Melbourne",
    enable_utc=True,
    beat_schedule_filename="celerybeat-schedule",
)

app.conf.beat_schedule = {
    # Run every 5 minutes
    "cleanup-temp-files": {
        "task": "myproject.tasks.cleanup_temp_files",
        "schedule": timedelta(minutes=5),
        "args": ("/tmp/uploads",),
    },
    # Run at 7:00 AM Melbourne time, Monday through Friday
    "send-morning-digest": {
        "task": "myproject.tasks.send_digest_email",
        "schedule": crontab(hour=7, minute=0, day_of_week="mon-fri"),
    },
    # Run on the 1st of every month at midnight
    "monthly-report": {
        "task": "myproject.tasks.generate_monthly_report",
        "schedule": crontab(day_of_month=1, hour=0, minute=0),
    },
}

Output (no output — this is a configuration module imported by Celery):

(module loaded successfully when imported by Celery CLI)

Notice beat_schedule_filename: beat writes a small file to track when each task last ran. This lets it survive restarts without re-firing tasks that already ran. Delete this file if you want beat to reset its schedule state.

Defining Periodic Tasks

Tasks are plain Python functions decorated with @app.task or @shared_task. Use @shared_task when your tasks live in a reusable app and should not import the Celery instance directly — this avoids circular import issues in larger projects:

# myproject/tasks.py
import os
import glob
from celery import shared_task
from datetime import datetime

@shared_task(name="myproject.tasks.cleanup_temp_files")
def cleanup_temp_files(directory):
    now = datetime.now().timestamp()
    removed = 0
    pattern = os.path.join(directory, "*")
    for path in glob.glob(pattern):
        try:
            age_seconds = now - os.path.getmtime(path)
            if age_seconds > 3600:  # older than 1 hour
                os.remove(path)
                removed += 1
        except (OSError, PermissionError) as e:
            print(f"Skipping {path}: {e}")
    print(f"[cleanup] Removed {removed} old files from {directory}")
    return removed

@shared_task(name="myproject.tasks.send_digest_email")
def send_digest_email():
    print(f"[digest] Sending morning digest at {datetime.now():%Y-%m-%d %H:%M}")
    # In production: connect to SMTP or use SendGrid/SES here
    return "sent"

@shared_task(name="myproject.tasks.generate_monthly_report")
def generate_monthly_report():
    month = datetime.now().strftime("%B %Y")
    print(f"[report] Generating report for {month}")
    return f"report-{month}.pdf"

Output (tasks run when triggered by beat):

[cleanup] Removed 3 old files from /tmp/uploads
[digest] Sending morning digest at 2026-07-10 07:00
[report] Generating report for July 2026

The name parameter in @shared_task is important: it must exactly match the string you put in beat_schedule. If they do not match, beat will silently queue tasks that never get picked up because no worker recognizes the task name.

Schedule Types: timedelta vs crontab

celery-beat offers two ways to express schedules. Here is when to use each:

# schedule_examples.py
from celery.schedules import crontab, timedelta

# timedelta -- interval-based ("every N units")
every_10_seconds = timedelta(seconds=10)
every_2_hours    = timedelta(hours=2)
every_3_days     = timedelta(days=3)

# crontab -- calendar-based ("at specific times")
every_minute     = crontab()                              # * * * * *
every_hour       = crontab(minute=0)                     # 0 * * * *
weekdays_9am     = crontab(hour=9, minute=0,
                            day_of_week="mon-fri")        # 0 9 * * 1-5
first_of_month   = crontab(day_of_month=1,
                            hour=0, minute=0)             # 0 0 1 * *

print("timedelta example:", every_2_hours)
print("crontab example:", weekdays_9am)

Output:

timedelta example: 2:00:00
crontab example: <crontab: 0 9 * * 1-5 (m/h/dM/MY/d)>

Use timedelta when you care about frequency (“run every hour”) and crontab when you care about calendar time (“run at 9 AM on weekdays”). A gotcha: timedelta(hours=1) fires every 60 minutes from when beat last sent the task — it drifts slightly. crontab(minute=0) fires on the hour, every hour, regardless of drift.

Running Beat and Worker

You need two processes running simultaneously. The worker handles task execution; beat handles scheduling. A common mistake is trying to run them together — celery-beat supports an embedded mode (-B flag) for development convenience, but it is not safe for production because a crashed worker also kills your scheduler.

Development (embedded beat, one terminal):

# dev_start.sh -- convenient for development, NOT for production
celery -A myproject.celery worker --beat --loglevel=info

Production (two separate processes):

# prod_worker.sh -- run in Terminal 1
celery -A myproject.celery worker --loglevel=info --concurrency=4

# prod_beat.sh -- run in Terminal 2
celery -A myproject.celery beat --loglevel=info --scheduler celery.beat.PersistentScheduler

Output (beat terminal):

celery beat v5.4.0 is starting.
LocalTime -> 2026-07-10 08:00:00
Configuration ->
    . broker -> redis://localhost:6379/0
    . scheduler -> celery.beat.PersistentScheduler
    . db -> celerybeat-schedule
    . maxinterval -> 5.00 minutes (300s)
beat: Starting...

The maxinterval (5 minutes by default) is how often beat wakes up to check if any task is due. If you have tasks scheduled every 10 seconds, lower this: --max-interval=10. Otherwise beat might sleep for 5 minutes and miss triggers.

Dynamic Schedules with django-celery-beat

So far our schedule is defined in Python code — change it and you have to restart beat. For schedules that need to change at runtime (user-defined reminders, configurable intervals), use django-celery-beat, which stores the schedule in your database and checks for changes dynamically.

# Install the extension
# pip install django-celery-beat

# settings.py -- add to INSTALLED_APPS
INSTALLED_APPS = [
    "django_celery_beat",  # add this line
    # ... your other apps
]

# Run migrations to create the schedule tables
# python manage.py migrate
# create_schedule.py -- run from Django shell or management command
from django_celery_beat.models import PeriodicTask, CrontabSchedule
import json

# Create a crontab schedule: every day at 3:00 AM
schedule, _ = CrontabSchedule.objects.get_or_create(
    minute="0",
    hour="3",
    day_of_week="*",
    day_of_month="*",
    month_of_year="*",
)

# Create the periodic task record
task, created = PeriodicTask.objects.get_or_create(
    name="nightly-data-sync",
    defaults={
        "task": "myproject.tasks.sync_data",
        "crontab": schedule,
        "args": json.dumps([]),
        "kwargs": json.dumps({"source": "production_db"}),
        "enabled": True,
    },
)
print(f"Task {'created' if created else 'already exists'}: {task.name}")
print(f"Schedule: {task.crontab}")

Output:

Task created: nightly-data-sync
Schedule: <CrontabSchedule: 0 3 * * * (m/h/dM/MY/d)>

Update the celery.py to use the database scheduler:

# myproject/celery.py -- update the scheduler setting
app.conf.update(
    beat_scheduler="django_celery_beat.schedulers:DatabaseScheduler",
)

With the DatabaseScheduler, beat polls the database every few seconds and picks up schedule changes without a restart. Django admin shows a clean UI for your periodic tasks out of the box — no code deploy needed to add or disable a scheduled job.

Real-Life Example: Automated API Price Monitor

This example polls a public API every minute, logs the latest data, and sends a simple alert if a value crosses a threshold. It uses jsonplaceholder.typicode.com as the data source so you can run it right now without any API keys. Swap in a real finance or monitoring API endpoint for production use.

# monitor/tasks.py
import requests
from celery import shared_task
from datetime import datetime

# Simple in-memory alert tracker; use Redis or a DB in production
_alert_sent = {}

@shared_task(name="monitor.tasks.check_metric",
             bind=True,
             max_retries=3,
             default_retry_delay=10)
def check_metric(self, record_id, threshold):
    try:
        response = requests.get(
            f"https://jsonplaceholder.typicode.com/posts/{record_id}",
            timeout=5
        )
        response.raise_for_status()
        data = response.json()
        # Simulate a metric from the post ID field
        metric_value = data.get("id", 0) * 2.75
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] Record #{record_id}: value={metric_value:.2f}")
        # Fire alert once if value crosses threshold
        if metric_value > threshold and not _alert_sent.get(record_id):
            print(f"  ALERT: {metric_value:.2f} crossed threshold {threshold:.2f}")
            _alert_sent[record_id] = True
        elif metric_value <= threshold:
            _alert_sent[record_id] = False  # reset when value drops
        return {"id": record_id, "value": metric_value, "ts": timestamp}
    except requests.RequestException as exc:
        print(f"[check_metric] Request failed: {exc}")
        raise self.retry(exc=exc)
# monitor/celery.py
from celery import Celery
from celery.schedules import timedelta

app = Celery("monitor", broker="redis://localhost:6379/0")
app.conf.include = ["monitor.tasks"]

app.conf.beat_schedule = {
    "check-record-42-every-minute": {
        "task": "monitor.tasks.check_metric",
        "schedule": timedelta(minutes=1),
        "args": (42, 50.0),   # record_id=42, threshold=50.0
        "options": {"expires": 55},  # drop if worker down >55s
    },
    "check-record-7-every-5min": {
        "task": "monitor.tasks.check_metric",
        "schedule": timedelta(minutes=5),
        "args": (7, 25.0),
    },
}

Output (worker terminal, running for 3 minutes):

[2026-07-10 08:00:01] Record #42: value=115.50
  ALERT: 115.50 crossed threshold 50.00
[2026-07-10 08:01:01] Record #42: value=115.50
[2026-07-10 08:05:01] Record #7: value=19.25
[2026-07-10 08:05:01] Record #42: value=115.50

Key patterns worth keeping from this example: bind=True gives the task access to self for retries; max_retries=3 with default_retry_delay=10 automatically retries on network failure; options: {expires: 55} prevents a pile-up of stale tasks if your worker goes offline. Extend this by writing values to a PostgreSQL table, sending real alerts via Twilio or SendGrid, or plotting a rolling chart with matplotlib.

Frequently Asked Questions

Can I run multiple beat instances?

No -- run exactly one beat process per Celery application. Running two beat instances causes duplicate tasks: both instances send the same task at the same time and your workers execute it twice. If you need high availability, use django-celery-beat with a database-backed scheduler and a distributed lock (such as celery-redlock) to ensure only one beat fires at a time. For most applications, a single beat process managed by supervisord or systemd is sufficient and reliable.

What happens if beat was offline and missed a scheduled run?

celery-beat does not backfill missed runs by default. If beat was offline for 2 hours and you have an hourly task, those 2 missed runs are skipped -- beat resumes from the current time when it restarts. If you need catch-up behavior (the task must run even if it was missed), implement it inside the task itself: check the last successful run timestamp from your database and re-run for each missed interval. The beat_schedule_filename pickle file tracks last-run times but does not trigger catch-up execution automatically.

How do I prevent a task from running again before the previous run finishes?

celery-beat does not know when a task finishes -- it only knows when to send it. Use a distributed lock to prevent overlap. The simplest approach is celery-singleton, which raises Ignore() if the same task is already running. Alternatively, use Redis directly: if not redis_client.set("lock:my-task", 1, nx=True, ex=300): raise Ignore(). The ex=300 sets a 5-minute TTL so the lock auto-expires if the task crashes without releasing it.

My crontab task fires at the wrong time. What is wrong?

Set timezone and enable_utc=True explicitly in your Celery config. By default Celery uses UTC; if your crontab(hour=9) is meant for 9 AM local time, it will fire at 9 AM UTC instead. Set app.conf.timezone = "Australia/Melbourne" (or your local timezone) and Celery will interpret crontab times in that timezone. Verify with celery -A myapp inspect scheduled -- it shows the next scheduled run time in UTC so you can sanity-check the conversion.

My periodic task fails occasionally. How do I add retries?

Decorate with bind=True and call self.retry(exc=exc) inside the exception handler, as shown in the real-life example above. Set max_retries and default_retry_delay on the decorator. For exponential backoff, pass countdown=2 ** self.request.retries to self.retry(): this waits 1s, then 2s, then 4s between retries. Be careful: if a 1-minute task fails and retries 3 times with increasing delays, the final retry could land after the next scheduled run has already started.

How do I monitor which tasks ran and whether they succeeded?

Set a result backend (backend="redis://localhost:6379/1") and Celery stores task results keyed by task ID. Use celery -A myapp events for a live event stream, or run celery -A myapp flower (install flower separately) for a web dashboard that shows task history, run times, and failure rates. For production, integrate with your monitoring stack: Celery can emit metrics to Datadog, Prometheus (via celery-prometheus-exporter), or any StatsD-compatible collector.

Conclusion

celery-beat turns Celery from a "do this now" system into a "do this now and also every night at 3 AM" system. The core concepts are straightforward: configure a broker, define tasks with @shared_task, declare a beat_schedule using timedelta or crontab, and run the beat and worker processes separately. For schedules that need to change without a deployment, django-celery-beat's database scheduler gives you admin-editable periodic tasks with zero code changes.

Extend the API monitor from this article by wiring in a real data source (the yfinance library is a free option for stock prices), storing readings in a PostgreSQL table via SQLAlchemy, and sending real alerts via SMTP or a service like SendGrid. Once that is running you will have a complete scheduled data pipeline -- periodic fetch, persist, alert -- all managed by celery-beat with automatic retries and failure visibility.

For more detail on Celery configuration and advanced task options, see the official celery-beat documentation and the Celery task guide.

How To Use Python Tenacity for Retry Logic

How To Use Python Tenacity for Retry Logic

Intermediate

You’ve built a Python script that calls a third-party API — weather data, a payment processor, a database over a flaky connection. Most of the time it works great. But occasionally the request times out, or the server returns a 503, or the network hiccups for half a second. Your script crashes. Your data pipeline fails at 3am. You get paged. Sound familiar?

The fix is retry logic — automatically re-attempting a failed operation before giving up. Python’s Tenacity library makes this trivially easy. It’s a standalone package (just pip install tenacity) that gives you a powerful @retry decorator and a rich set of stop conditions, wait strategies, and error callbacks — no need to write the same while True: try/except/sleep boilerplate ever again.

In this article we’ll cover how Tenacity works, how to configure it for real-world retry scenarios, and how to combine exponential backoff with jitter so your retries don’t make a thundering-herd problem worse. By the end, you’ll be able to wrap any flaky function with battle-tested retry logic in about five lines of code.

How To Use Python Tenacity: Quick Example

Here’s the simplest possible Tenacity usage — retry a function up to 3 times before giving up:

# quick_tenacity.py
import requests
from tenacity import retry, stop_after_attempt, wait_fixed

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def fetch_status():
    response = requests.get("https://httpbin.org/status/500")
    response.raise_for_status()  # raises HTTPError on 4xx/5xx
    return response.status_code

try:
    result = fetch_status()
    print("Success:", result)
except Exception as e:
    print("Failed after 3 attempts:", e)

Output:

Failed after 3 attempts: 500 Server Error: INTERNAL SERVER ERROR for url: https://httpbin.org/status/500

The @retry decorator intercepts the exception raised by raise_for_status(), waits 2 seconds, and tries again — up to 3 times total. When all attempts are exhausted, Tenacity re-raises the last exception. We’re using httpbin.org/status/500 here because it reliably returns a 500 error, making it a perfect test target without needing a broken server of your own.

That’s the core pattern. The rest of this article shows you how to customize the stop condition, wait strategy, and error handling for real production use.

What Is Tenacity and Why Use It?

Tenacity is a general-purpose retrying library for Python. It was forked from the older retrying package in 2016 and has been actively maintained since. Its key insight is that retry logic has several independent dimensions — when to stop, how long to wait between tries, which exceptions to retry on, and what to do when all tries are exhausted — and it lets you configure each dimension independently using composable building blocks.

Here’s how Tenacity compares to the most common alternatives:

ApproachProsCons
Manual while loopNo dependenciesRepetitive, easy to get wrong, hard to test
retrying (original)Simple APIUnmaintained since 2016, no async support
tenacityComposable, async support, active maintenanceSmall learning curve
backoffMinimal APIFewer customization options than Tenacity

Tenacity’s composable design means you can combine strategies freely — exponential backoff plus jitter plus a maximum delay cap, retrying only on specific exceptions, with a custom callback on each retry — all in one decorator call.

Developer staring at infinite retry loop on monitor
The API said no. Tenacity said: ask again.

Controlling When to Stop Retrying

Tenacity gives you several composable stop conditions. You can combine them with the | operator to stop when either condition is met.

Stop After N Attempts

The most common stop condition: give up after a fixed number of tries. The count includes the first attempt — so stop_after_attempt(3) means 1 original call + 2 retries.

# stop_attempts.py
from tenacity import retry, stop_after_attempt

@retry(stop=stop_after_attempt(5))
def unstable_call():
    raise ConnectionError("Server busy")

try:
    unstable_call()
except Exception as e:
    print(f"Gave up: {e}")

Output:

Gave up: Server busy

Stop After a Time Limit

Sometimes it’s more useful to cap the total elapsed time rather than the number of attempts. stop_after_delay(seconds) stops retrying once more than N seconds have elapsed since the first call.

# stop_delay.py
from tenacity import retry, stop_after_delay, wait_fixed

@retry(stop=stop_after_delay(10), wait=wait_fixed(3))
def slow_service():
    raise TimeoutError("Still not ready")

try:
    slow_service()
except Exception as e:
    print(f"Timed out: {e}")

Output:

Timed out: Still not ready

This function retries every 3 seconds but will never run beyond 10 seconds total. The combination stop=stop_after_attempt(5) | stop_after_delay(30) stops whichever limit is hit first — a common pattern for production code.

Developer watching hourglass drain with failed API requests stacking up
stop_after_delay(30): because infinite patience is a bug, not a feature.

Wait Strategies: Fixed, Exponential, and Jitter

How long you wait between retries matters as much as how many times you retry. Hammering a struggling server every millisecond makes things worse. Tenacity’s wait strategies let you control the delay precisely.

Fixed Wait

wait_fixed(seconds) waits the same amount of time between every attempt. Simple and predictable, good for testing or when the failure is likely transient and brief.

# wait_fixed_example.py
import time
from tenacity import retry, stop_after_attempt, wait_fixed

@retry(stop=stop_after_attempt(3), wait=wait_fixed(1))
def ping():
    print(f"  Attempt at {time.strftime('%H:%M:%S')}")
    raise IOError("No response")

try:
    ping()
except Exception as e:
    print(f"Failed: {e}")

Output:

  Attempt at 09:00:00
  Attempt at 09:00:01
  Attempt at 09:00:02
Failed: No response

Exponential Backoff

Exponential backoff doubles the wait time after each failure. This is the right default for most API and network retries — it gives overloaded services breathing room rather than piling on more requests.

# wait_exponential_example.py
import time
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=30)
)
def call_api():
    print(f"  Attempt at {time.strftime('%H:%M:%S')}")
    raise ConnectionError("Upstream unavailable")

try:
    call_api()
except Exception as e:
    print(f"Gave up: {e}")

Output:

  Attempt at 09:00:00
  Attempt at 09:00:01
  Attempt at 09:00:03
  Attempt at 09:00:07
  Attempt at 09:00:15
Gave up: Upstream unavailable

The multiplier scales the base delay, min sets the floor (no delay shorter than 1s), and max caps the ceiling so delays don’t grow unboundedly on long retry sequences.

Adding Jitter

When many clients retry simultaneously after a service blip, exponential backoff alone creates “retry waves” — hundreds of requests hitting the server at the same moment. Adding random jitter spreads retries across time, preventing this thundering-herd effect. Use wait_random_exponential to combine both in one step:

# wait_jitter_example.py
from tenacity import retry, stop_after_attempt, wait_random_exponential

@retry(
    stop=stop_after_attempt(6),
    wait=wait_random_exponential(multiplier=1, max=60)
)
def distributed_fetch():
    raise IOError("Service unavailable")

# Each client will retry on a slightly different schedule

The wait time is drawn from a random distribution bounded by the exponential cap, so two clients starting at the same time will take slightly different retry paths. AWS, Google Cloud, and Stripe all recommend this pattern in their API documentation.

Multiple developers frozen mid-sprint scattered by jitter cloud
wait_random_exponential: distributed systems therapy.

Retrying on Specific Exceptions

By default Tenacity retries on any exception. In production you usually want to retry on transient errors (network timeouts, 503s) but not on permanent ones (401 Unauthorized, 404 Not Found, invalid input). Use retry_if_exception_type to be precise:

# retry_specific.py
import requests
from tenacity import (
    retry, stop_after_attempt, wait_exponential,
    retry_if_exception_type
)

class TransientError(Exception):
    pass

class PermanentError(Exception):
    pass

@retry(
    stop=stop_after_attempt(4),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    retry=retry_if_exception_type(TransientError)
)
def smart_fetch(url):
    response = requests.get(url)
    if response.status_code == 503:
        raise TransientError("Service temporarily unavailable")
    if response.status_code == 401:
        raise PermanentError("Authentication failed -- check your API key")
    response.raise_for_status()
    return response.json()

try:
    data = smart_fetch("https://httpbin.org/status/503")
except TransientError as e:
    print(f"Gave up after retries: {e}")
except PermanentError as e:
    print(f"Not retrying -- permanent error: {e}")

Output:

Gave up after retries: Service temporarily unavailable

You can also use retry_if_result to retry based on the return value rather than an exception — useful when a bad response returns 200 OK but with an error payload.

Before/After Callbacks and Logging

Tenacity supports before, after, and before_sleep callbacks for observability. The most useful is before_sleep, which fires before each wait period and lets you log retry attempts without cluttering the main function:

# retry_logging.py
import logging
from tenacity import (
    retry, stop_after_attempt, wait_exponential,
    before_sleep_log
)

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

@retry(
    stop=stop_after_attempt(4),
    wait=wait_exponential(multiplier=1, min=2, max=20),
    before_sleep=before_sleep_log(logger, logging.WARNING)
)
def fetch_with_logging():
    raise ConnectionError("Upstream timeout")

try:
    fetch_with_logging()
except ConnectionError as e:
    logger.error("All retries exhausted: %s", e)

Output:

WARNING:__main__:Retrying fetch_with_logging in 2.0 seconds as it raised ConnectionError: Upstream timeout.
WARNING:__main__:Retrying fetch_with_logging in 4.0 seconds as it raised ConnectionError: Upstream timeout.
WARNING:__main__:Retrying fetch_with_logging in 8.0 seconds as it raised ConnectionError: Upstream timeout.
ERROR:__main__:All retries exhausted: Upstream timeout

The built-in before_sleep_log and after_log helpers format retry messages with the attempt number and wait time. You can also pass any callable to before_sleep that accepts a RetryCallState object for custom logging or metrics emission.

Developer with clipboard logging retry attempts while error messages flash on screen
before_sleep_log: because “it just worked eventually” is not an incident response.

Async Support

Tenacity works with async def functions out of the box — the same decorator handles both sync and async functions. No separate import or special configuration needed:

# async_tenacity.py
import asyncio
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def async_fetch(url: str) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        response.raise_for_status()
        return response.json()

async def main():
    try:
        data = await async_fetch("https://jsonplaceholder.typicode.com/todos/1")
        print("Title:", data["title"])
    except Exception as e:
        print("Failed:", e)

asyncio.run(main())

Output:

Title: delectus aut autem

We use httpx here because the standard requests library is synchronous and doesn’t play nicely with asyncio. If your project already uses aiohttp, Tenacity works the same way — just decorate the async def and it handles the rest.

Real-Life Example: Resilient API Client

Here’s a practical class that wraps an HTTP API with Tenacity retry logic, structured-data parsing, and proper error handling for both transient and permanent failures:

Developer inspecting resilient API pipeline with retry arrows
Production-grade resilience: 20 lines, zero prayer.
# resilient_client.py
import requests
import logging
from tenacity import (
    retry, stop_after_attempt, wait_random_exponential,
    retry_if_exception_type, before_sleep_log
)

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class TransientAPIError(Exception):
    """Raised for 5xx responses and network errors -- safe to retry."""
    pass

class PermanentAPIError(Exception):
    """Raised for 4xx responses -- do not retry."""
    pass

class ResilientAPIClient:
    BASE_URL = "https://jsonplaceholder.typicode.com"

    @retry(
        stop=stop_after_attempt(5),
        wait=wait_random_exponential(multiplier=1, max=30),
        retry=retry_if_exception_type(TransientAPIError),
        before_sleep=before_sleep_log(logger, logging.WARNING)
    )
    def _get(self, path: str) -> dict:
        try:
            response = requests.get(self.BASE_URL + path, timeout=10)
        except requests.exceptions.Timeout:
            raise TransientAPIError("Request timed out")
        except requests.exceptions.ConnectionError as e:
            raise TransientAPIError(f"Connection failed: {e}")

        if 500 <= response.status_code < 600:
            raise TransientAPIError(f"Server error: {response.status_code}")
        if 400 <= response.status_code < 500:
            raise PermanentAPIError(f"Client error: {response.status_code}")

        return response.json()

    def get_user(self, user_id: int) -> dict:
        return self._get(f"/users/{user_id}")

    def get_posts_for_user(self, user_id: int) -> list:
        return self._get(f"/posts?userId={user_id}")

if __name__ == "__main__":
    client = ResilientAPIClient()

    user = client.get_user(1)
    print(f"User: {user['name']} ({user['email']})")

    posts = client.get_posts_for_user(1)
    print(f"Posts: {len(posts)} found")
    for post in posts[:2]:
        print(f"  - {post['title'][:50]}")

Output:

User: Leanne Graham (Sincere@april.biz)
Posts: 10 found
  - sunt aut facere repellat provident occaecati excepturi
  - qui est esse

This pattern separates retry logic from business logic cleanly. The _get method knows only about HTTP mechanics and Tenacity. The get_user and get_posts_for_user methods know only about the API structure. To extend this, add methods for POST/PUT/DELETE operations (though be careful — only idempotent operations should be retried without caution).

Frequently Asked Questions

How do I install Tenacity?

Run pip install tenacity. No other dependencies are required. Tenacity supports Python 3.8+ and is actively maintained. To confirm it’s installed correctly, run python -c "import tenacity; print(tenacity.__version__)".

Does Tenacity re-raise the original exception?

Yes. When all attempts are exhausted, Tenacity raises a tenacity.RetryError by default, with the original exception stored in RetryError.__cause__. If you want the raw original exception raised instead, add reraise=True to the @retry decorator. Most production code uses reraise=True so the caller sees the actual failure, not a wrapper.

Is it safe to retry any function?

Only if the function is idempotent — meaning calling it twice has the same effect as calling it once. GET requests, read operations, and pure functions are safe. POST requests that create resources, payment operations, or any operation with side effects should NOT be retried blindly. For those, use Tenacity only on the specific exception types that confirm the operation did not go through (like a TimeoutError before the request completed).

Can I combine stop conditions?

Yes — use the | operator to stop when either condition is met: stop=stop_after_attempt(5) | stop_after_delay(60). This stops after 5 attempts OR after 60 seconds, whichever comes first. The & operator requires both conditions to be true simultaneously, which is less common but valid.

Does Tenacity work with async functions?

Yes, transparently. Apply @retry to an async def function and Tenacity detects the coroutine and awaits it correctly. The same wait strategies, stop conditions, and callbacks work for both sync and async code. There’s no separate import or class needed — the same from tenacity import retry handles both.

How do I test code that uses Tenacity in unit tests?

The cleanest approach is to patch the decorated function’s .retry attribute or mock the underlying function so it fails N times then succeeds. Alternatively, pass a custom stop=stop_after_attempt(1) in your test environment via the retry_with method: my_function.retry.stop = stop_after_attempt(1). This overrides the production retry count so tests run fast without sleeping.

Conclusion

Python Tenacity turns retry logic from a chore into a one-liner. We’ve covered the core decorator API, all the major stop conditions (stop_after_attempt, stop_after_delay), wait strategies (fixed, exponential, jitter), exception filtering with retry_if_exception_type, and built-in logging with before_sleep_log. The async support means you can use the same patterns in FastAPI, aiohttp, or any other async Python codebase.

The real-life example shows the most important architectural decision: keep your retry configuration on a single internal method and let higher-level methods stay clean. This makes it easy to tune retry parameters (attempt count, max delay) in one place as you learn how your upstream services actually behave.

For deeper customization — custom retry predicates, statistics callbacks, or Tenacity’s Retrying context-manager API — see the official Tenacity documentation.