Filter concepts by levelShowing all levels.

Django · i18n and Time Zones

Internationalization and Time Zones

Storage versus display — in two dimensions, with an hour that happens twice

  • USE_TZ is on by defaultaware datetimes, stored in UTC — TIME_ZONE only decides how they render
  • A reading is not a moment"01:30, 25 Oct 2026" happens twice in London and once nowhere in spring
  • gettext_lazy at import, gettext per requestverbose_name and choices run once; a view body runs per user
  • A lazy object is not a strstr.format() rejects it — use format_lazy; json.dumps raises
  • Only .mo is read at runtimeship .po without compilemessages and the site is untranslated, silently

1.The settings that turn it on, and what each one actually controls

Four settings decide how Django handles language and time, and they are easy to confuse because two of them look like defaults and are not. `USE_TZ` decides whether datetimes are timezone-aware — it is **on by default**, and it is the setting that makes `timezone.now()` return an aware value in UTC. `TIME_ZONE` is the *default display* timezone, not the storage one; storage is UTC whenever `USE_TZ` is on. `LANGUAGE_CODE` is the fallback language for a request whose own preference could not be determined. `USE_I18N` switches the translation machinery on at all.

Key features

  • Time zone support is enabled by default — `USE_TZ` must be explicitly set to `False` to disable it
  • With `USE_TZ = True`, datetimes are stored in UTC; `TIME_ZONE` is the default *display* zone
  • `timezone.now()` returns an aware datetime when `USE_TZ = True` and a naive one when it is `False`
  • `LANGUAGE_CODE` is the fallback only — `LocaleMiddleware` resolves per request and sets `request.LANGUAGE_CODE`
  • Discovery order: URL prefix → cookie → `Accept-Language` header → `LANGUAGE_CODE`
  • Django never guesses a user's timezone — you store it and call `timezone.activate()` yourself

Storage versus display — which setting is on which side

Storage versus display — which setting is on which side
SettingSideWhat it actually does
`USE_TZ`storageon → datetimes are aware and stored in UTC; off → naive, stored as written
`TIME_ZONE`displaythe default rendering zone; **not** the storage zone
`USE_I18N`displayswitches the translation machinery on
`LANGUAGE_CODE`displaythe fallback language when a request expresses no preference
`LANGUAGES`displaythe list a language selector and `LocaleMiddleware` may choose from
`LOCALE_PATHS`buildwhere your own `.po`/`.mo` files live

How the active language and the active timezone are resolved

How the active language and the active timezone are resolved
QuestionLanguageTimezone
resolved per request?yes, by `LocaleMiddleware`no — only if you do it
1st sourceURL prefix (`i18n_patterns`)whatever you stored on the user
2nd sourcecookie (`LANGUAGE_COOKIE_NAME`)the session
3rd source`Accept-Language` header— no header exists
fallback`LANGUAGE_CODE``TIME_ZONE`
exposed as`request.LANGUAGE_CODE``timezone.get_current_timezone()`
Four settings, two of which are about storage and two about display

USE_TZ = True TIME_ZONE = "Europe/London" USE_I18N = True LANGUAGE_CODE = "en-gb"

USE_TZ = True

Storage — On by default. Datetimes become aware and are stored in UTC. Turning this off does not simplify anything — it moves the ambiguity into your data.

TIME_ZONE = "Europe/London"

Display default — What a timestamp renders as when nothing more specific is active. It is not where the value is stored, and naming it "the project timezone" is what causes the confusion.

USE_I18N = True

Machinery switch — Turns the translation layer on. With it off, `gettext` calls return the original string and `.po` files are never consulted.

LANGUAGE_CODE = "en-gb"

Fallback only — Used when `LocaleMiddleware` finds no URL prefix, no cookie and no usable `Accept-Language` header. Most real requests never reach it.

  • Whole: USE_TZ = True TIME_ZONE = "Europe/London" USE_I18N = True LANGUAGE_CODE = "en-gb"
  • USE_TZ = True — Storage: On by default. Datetimes become aware and are stored in UTC. Turning this off does not simplify anything — it moves the ambiguity into your data.
  • TIME_ZONE = "Europe/London" — Display default: What a timestamp renders as when nothing more specific is active. It is not where the value is stored, and naming it "the project timezone" is what causes the confusion.
  • USE_I18N = True — Machinery switch: Turns the translation layer on. With it off, `gettext` calls return the original string and `.po` files are never consulted.
  • LANGUAGE_CODE = "en-gb" — Fallback only: Used when `LocaleMiddleware` finds no URL prefix, no cookie and no usable `Accept-Language` header. Most real requests never reach it.

Command

python
USE_TZ = True  # aware datetimes in UTC; TIME_ZONE only decides how they render

