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:
| Type | What It Represents | When To Use It |
|---|---|---|
Instant | A UTC-based point in time with no wall-clock display | Logging, event timestamps, storing datetimes in a database |
ZonedDateTime | A wall-clock time in a named IANA timezone | Scheduling, calendar events, displaying time to users in their timezone |
LocalSystemDateTime | A datetime in the current system’s local timezone | Scripts that run in a known environment and display local time |
NaiveDateTime | A datetime with no timezone at all | Historical 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.
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.
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.
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.
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.
# 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.