pendulum
Pendulum is a datetime library whose main class, DateTime, subclasses the standard library datetime, so it drops into code that already expects one. What it adds is the part the stdlib makes tedious: every instance is timezone aware (UTC unless you say otherwise), arithmetic is done with add() and subtract() rather than timedelta math, and both understand that a day is not always 24 hours when a DST transition sits in the middle. It also gives you a real parser, human-readable differences like "2 minutes ago" in dozens of CLDR locales, period boundaries via start_of and end_of, an Interval type for the span between two datetimes, and a Duration that knows about months and years. Since 3.0 the parsing hot path is a compiled Rust extension, which is why there are 66 wheels on PyPI and one pure Python fallback.
Still the most pleasant datetime API in Python, and the DST-aware arithmetic alone justifies it for anything scheduling-shaped. The caution is direction rather than quality: releases are roughly annual now, whenever is the better-designed newer option, and if all you needed was timezone support, zoneinfo has been in the standard library since 3.9.
Use it if
- You are doing date arithmetic across timezones and DST is real for you: add(days=1) on Pendulum walks the calendar correctly, where naive timedelta math silently gives you a 23 or 25 hour day
- You want relative time strings without another dependency: diff_for_humans() gives "2 minutes ago" or "5 months from now" with CLDR translations built in
- You keep writing the same period boundary code: start_of('week'), end_of('month'), and Interval.range('days') replace a pile of replace() calls and off-by-one bugs
- You want the timezone attached to every object by default, so a naive datetime cannot leak into your database or your comparisons
- You are porting existing datetime code and want to keep it working: DateTime is a datetime subclass, so isinstance checks and most library boundaries accept it unchanged
- You only need timezones. Python 3.9 has zoneinfo in the standard library, and datetime plus zoneinfo covers a surprising amount of what people install this for
- You want a library that is clearly still moving. There have been three releases in the 3.x line since December 2023, the last on 2026-01-30, and 207 open issues out of 263 open issues and PRs; the project was renamed to a python-pendulum org and is community maintained now rather than driven by its original author
- You care about API design over convenience. Whenever is the modern take on this problem, with separate types for instants, local datetimes, and zoned datetimes so the compiler catches the mistakes Pendulum lets you make at runtime
- You use libraries that type-check with type() instead of isinstance(). The README documents breakage with sqlite3, mysqlclient, and PyMySQL, each needing a hand-registered adapter, and Django needs care around isoformat() always carrying an offset
- You call datetime code in a hot loop. Every operation allocates a subclass instance and goes through more Python than the stdlib does, so parsing millions of timestamps is measurably slower than datetime.fromisoformat
- Your platform has no wheel. There is a py3-none-any fallback, but you lose the Rust parsing extension and get the slower pure Python path with no warning
- You want the test helpers to work out of the box. pendulum.travel_to() and freeze() need time-machine installed separately, and on PyPy they are not available at all
Setup reality
pip install pendulum pulls python-dateutil and tzdata and gives you one of 66 compiled wheels covering CPython 3.9 through 3.13, PyPy, and the usual Linux, macOS, and Windows targets. When none of them match you get the py3-none-any wheel instead, which works but runs the slower pure Python parser, and nothing tells you that happened; you can force it deliberately with PENDULUM_EXTENSIONS=0. Version floors are confusing right now: 3.2.0 declares Python 3.9 or newer, but the README on main already says 3.10 and newer, so check before you pin. The real friction is downstream. Because DateTime subclasses datetime, most things work, but any library that branches on type(obj) rather than isinstance rejects it, and the README lists the ones people actually hit: sqlite3, mysqlclient, and PyMySQL all need an adapter registered by hand, and Django's DateTimeField can choke because Pendulum's isoformat() always includes an offset. Finally, if you want the testing helpers, pendulum.travel_to and pendulum.freeze are wrappers around time-machine, which only ships in the test extra, so a plain install gives you an ImportError at the moment the test runs.
Patterns
Create aware datetimes and move between zonesnow-and-timezones
import pendulum
now = pendulum.now("Europe/Paris")
utc = now.in_timezone("UTC") # in_tz is the short alias
dt = pendulum.datetime(2026, 3, 29, 9, 30, tz="Europe/Paris")
pendulum.today("Asia/Kolkata")
pendulum.yesterday() # local timezone by default
pendulum.now().naive() # strip tzinfo when something demands itpendulum.datetime() defaults to UTC while today, tomorrow, and yesterday default to the local zone, which is an easy inconsistency to trip over. Every instance is aware, so comparing one to a naive stdlib datetime raises TypeError instead of quietly comparing wrong.
Parse ISO 8601, and everything elseparse-strings
import pendulum
pendulum.parse("2026-08-06T12:30:00+02:00")
pendulum.parse("2026-08-06", tz="America/New_York")
pendulum.parse("2026-W32-3") # ISO week date
pendulum.parse("P1Y2M3DT4H") # ISO duration -> Duration
pendulum.parse("06/08/2026", strict=False, day_first=True)
pendulum.from_format("06 Aug 2026 14:05", "DD MMM YYYY HH:mm", tz="UTC")strict=True is the default and only accepts ISO 8601 formats; strict=False hands anything else to dateutil, which will guess and sometimes guess wrong, so day_first matters. parse() returns whichever of Date, Time, DateTime, or Duration the input described, so annotate carefully if you feed it user input.
Add and subtract without DST bugsdate-arithmetic
import pendulum
start = pendulum.datetime(2026, 3, 28, 12, 0, tz="Europe/Paris")
start.add(days=1) # 2026-03-29 12:00+02:00, still noon local
start.add(hours=24) # 2026-03-29 13:00+02:00, exactly 24h later
pendulum.now().subtract(weeks=2, hours=3)
pendulum.now().add(months=1) # 31 Jan + 1 month clamps to 28 or 29 FebCalendar units (years, months, weeks, days) move along wall-clock time, so add(days=1) keeps the same local hour across a DST change, while add(hours=24) adds real elapsed time. Picking the wrong one is the single most common scheduling bug this library exists to prevent.
Handle times that do not exist or happen twicedst-edge-cases
import pendulum
# 02:30 is skipped on this date in Paris; pendulum shifts it forward
pendulum.datetime(2013, 3, 31, 2, 30, tz="Europe/Paris") # -> 03:30+02:00
# fail loudly instead of guessing
pendulum.datetime(2013, 3, 31, 2, 30, tz="Europe/Paris",
raise_on_unknown_times=True)
# ambiguous time in autumn: fold picks which of the two you mean
pendulum.datetime(2013, 10, 27, 2, 30, tz="Europe/Paris", fold=0)
pendulum.now("Europe/Paris").is_dst()By default a nonexistent local time is normalised forward rather than rejected, which is convenient for display and dangerous for a scheduler. Pass raise_on_unknown_times=True anywhere a wrong hour would matter, and set fold explicitly for ambiguous autumn times instead of accepting the default.
Relative time strings and localisationhumanize-differences
import pendulum
past = pendulum.now().subtract(minutes=2)
past.diff_for_humans() # '2 minutes ago'
past.diff_for_humans(absolute=True) # '2 minutes'
past.diff_for_humans(locale="fr") # 'il y a 2 minutes'
later = pendulum.now().add(days=3)
later.diff_for_humans(past) # '3 days after'
pendulum.set_locale("de") # process-wide defaultWith no argument it compares against now and says "ago" or "from now"; passing another datetime switches the wording to "before" and "after". set_locale is global state, so in a web app pass locale= per call instead of setting it per request and racing other handlers.
Start and end of a day, week, month, or yearperiod-boundaries
import pendulum
dt = pendulum.now("UTC")
dt.start_of("day") # 00:00:00
dt.end_of("month") # last day, 23:59:59.999999
dt.start_of("week") # Monday by default
dt.end_of("year")
pendulum.week_starts_at(pendulum.SUNDAY) # change it globally
dt.set(hour=9, minute=0, second=0, microsecond=0)Valid units are second, minute, hour, day, week, month, year, decade, and century; anything else raises ValueError rather than being ignored. end_of gives 23:59:59.999999, not the next midnight, so half-open range queries in SQL should use start_of the following period instead.
The span between two datetimesintervals
import pendulum
start = pendulum.datetime(2026, 1, 1)
end = pendulum.datetime(2026, 3, 15, 6, 30)
span = pendulum.interval(start, end)
span.in_days() # 73
span.hours # 6, the remainder after whole days
span.in_words(locale="en") # '2 months 1 week 6 hours 30 minutes'
for day in span.range("days"):
schedule(day)
for slot in span.range("hours", 6):
schedule(slot)This was called Period before 3.0, and pendulum.period() no longer exists. Watch the property versus method split: .hours is the leftover hours after larger units are taken out, while .in_hours() is the total. dt.diff(other) returns one of these, absolute by default.
Durations that understand months and yearsdurations
import pendulum
d = pendulum.duration(years=1, months=2, days=3, hours=4)
d.in_words() # '1 year 2 months 3 days 4 hours'
d.total_seconds() # months and years use average lengths here
d.in_days()
pendulum.now() + pendulum.duration(months=1) # calendar-correct
pendulum.now() + d.as_timedelta() # stdlib, fixed lengthDuration subclasses timedelta but adds months and years, which timedelta refuses to model because they have no fixed length. Adding a Duration to a Pendulum DateTime does the calendar-correct thing; converting to a plain timedelta first collapses months into an average number of days and quietly changes the answer.
Format for humans and for machinesformatting
import pendulum
dt = pendulum.datetime(2026, 8, 6, 14, 5, 9, tz="Europe/Berlin")
dt.to_iso8601_string() # '2026-08-06T14:05:09+02:00'
dt.to_rfc3339_string()
dt.to_datetime_string() # '2026-08-06 14:05:09'
dt.to_day_datetime_string() # 'Thu, Aug 6, 2026 2:05 PM'
dt.format("dddd Do [of] MMMM YYYY HH:mm")
dt.format("dddd DD MMMM", locale="fr")
dt.strftime("%Y-%m-%d") # stdlib tokens still workformat() uses CLDR-style tokens (YYYY, MMMM, dddd), not strftime percent codes, and literal text has to be wrapped in square brackets or its letters get interpreted as tokens. strftime is inherited from datetime and unchanged, so both vocabularies exist side by side in the same class.
Convert to and from stdlib datetimesstdlib-interop
import datetime
import pendulum
naive = datetime.datetime(2026, 8, 6, 12, 0)
pendulum.instance(naive) # assumed UTC
pendulum.instance(naive, tz="Europe/Paris")
pendulum.from_timestamp(1785000000, tz="UTC")
dt = pendulum.now()
isinstance(dt, datetime.datetime) # True
datetime.datetime.fromisoformat(dt.to_iso8601_string()) # plain datetime backinstance() treats a naive input as UTC unless you say otherwise, which is a silent data corruption if it was really local time. Subclassing means most libraries accept a DateTime directly, but anything doing type(obj) is datetime.datetime will not, which is why sqlite3, mysqlclient, and PyMySQL each need an adapter registered.
Make DBAPI drivers accept a DateTimedatabase-adapters
from sqlite3 import register_adapter
from pendulum import DateTime
register_adapter(DateTime, lambda val: val.isoformat(" "))
# mysqlclient / PyMySQL
import MySQLdb.converters
import pymysql.converters
MySQLdb.converters.conversions[DateTime] = MySQLdb.converters.DateTime2literal
pymysql.converters.conversions[DateTime] = pymysql.converters.escape_datetimeThese drivers dispatch on the exact type, so a datetime subclass falls through to the default handler and you get a stringified repr in your column or an InterfaceError. Register the adapters once at import time, before any connection is opened, and remember Pendulum's isoformat always carries an offset, which some MySQL column types reject.
Freeze and move time in teststesting-time-travel
# pip install "pendulum[test]" (installs time-machine)
import pendulum
def test_expiry():
with pendulum.travel_to(pendulum.datetime(2030, 1, 1, tz="UTC"), freeze=True):
assert pendulum.now().year == 2030
pendulum.travel(days=3) # move relative to now
...
pendulum.travel_back() # always restorependulum.set_test_now() from 2.x is gone; these helpers wrap time-machine, which only ships in the test extra, so a plain install raises ImportError the first time a test calls them. They are unavailable on PyPy entirely. Use the context manager form, because travel without travel_back leaks the shifted clock into every later test in the process.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| whenever | PyPI | You want separate types for instants, local times, and zoned datetimes so wrong comparisons fail at type-check time rather than in production |
| arrow | PyPI | You want a similar convenience wrapper with humanize and a large existing user base, and you do not need the datetime subclassing |
| python-dateutil | PyPI | You mostly need flexible parsing and recurrence rules, and you are happy with plain datetime objects plus zoneinfo for the rest |