mrkeyoor.com_
Thu 06 Aug 10:54 UTC
PyPIUtilsupdated 06 Aug 2026

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').

Verdict

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.

API stability5/5Function names and signatures have held across the whole 4.x line; new behaviour arrives as extra keyword arguments such as minimum_unit, when and ndigits rather than renames, and the only breaking change in recent years has been raising the Python floor to 3.10 for 4.16.0
Docs4/5Every public function carries doctest examples, the README walks each family of helpers, and Read the Docs mirrors the API; the marks come off because a few README examples have drifted from the code (naturaldelta(timedelta(seconds=1001)) actually returns '17 minutes', not the '16 minutes' shown) and nothing documents the two real traps, the thread-local locale and the timezone conversion inside naturaltime
Maintenance5/5Pushed 2026-08-01, with 4.13.0 through 4.16.0 shipping between August 2025 and June 2026, 14 genuinely open issues out of 37 open issues and PRs, and Python 3.14 and 3.15 already in the classifiers under the python-humanize org rather than a single personal account
Ecosystem4/5Around 16.4M downloads a week and the package most Python codebases reach for when they want 'time ago' text, but it complements Babel and arrow rather than replacing them, and there is no plugin surface: what ships in the wheel is the whole feature set

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

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

PackageRegistryPick it when
babelPyPIYou need real CLDR formatting for numbers, currencies and dates across locales rather than English shapes with a few separator swaps
arrowPyPIYou want one datetime replacement that parses, shifts, converts timezones and has a humanize() method in the same object
timeagoPyPIRelative time strings are all you need and you would rather not import twenty functions you will never call