mrkeyoor.com_
Thu 06 Aug 07:40 UTC
PyPIUtilsupdated 06 Aug 2026

arrow

arrow gives you one datetime-like object that is timezone-aware by default and covers the things the standard library spreads across datetime, time, calendar, dateutil and pytz. You get relative math (shift(hours=-1, weeks=2)), timezone conversion (to("US/Pacific")), moment.js-style formatting and parsing (format("YYYY-MM-DD HH:mm ZZ")), human strings in many languages (humanize(locale="ko-kr")), and time spans and ranges that stdlib simply does not have. The catch worth knowing before you start: an Arrow is not a datetime subclass. It wraps one, exposes it as .datetime, and any code that isinstance-checks datetime will reject it.

Verdict

A pleasant single type for date math, formatting and humanization in application code, and still the easiest way to get "3 days ago" in twenty languages. Because it is not a datetime subclass, it works best contained inside your own modules; if you are choosing today, pendulum or whenever cost you less at the boundaries.

API stability5/5The 1.x line has held since 2021 with no breaking rename; 1.4.0 added Python 3.14 support and kept everything else. Code written against arrow 1.0 still runs unchanged.
Docs4/5arrow.readthedocs.io documents every factory, token and locale with runnable examples, and the token table is genuinely good. What it plays down is the one thing that bites people: that Arrow is not a datetime and what that costs at library boundaries.
Maintenance3/5Pushed June 2026 and Python 3.14 is supported, so it is not abandoned, but releases are years apart (1.3.0 in 2023, 1.4.0 in October 2025) and 95 issues (180 counting PRs) are open. Expect compatibility updates, not new features.
Ecosystem4/5Around 18.8 million weekly downloads and a long tail of packages that depend on it, with dozens of contributed humanize locales. There is no plugin system, and framework integrations mostly expect stdlib datetime instead.

Use it if

  • Your code does a lot of relative date math and formatting in one place and you would rather write dt.shift(months=-1).floor("day") than assemble relativedelta and replace() calls by hand
  • You need human-readable relative strings such as "an hour ago" in more than one language, which humanize(locale=...) covers with community-contributed locales
  • You need time spans, floors, ceilings or ranges: span("hour"), floor("day"), Arrow.range("hour", start, end) and Arrow.span_range have no stdlib equivalent and are tedious to write correctly
  • You are timezone-aware by policy and want a type that refuses to be naive by default, so a UTC-versus-local mistake becomes hard rather than easy
Skip it if

Setup reality

pip install arrow is painless: pure Python, and the only runtime dependencies are python-dateutil plus tzdata on Python 3.9 and up. The friction starts after the install and it is all at the edges of your code. First, the format tokens are moment.js style, not strftime, and mixing them fails silently rather than loudly: a.format("%Y-%m-%d") returns the string "%Y-%23-%6" because Y, m and d are parsed as arrow tokens and the percent signs pass through. Literal text has to be bracketed, as in "YYYY-MM-DD [at] HH:mm". Second, because Arrow is not a datetime, you will write .datetime at nearly every boundary: database writes, JSON encoding, and any third-party call that type-checks. Third, arrow.get with an explicit format is strict and raises ParserMatchError (a subclass of ParserError) on a mismatch, while arrow.get without a format silently accepts partial inputs like a bare year. Decide once which of those two you want and be consistent.

Patterns

Get now, in UTC or a named zonecreate-current-time

import arrow

arrow.utcnow()              # <Arrow [2026-08-06T06:13:31.862024+00:00]>
arrow.now()                # local zone
arrow.now("US/Pacific")
arrow.get(2013, 5, 5)       # <Arrow [2013-05-05T00:00:00+00:00]>

Everything is timezone-aware and defaults to UTC, including arrow.get() built from bare integers. arrow.now() without an argument reads the machine's local zone, which differs between your laptop and a container set to UTC.

Parse ISO 8601 and explicit formatsparse-strings

import arrow
from arrow.parser import ParserError

arrow.get("2013-05-11T21:23:58.970460+07:00")
arrow.get("2013-05-05 12:30:45", "YYYY-MM-DD HH:mm:ss")
arrow.get("2013-05-05", tzinfo="US/Pacific")

try:
    arrow.get("05/11/2013", "YYYY-MM-DD")
except ParserError as e:
    print(e)  # Failed to match 'YYYY-MM-DD' when parsing '05/11/2013'.

With a format string, parsing is strict and raises ParserMatchError, which subclasses ParserError. Without one, it is lenient in ways that hide bad data: arrow.get("2013") returns 2013-01-01T00:00:00+00:00 instead of failing.

Format with moment.js-style tokensformat-output

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")

a.format()                          # '2013-05-11 21:23:58+00:00'
a.format("YYYY-MM-DD HH:mm:ss ZZ")  # '2013-05-11 21:23:58 +00:00'
a.format("dddd, MMMM D, YYYY", locale="fr")
a.format("YYYY-MM-DD [at] HH:mm")   # '2013-05-11 at 21:23'
a.isoformat()                       # '2013-05-11T21:23:58+00:00'

These are not strftime codes. a.format("%Y-%m-%d") returns the string '%Y-%23-%6' with no error, because Y, m and d are tokens and the percent signs are literal. Square brackets escape literal text; a.strftime() is still available if you want the C-style codes.