Time zone support is on by default. `USE_TZ` controls *storage* — aware datetimes, saved in UTC — while `TIME_ZONE` only controls the default *display* zone; calling it "the project timezone" is what makes people think it changes what is stored. `LANGUAGE_CODE` is likewise a fallback: `LocaleMiddleware` resolves per request via URL prefix → cookie → `Accept-Language` → `LANGUAGE_CODE`. There is no equivalent for time, because no header carries a zone — store it on the user and call `timezone.activate()`, then deactivate in a `finally` so it cannot leak into the next request on that thread.

2.`gettext` vs `gettext_lazy`, and the file workflow behind them

You mark a string as translatable by wrapping it: `_("Order cancelled")`. Which `_` you imported decides *when* the translation happens. `gettext` translates immediately, using whatever language is active at that moment — right for code that runs inside a request. `gettext_lazy` returns a placeholder that translates only when it is finally rendered as text — right for anything evaluated once at import, like a model field's `verbose_name` or a form label, because at import time there is no active language yet. Marked strings are then extracted into `.po` files with `makemessages`, translated, and compiled to `.mo` with `compilemessages`.

Key features

  • `gettext` translates now, against the active language; `gettext_lazy` defers until the string is rendered
  • Use lazy for anything evaluated at import: `verbose_name`, `help_text`, form labels, `choices`, class attributes
  • `str.format()` "will not work when either the format_string or any of the arguments contains lazy translation objects" — use `format_lazy`
  • `ngettext(singular, plural, count)` for plurals; both forms must use the *same* placeholder names
  • Never `_(f"Welcome {name}")` — the f-string is evaluated before the marker, so nothing extractable remains
  • `makemessages` writes `.po`; `compilemessages` writes `.mo` — only `.mo` is read at runtime

Which marker to import, by where the code runs

Which marker to import, by where the code runs
LocationRunsUse
a view body, a method, a taskper request/call`gettext as _`
`verbose_name`, `help_text`at import`gettext_lazy as _`
form field `label`, `error_messages`at import`gettext_lazy as _`
`choices` / module-level constantsat import`gettext_lazy as _`
a string with a count in iteither`ngettext` / `ngettext_lazy`
same word, two meanings ("May")either`pgettext("month name", "May")`

The file workflow, and what breaks at each step

The file workflow, and what breaks at each step
Command / fileProducesIf you skip it
`makemessages -l fr``locale/fr/LC_MESSAGES/django.po`new strings are never offered for translation
(a translator edits the `.po`)`msgstr` entriesempty `msgstr` falls back to the original — silently
`compilemessages``django.mo` (binary)**the site renders untranslated, with no error**
`LOCALE_PATHS`where Django looksyour app's catalogue is not found
`makemessages -d djangojs``djangojs.po`strings inside JavaScript are missed
From a marked string to a translated page — and where each step fails quietly

1 · Mark it

Wrap the literal in `_()`. `makemessages` is a scanner, so it can only see literals — a variable passed to `_()` extracts nothing at all.

2 · Extract

`makemessages -l fr` writes `locale/fr/LC_MESSAGES/django.po`, each entry carrying the source file and line it came from.

3 · Translate

A translator fills in `msgstr`. An entry left empty is not an error — gettext falls back to the original string, so a missed translation looks like a design choice.

4 · Compile

`compilemessages` writes the binary `.mo`, which is the only file read at runtime. Ship `.po` without compiling and the site renders in English with nothing logged.

5 · Resolve, per request

`LocaleMiddleware` picks the language — URL prefix, then cookie, then `Accept-Language`, then `LANGUAGE_CODE` — and lazy strings resolve against it as they render.

  1. 1 · Mark it — Wrap the literal in `_()`. `makemessages` is a scanner, so it can only see literals — a variable passed to `_()` extracts nothing at all.
  2. 2 · Extract — `makemessages -l fr` writes `locale/fr/LC_MESSAGES/django.po`, each entry carrying the source file and line it came from.
  3. 3 · Translate — A translator fills in `msgstr`. An entry left empty is not an error — gettext falls back to the original string, so a missed translation looks like a design choice.
  4. 4 · Compile — `compilemessages` writes the binary `.mo`, which is the only file read at runtime. Ship `.po` without compiling and the site renders in English with nothing logged.
  5. 5 · Resolve, per request — `LocaleMiddleware` picks the language — URL prefix, then cookie, then `Accept-Language`, then `LANGUAGE_CODE` — and lazy strings resolve against it as they render.

Command

python
from django.utils.translation import gettext_lazy as _   # models, forms, module level

