humanize
humanize turns raw numbers, byte counts and datetimes into the phrasing people actually read: 1234567 becomes '1,234,567' or '1.2 million', a million bytes becomes '1.0 MB', and a timestamp from an hour ago becomes 'an hour ago'. It is one flat module of about twenty functions with zero dependencies, so you import humanize and call humanize.naturaltime(value) directly with no objects to construct. The helpers fall into four groups: numbers (intcomma, intword, ordinal, apnumber, fractional, scientific, clamp, metric), time (naturalday, naturaldate, naturaltime, naturaldelta, precisedelta), byte sizes (naturalsize), and lists (natural_list). Every phrase it emits goes through gettext and the wheel ships compiled catalogs for more than thirty locales, switched at runtime with humanize.activate('de_DE').
The default answer for turning numbers, byte sizes and durations into readable English, and it earns that by being dependency-free, stable for years, and hard to crash. Treat its localization as a bonus rather than a plan: the moment formatting has to be correct in locales it does not special-case, put Babel underneath.
Use it if
- You want Django's naturaltime behaviour outside Django: naturaltime(value) gives 'a second ago' or '2 days from now' and picks the tense from the value itself when you pass a datetime or timedelta
- You are printing byte counts and want all three conventions from one function: naturalsize(n) for SI MB, binary=True for MiB, gnu=True for the short '976.6K' form that ls -h prints
- You need a precise duration rather than a fuzzy one: precisedelta(delta, minimum_unit='microseconds', suppress=['days']) composes the exact string instead of rounding everything to one unit
- You want output in more than one language without building your own gettext setup: the wheel carries compiled .mo files for thirty-plus locales and humanize.activate('de_DE') switches all of them at once
- You are rendering in templates and cannot afford exceptions: intcomma(None) returns 'None' and ordinal('something else') returns the input back, so a bad row does not take the page down
- You need locale-correct number formatting. Only six locales have separator entries in humanize (de_DE, fr_FR, it_IT, pt_BR, hu_HU, lv), so intcomma prints '1,234,567' for a Spanish or Indian user no matter what you activate. Babel and CLDR exist for exactly this and humanize does not pretend to replace them
- You are running async code and want per-request locales. activate() stores the language in a threading.local, not a ContextVar, so every coroutine sharing the event loop thread also shares the locale and one request's activate() bleeds into the next
- Server timezone is not something you want to think about. naturaltime rewrites any aware datetime to naive local time via datetime.fromtimestamp() before subtracting, so the same database row reads differently depending on the TZ of the process unless you pass when= yourself
- You only need one helper. It is small and dependency-free, but a fifteen-line 'time ago' function is a real option; naturalsize and precisedelta are the two pieces that are genuinely annoying to reimplement
- You want loud failures. Every function swallows bad input and falls back to str(value), so an upstream None shows up as the literal word 'None' in your UI rather than as a traceback in your logs
- You are stuck on Python 3.9 or older. 4.16.0 requires 3.10 or newer, so you would be pinning to the 4.9 line and losing later fixes
Setup reality
pip install humanize and you are done: no dependencies, pure Python, and 4.16.0 declares Python 3.10 or newer, so anything older pins to the 4.9 line. The friction is not in installation, it is in the two pieces of hidden global state. Translations live as compiled .mo files inside the wheel, so activate('de_DE') works immediately, but a locale that is not shipped raises FileNotFoundError at the activate() call rather than at render time, and adding your own catalogs means passing path= on every activate. That call writes to a threading.local, so the locale is per-thread process state; under gunicorn threads or an asyncio loop you are sharing it with other requests. On the time side, naturaltime and naturaldelta compare against datetime.now() in the process timezone and silently convert aware datetimes to naive local time, so a container running in UTC and a developer laptop in IST print different words for the same row. Neither problem is visible in a REPL, which is why both reach production. Pass when= to naturaltime for anything you intend to test.
Patterns
Render a timestamp as 'an hour ago'relative-time-ago
import datetime as dt
import humanize
now = dt.datetime.now()
humanize.naturaltime(now - dt.timedelta(seconds=1)) # 'a second ago'
humanize.naturaltime(now - dt.timedelta(seconds=3600)) # 'an hour ago'
humanize.naturaltime(now + dt.timedelta(days=2)) # '2 days from now'
# a bare number is seconds, and there the tense is yours to pick
humanize.naturaltime(90) # 'a minute ago'
humanize.naturaltime(90, future=True) # '2 minutes from now'The future flag is ignored for datetime and timedelta inputs, where the tense is derived from the value; it only matters for plain numbers. Anything under a second collapses to the single word 'now', so a row you just inserted reads 'now' rather than '0 seconds ago'. Raise minimum_unit to 'milliseconds' if you are showing short intervals.
Make relative time deterministic with when=pin-the-reference-clock
import datetime as dt
import humanize
created = dt.datetime(2026, 8, 1, 12, 0, tzinfo=dt.timezone.utc)
# unpinned: compared against datetime.now() in the process timezone
humanize.naturaltime(created)
# pinned: reproducible in a test, and correct for a user's own clock
humanize.naturaltime(
created,
when=dt.datetime(2026, 8, 1, 15, 0, tzinfo=dt.timezone.utc),
)
# '3 hours ago'Both value and when are passed through datetime.fromtimestamp() if they are aware, so they land in the server's local timezone before subtraction. Supplying when= is the only way to get output you can assert on in tests, and the only way to render relative to the viewer's clock instead of the machine's.
Format byte counts in SI, binary or GNU stylebyte-sizes
import humanize
humanize.naturalsize(1_000_000) # '1.0 MB' SI, powers of 1000
humanize.naturalsize(1_000_000, binary=True) # '976.6 KiB' powers of 1024
humanize.naturalsize(1_000_000, gnu=True) # '976.6K' ls -h style
humanize.naturalsize(1_000_000, format="%.2f") # '1.00 MB'The default is SI, so '1.0 GB' here means 1,000,000,000 bytes, which is not what du, ls or most filesystem tools report. Choose one convention and apply it across the whole product, because mixing SI in the file list with binary in the storage meter makes two screens disagree about the same file.
Comma groups, word scale, ordinals and AP numberslarge-numbers
import humanize
humanize.intcomma(1_234_567) # '1,234,567'
humanize.intcomma(1234.5454, 2) # '1,234.55' ndigits rounds
humanize.intword(1_200_000) # '1.2 million'
humanize.intword(1_234_000, "%0.3f") # '1.234 million'
humanize.intword(800) # '800' unchanged below a thousand
humanize.apnumber(4) # 'four' AP style spells out 1 to 9
humanize.apnumber(41) # '41'
humanize.ordinal(1002) # '1002nd'intword returns small values untouched, so a dashboard that mixes 800 with 1.2 million needs a fallback to intcomma or the column looks inconsistent. intcomma's separator only changes for the six locales humanize keeps in its separator table, so pairing it with an activate() for, say, es_ES still prints commas.
Turn a timedelta into one readable unitfuzzy-duration
import datetime as dt
import humanize
humanize.naturaldelta(dt.timedelta(seconds=1001)) # '17 minutes'
humanize.naturaldelta(dt.timedelta(milliseconds=4)) # 'a moment'
humanize.naturaldelta(
dt.timedelta(milliseconds=4), minimum_unit="milliseconds"
) # '4 milliseconds'
humanize.naturaldelta(dt.timedelta(days=400)) # '1 year, 1 month'
humanize.naturaldelta(dt.timedelta(days=400), months=False) # '1 year, 35 days'naturaldelta rounds to the nearest unit, so sub-second values become 'a moment' unless you lower minimum_unit. months=True treats a month as 30.5 days, which is fine for prose and wrong for billing; pass months=False when the number has to reconcile with a calendar.
Compose an exact duration stringprecise-duration
import datetime as dt
import humanize
delta = dt.timedelta(days=2, seconds=3633, microseconds=123_000)
humanize.precisedelta(delta)
# '2 days, 1 hour and 33.12 seconds'
humanize.precisedelta(delta, minimum_unit="microseconds")
# '2 days, 1 hour, 33 seconds and 123 milliseconds'
humanize.precisedelta(delta, suppress=["days"], format="%0.4f")
# '49 hours and 33.1230 seconds'
humanize.precisedelta(delta, minimum_unit="minutes")
# '2 days, 1 hour and 0.55 minutes'suppress folds a unit into the next larger one, which is how you get '49 hours' instead of '2 days, 1 hour'. Watch minimum_unit: setting it to 'minutes' leaves a fractional tail like '0.55 minutes' rather than dropping the remainder, so it is a floor on the unit, not a rounding instruction.
Say 'today' and 'yesterday', fall back to a datecalendar-day
import datetime as dt
import humanize
today = dt.date.today()
humanize.naturalday(today) # 'today'
humanize.naturalday(today - dt.timedelta(days=1)) # 'yesterday'
humanize.naturalday(dt.date(2007, 6, 5)) # 'Jun 05'
humanize.naturalday(dt.date(2007, 6, 5), "%Y-%m-%d") # '2007-06-05'
humanize.naturaldate(dt.date(2007, 6, 5)) # 'Jun 05 2007'naturalday drops the year by default, which reads fine for recent activity and is ambiguous for archives; naturaldate is the same helper with the year kept. For an aware datetime it computes 'today' in that value's own timezone, which is the opposite of what naturaltime does, so the two helpers can disagree about the same instant near midnight.
Render in another language at runtimeswitch-locale
import datetime as dt
import humanize
humanize.activate("de_DE")
humanize.naturaltime(dt.timedelta(seconds=3)) # 'vor 3 Sekunden'
humanize.intcomma(1_234_567) # '1.234.567'
humanize.activate("fr_FR")
humanize.naturaldelta(dt.timedelta(days=3)) # '3 jours'
humanize.deactivate()
humanize.intcomma(1_234_567) # '1,234,567'
humanize.activate("xx_XX")
# FileNotFoundError: No translation file found for domain: 'humanize'activate() writes to a threading.local, so it is per-thread global state, not per-call and not per-task. In a threaded WSGI server you must deactivate() in a finally block, and under asyncio it is unsafe outright because coroutines on one loop thread share the value. An unshipped locale raises FileNotFoundError right there, so validate the language code before you call it.
Build an Oxford-free list phrasejoin-a-list
import humanize
humanize.natural_list(["one"]) # 'one'
humanize.natural_list(["one", "two"]) # 'one and two'
humanize.natural_list(["one", "two", "three"]) # 'one, two and three'
humanize.natural_list([]) # ''Items are coerced with str(), so model objects come out as their repr unless you map them first. The separator word is not translated by the locale machinery the way durations are, and there is no serial comma and no way to ask for one, so if your style guide requires 'one, two, and three' you need your own join.
Bound a value and print SI unit prefixesclamp-and-units
import humanize
humanize.clamp(0.0001, floor=0.01) # '<0.01'
humanize.clamp(0.99, format="{:.0%}", ceil=0.99) # '99%'
humanize.clamp(1.0, format="{:.0%}", ceil=0.99) # '>99%'
humanize.metric(1500, "V") # '1.50 kV'
humanize.metric(0.0002, "F") # '200 μF'clamp uses str.format templates while intword and naturalsize use printf-style ones, so the two families of format arguments are not interchangeable and mixing them up raises rather than misformatting. metric emits a real micro sign, so anything downstream that assumes ASCII, such as a legacy log shipper or a fixed-width report, needs the encoding checked.
Expose the helpers as Jinja filterstemplate-filters
from jinja2 import Environment
import humanize
env = Environment()
env.filters["naturaltime"] = humanize.naturaltime
env.filters["naturalsize"] = humanize.naturalsize
env.filters["intcomma"] = humanize.intcomma
env.from_string("{{ n|intcomma }} bytes").render(n=1234567)
# '1,234,567 bytes'
env.from_string("{{ size|naturalsize(binary=True) }}").render(size=1048576)
# '1.0 MiB'The functions are plain callables with no shared state beyond the locale, so registering them directly works and needs no wrapper. Because they never raise, a null column renders as the word 'None' inside your page instead of failing the request, so guard optional fields in the template rather than trusting humanize to signal the problem.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| babel | PyPI | You need real CLDR formatting for numbers, currencies and dates across locales rather than English shapes with a few separator swaps |
| arrow | PyPI | You want one datetime replacement that parses, shifts, converts timezones and has a humanize() method in the same object |
| timeago | PyPI | Relative time strings are all you need and you would rather not import twenty functions you will never call |