Move a time by a relative amountshift-relative

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")

a.shift(hours=-1)
a.shift(weeks=+2, hours=-1)   # 2013-05-25T20:23:58+00:00
a.shift(months=+1)
a.shift(years=-1, days=+3)

shift understands calendar units (years, months, quarters, weeks) as well as clock units, so shift(months=+1) on January 31 clamps to the end of February rather than overflowing. It returns a new Arrow; nothing mutates.

Set specific fieldsreplace-absolute

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")

a.replace(hour=0, minute=0)      # 2013-05-11T00:00:58+00:00
a.replace(tzinfo="US/Pacific")   # same wall clock, different zone

replace() sets fields absolutely and shift() moves relatively; mixing them up is the classic arrow bug. Note that replace(hour=0, minute=0) leaves seconds and microseconds alone, so use floor("day") when you want true midnight.

Convert between zonesconvert-timezone

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")

a.to("US/Pacific")   # 2013-05-11T14:23:58-07:00
a.to("local")
a.to("UTC")
a.utcoffset(), a.tzname()   # (datetime.timedelta(0), 'UTC')
a.to("US/Pacific").naive    # datetime(2013, 5, 11, 14, 23, 58)

to() changes the zone while keeping the same instant; .naive strips the tzinfo afterwards and hands you a plain datetime in that wall clock. Zone names come from the IANA database, so "US/Pacific" and "America/Los_Angeles" both work.

Produce and read human-friendly stringshumanize-and-back

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")
b = arrow.get("2013-05-11T23:23:58+00:00")

a.humanize(b)                       # '2 hours ago'
a.humanize(b, only_distance=True)   # '2 hours'
a.humanize(locale="ko-kr")          # '13년 전'
a.humanize(granularity=["day", "hour"])
arrow.utcnow().dehumanize("2 hours ago")

Pass the reference time explicitly; the default is now, which makes tests time-dependent. Locale files are community contributions with uneven coverage, and the exact wording can change between releases, so never parse humanize output.

Floor, ceiling and span of a time unitspans-and-boundaries

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")

a.floor("day")   # 2013-05-11T00:00:00+00:00
a.ceil("day")    # 2013-05-11T23:59:59.999999+00:00
a.span("hour")   # (21:00:00, 21:59:59.999999)
start, end = a.span("month")

ceil() lands on .999999, not on the next unit's midnight, so a BETWEEN query using it is inclusive on both ends. If your database comparison is half-open, use floor() of the following period instead.

Walk a time range in fixed stepsiterate-ranges

import arrow
start = arrow.get("2013-05-05T12:30")
end = arrow.get("2013-05-05T15:30")

for t in arrow.Arrow.range("hour", start, end):
    print(t)   # 12:30, 13:30, 14:30, 15:30

for lo, hi in arrow.Arrow.span_range("hour", start, end):
    print(lo, hi)   # 12:00-12:59:59.999999, ...

range() steps from the start instant and keeps its minutes and seconds; span_range() snaps to unit boundaries instead. Both return every step in memory as a list, so bound the range before calling them on years of minutes.

Cross the boundary into stdlib and other librariesinterop-with-datetime

import arrow, datetime, json
a = arrow.get("2013-05-11T21:23:58+00:00")

isinstance(a, datetime.datetime)   # False
a.datetime                          # real datetime, tz-aware
a.naive                             # real datetime, tz stripped

json.dumps({"t": a.isoformat()})   # works
# json.dumps({"t": a}) -> TypeError: Object of type Arrow is not JSON serializable

arrow.Arrow.fromdatetime(datetime.datetime(2013, 5, 5), "US/Pacific")
arrow.get(datetime.datetime.now(datetime.timezone.utc))

This is the tax for using arrow. Pass .datetime to SQLAlchemy columns, pandas, pydantic and anything that type-checks, and .isoformat() to JSON. Storing an Arrow in a dataclass that later gets serialized is the usual way this surfaces late.

Convert to and from Unix timestampstimestamps

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")

a.timestamp()      # 1368307438.0  (float, a method)
a.int_timestamp    # 1368307438    (int, a property)
arrow.get(1367900664)
arrow.get(1367900664.152325)

timestamp() is a method in arrow 1.x (it was a property in 0.x, which is the most common upgrade break) and int_timestamp is a property. arrow.get on a bare number always treats it as seconds since the epoch in UTC, so divide milliseconds first.

Read calendar fieldscalendar-accessors

import arrow
a = arrow.get("2013-05-11T21:23:58+00:00")

a.date(), a.time()        # datetime.date, datetime.time
a.weekday()               # 5 (Saturday, Monday is 0)
a.isocalendar()           # IsoCalendarDate(year=2013, week=19, weekday=6)
a.year, a.month, a.day, a.hour, a.tzinfo

weekday() is zero-based from Monday while isocalendar().weekday is one-based, and the two disagree by design because both mirror stdlib. Pick one convention per codebase and write it down.

Alternatives

PackageRegistryPick it when
wheneverPyPIYou want strict typed separation of instants, local times and zoned times, with a Rust core, and you are starting fresh rather than migrating.
pendulumPyPIYou want the same ergonomics but a type that actually subclasses datetime, so it drops into libraries that type-check without conversion.
python-dateutilPyPIAll you really needed was flexible string parsing and relativedelta math on top of ordinary stdlib datetimes.