mrkeyoor.com_
Thu 06 Aug 08:46 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The 2.x to 3.x jump was disruptive (Period became Interval, set_test_now was replaced by travel_to and freeze on top of time-machine), but 3.0 shipped in December 2023 and the two releases since have been additive; the only deprecation in flight is pendulum.__version__, slated for removal in 3.4
Docs4/5pendulum.eustace.io covers the whole API with examples for every method, and the README is unusually honest in listing the sqlite3, PyMySQL, and Django interop breakages with working fixes; the gaps are the newer Interval and Duration behaviour and the testing helpers, which are thin compared to the DateTime pages
Maintenance3/5Pushed 2026-07-06 and the repo moved to a python-pendulum organisation with community maintainers, but releases are roughly annual (3.0.0 December 2023, 3.1.0 April 2025, 3.2.0 January 2026) and 207 open issues out of 263 open issues and PRs suggests the triage queue is well behind the report rate
Ecosystem4/5About 17.5M downloads a week and a dependency of Airflow and other scheduling tools, so it is widely present in data stacks; the counterweight is that zoneinfo covers the common case in the standard library now and whenever is drawing new projects away

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
Skip it if

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 it

pendulum.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 Feb

Calendar 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 default

With 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 length

Duration 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 work

format() 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 back

instance() 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_datetime

These 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 restore

pendulum.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

PackageRegistryPick it when
wheneverPyPIYou want separate types for instants, local times, and zoned datetimes so wrong comparisons fail at type-check time rather than in production
arrowPyPIYou want a similar convenience wrapper with humanize and a large existing user base, and you do not need the datetime subclassing
python-dateutilPyPIYou mostly need flexible parsing and recurrence rules, and you are happy with plain datetime objects plus zoneinfo for the rest