Pick the marker by when the code runs: `gettext` in views and methods, `gettext_lazy` for anything evaluated at import — `verbose_name`, `help_text`, labels, `choices`. A lazy object is not a `str`: `str.format()` does not accept one (use `format_lazy`), and serialising one into JSON, a cache or a task argument either raises or freezes a language for everyone. Never wrap an f-string — the interpolation happens first and `makemessages` extracts nothing. And remember only `.mo` is read at runtime, so a deploy that skips `compilemessages` renders untranslated with no error at all.

3.Naive vs aware, UTC, and the hour that happens twice

A datetime is **aware** when its `tzinfo` is set and describes an offset, and **naive** when it is not. A naive datetime is a wall-clock reading with no zone — "01:30 on 25 October" — which is not enough information to identify a moment, because in London that reading happens twice that night. So store instants in UTC, which has no daylight saving and no ambiguity, and convert to a local zone only when you display them. `timezone.now()` gives you an aware UTC value; `timezone.localtime()` converts one for display.

Key features

  • Aware = `tzinfo` set and describing an offset; naive = not set — "otherwise, it's naive"
  • `timezone.now()` is aware when `USE_TZ = True` and naive when it is `False`
  • Store UTC, render local: `timezone.localtime(value)` uses the currently active zone
  • Saving a naive datetime raises `RuntimeWarning: … received a naive datetime … while time zone support is active`
  • Datetimes inside a DST transition "don't exist or are ambiguous" — Django reports them as invalid values
  • Adding `timedelta(days=1)` adds 24 hours of *elapsed* time, not "the same time tomorrow"

Reading in, instant out — what to do at each boundary

Reading in, instant out — what to do at each boundary
BoundaryIt is aDo
form / API inputreadingattach the user's zone, then store the resulting instant
database columninstantUTC, always — this is what `USE_TZ` arranges
template outputreadingautomatic: `{{ value }}` renders in the active zone
Python output to a personreading`timezone.localtime(value)`
JSON to another systeminstantISO-8601 with the offset — `2026-10-25T00:30:00+00:00`
a log lineinstantUTC; a log correlated across regions must share a timeline

The helpers, and when each is the right one

The helpers, and when each is the right one
CallReturnsUse it when
`timezone.now()`aware UTCalways — instead of `datetime.now()`
`timezone.localtime(v)`aware, in the active zonerendering a value in Python
`timezone.localdate(v)`a `date` in the active zone"which day was this, for this reader?"
`timezone.make_aware(v, tz)`awarepromoting a parsed reading to an instant
`timezone.is_aware(v)`boolguarding a boundary you do not control
`{{ value|localtime }}`rendered texttemplates — usually automatic already
Two instants, one wall-clock reading — 25 October 2026, London

The night UK clocks go back. Stored in UTC these two rows are an hour apart and sort correctly; stored as local wall-clock readings they are the same value, and nothing downstream can tell them apart again.

  • Two boxes at the top show two distinct UTC instants on 25 October 2026: 00:30 UTC, which is British Summer Time at UTC plus one, and 01:30 UTC, which is Greenwich Mean Time at UTC plus zero.
  • Arrows from both boxes converge on a single box below, labelled "01:30 on the wall in London" — the same local reading for both instants.
  • A note records that the clock goes back at 02:00 BST to 01:00 GMT, so the 01:00 to 02:00 hour of readings occurs twice; in spring the same hour never occurs at all.

Command

python
timezone.now()            # aware, UTC — never datetime.now()

Aware means `tzinfo` is set; naive is a wall-clock reading, and a reading is not a moment — on 25 October 2026 the reading "01:30" happens twice in London. Store instants in UTC (`timezone.now()`, never `datetime.now()`), render readings with `timezone.localtime()`. Promote input to an instant at the boundary by attaching the user's zone, and reject readings that fall in the repeated hour instead of guessing. Group reports with a `tzinfo` — "per day" is a local question. And `+ timedelta(days=1)` is 24 elapsed hours, not the same time tomorrow.

Remember

  • Django resolves the active *language* per request. It never resolves the active *timezone* — no header carries one.
  • `timezone.now()`, never `datetime.now()`. The second is a wall-clock reading from whichever machine ran it.
  • `+ timedelta(days=1)` adds 24 elapsed hours, which is not "the same time tomorrow" on the two days a year that are 23 or 25 hours long.
  • Never wrap an f-string in `_()`: the interpolation runs first, so `makemessages` extracts nothing and the string stays English forever.

Pro tip

  • Deactivate the timezone in a `finally`. Workers reuse threads, and an activated zone leaks into the next request on that thread.
  • `TruncDate("when", tzinfo=…)` — "bookings per day" is a local question, and truncating in UTC silently misfiles evening rows.

Next up

  • Management Commands
  • File Exports and Imports
  • Multi-Tenancy

Sources

  • Time zones — docs.djangoproject.com/en/5.2/topics/i18n/timezones/
  • Translation — docs.djangoproject.com/en/5.2/topics/i18n/translation/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.