Filter concepts by levelShowing all levels.

Django · Section 81

Internationalization and Time Zones

Level
intermediate
Read
34 min
Concepts
3

Both halves of this section are the same distinction applied twice: what you *store* is not what you *show*. For time, `USE_TZ` is the storage switch and it is on by default — datetimes become aware and the database holds UTC. `TIME_ZONE` is only the default display zone, which is why calling it "the project timezone" misleads people into believing it changes what is written. For language, the strings in your source are canonical and `LANGUAGE_CODE` is merely the fallback; `LocaleMiddleware` resolves the real one per request from a URL prefix, a cookie, `Accept-Language`, and only then the setting. There is no equivalent for time, because no header carries a zone: you store the user's zone yourself and call `timezone.activate()`, then deactivate in a `finally` so it cannot leak into the next request on a reused worker thread. The translation half turns on one timing question. `gettext` translates immediately against the active language, which is right inside a view; `gettext_lazy` defers until the string is rendered, which is what anything evaluated at import needs — `verbose_name`, `help_text`, form labels, `choices` — because at import there is no active language and `gettext` would freeze one for the whole process. The price is that a lazy object is not a string: `str.format()` rejects it (that is what `format_lazy` is for), and serialising one into JSON or a cache either raises or bakes one language into a shared store. Around all of it sits a dull but unforgiving pipeline — `makemessages` writes `.po`, translators fill it in, `compilemessages` produces the `.mo` that is the only file read at runtime, so a deploy that skips the compile step renders untranslated with nothing logged. The time half turns on a sharper distinction: an instant versus a wall-clock reading. A reading is not a moment. On 25 October 2026, the reading "01:30" occurs twice in London, an hour apart in UTC, and in spring the corresponding hour occurs not at all. So promote input to an instant at the boundary by attaching the zone it was written in, store UTC, render with `localtime()`, and pass a `tzinfo` when you group by day — because "per day" is a local question, and truncating in UTC misfiles every late-evening row.

What is true here

  1. Storage and display are different axes; USE_TZ/TIME_ZONE and the string/LANGUAGE_CODE pair each split across them.
  2. Language is resolved per request by middleware; timezone never is — that part is yours to write.
  3. Choose the marker by when the code runs: lazy at import, non-lazy per request.
  4. A lazy translation is a proxy, and every serialisation boundary is where that leaks.
  5. An instant is universal; a wall-clock reading repeats once a year and vanishes once a year.

What you will be able to do

  • Explain what `USE_TZ` changes, and why turning it off does not simplify anything
  • Pick `gettext` or `gettext_lazy` without guessing, and know why the other one breaks
  • Ship a translation that actually appears, rather than one that silently falls back
  • Handle the repeated hour deliberately instead of being an hour out twice a year
Stored or shown, language or time — where each setting and helper belongs
USE_TZ = True
aware datetimes, written to the database as UTC
timezone.now()
the instant — never datetime.now()
TIME_ZONE
the default display zone, and only the default
timezone.localtime()
renders an instant in the request's active zone
the literal in your source
the canonical string — this is what makemessages extracts
.mo catalogue
compiled; the only file read at runtime
LANGUAGE_CODE
fallback only — used when the request expressed no preference
request.LANGUAGE_CODE
resolved per request: URL → cookie → Accept-Language → setting
  • USE_TZ = True: what is stored, time — aware datetimes, written to the database as UTC
  • timezone.now(): what is stored, between time and language — the instant — never datetime.now()
  • TIME_ZONE: what is shown, time — the default display zone, and only the default
  • timezone.localtime(): what is shown, time — renders an instant in the request's active zone
  • the literal in your source: what is stored, language — the canonical string — this is what makemessages extracts
  • .mo catalogue: what is stored, language — compiled; the only file read at runtime
  • LANGUAGE_CODE: what is shown, language — fallback only — used when the request expressed no preference
  • request.LANGUAGE_CODE: what is shown, language — resolved per request: URL → cookie → Accept-Language → setting

The switches, and what they really control

Four settings, two about storage and two about display — and the one Django will not resolve for you.

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

coreintermediate

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.

Think of it as

The useful split is between what is *stored* and what is *shown*, and each pair of settings sits on one side of it. With `USE_TZ` on, every datetime you save goes to the database in UTC — one unambiguous instant, the same value regardless of who wrote it. `TIME_ZONE` then governs how that instant is rendered when nothing more specific applies, which is why calling it "the project timezone" misleads people into thinking it changes storage. It does not. The same shape holds for language: the strings in your code are one canonical language, and `LANGUAGE_CODE` is only the fallback used for rendering when a request has expressed no preference of its own. Requests almost always do express one. With `LocaleMiddleware` installed, Django resolves the active language per request in a documented order — a language prefix in the URL, then a cookie, then the `Accept-Language` header, then `LANGUAGE_CODE` — and the result lands on `request.LANGUAGE_CODE`. The equivalent for time has no such default: Django will not guess a user's timezone, because a browser does not send one in a header. You have to obtain it, store it against the user or the session, and call `timezone.activate()` per request. Until you do, every timestamp on the page renders in `TIME_ZONE`, which is correct for exactly the subset of your users who happen to live there. The two settings that are usually wrong in an existing project are these last two: `USE_TZ` turned off years ago by someone who found aware datetimes annoying, and a `TIME_ZONE` set to the founder's city and quietly treated as though it were storage.

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

What we're doing: Activate the right timezone per request from the user's stored preference, and fall back cleanly for everybody else.

core/middleware.pypython
import zoneinfo
from django.utils import timezone


class UserTimezoneMiddleware:
    """Django resolves the active *language* for you. It never resolves the
    active *timezone* — no header carries one. This does that job."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tzname = None
        if request.user.is_authenticated:
            tzname = request.user.timezone          # e.g. "Asia/Kolkata"
        tzname = tzname or request.session.get("display_timezone")

        if tzname:
            try:
                timezone.activate(zoneinfo.ZoneInfo(tzname))
            except zoneinfo.ZoneInfoNotFoundError:
                timezone.deactivate()               # bad data must not 500
        else:
            timezone.deactivate()                   # fall back to TIME_ZONE

        try:
            return self.get_response(request)
        finally:
            timezone.deactivate()                   # never leak into the next request
14
A stored IANA name on the user is the only reliable source. Guessing from an IP address gets travellers wrong and gets VPN users wrong more often.
20
`timezone.activate()` sets the zone for this thread. Everything after it — template rendering, `localtime()`, form input parsing — uses it, so no template needs to know who the user is.
21–22
A stale or hand-edited zone name must not become a 500. Falling back to `TIME_ZONE` shows a slightly wrong time; raising shows nothing at all.
24
`deactivate()` is the explicit fallback to `TIME_ZONE`, not a no-op. Being explicit is what makes the anonymous-visitor path readable.
26–29
The `finally` matters because workers reuse threads. An activated timezone that is never cleared leaks into whichever request that thread serves next — a bug that only appears under concurrency.

Why this works: Storage stays UTC for everyone, each request renders in the viewer's own zone, and bad or missing data degrades to `TIME_ZONE` instead of an error.

Reading `settings.TIME_ZONE` to decide what a user should see

Wrong

python
local = value.astimezone(ZoneInfo(settings.TIME_ZONE))
# every user sees the server's default zone, whoever they are

Better

python
local = timezone.localtime(value)   # uses the zone activated for THIS request

What you see: Timestamps are correct for colleagues in the office and consistently hours out for everyone else, and nobody notices until a customer disputes a deadline.

Why: `TIME_ZONE` is a project-wide fallback, so reading it directly hard-codes the assumption that every reader shares one zone. `timezone.localtime()` uses the *currently active* zone, which the middleware set from the user's own preference and which falls back to `TIME_ZONE` only when there is nothing better. The two agree in development, where you are the only user, which is precisely why the bug survives to production.

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.

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

Together

python
# settings.py
USE_TZ = True                 # aware datetimes, stored in UTC
TIME_ZONE = "Europe/London"   # what an unauthenticated visitor sees
USE_I18N = True
LANGUAGE_CODE = "en-gb"       # fallback only; LocaleMiddleware overrides per request

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()`

Together

python
# The timezone column has no middleware, so it is yours to write:
class TimezoneMiddleware:
    def __call__(self, request):
        tzname = getattr(request.user, "timezone", None)
        timezone.activate(ZoneInfo(tzname)) if tzname else timezone.deactivate()
        return self.get_response(request)

Remember: 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.

See also: aware datetimes utc and dst · gettext and the translation workflow · settings py

Advertisement

Marking strings, and shipping the catalogue

`gettext` or `gettext_lazy` by when the code runs, plus the `.po` → `.mo` pipeline that fails quietly.

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

coreintermediate

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

Think of it as

Everything here follows from one timing question: is this string produced now, or reused for the lifetime of the process? A view body runs per request, and by then `LocaleMiddleware` has already activated a language — so `gettext` gets the right answer and returns a plain string. A model class body runs once, at import, long before any request exists. If you use `gettext` there, the string is translated exactly once into whichever language happened to be active during startup, and every user afterwards sees that language regardless of their own. This is the failure the documentation is describing when it says lazy translation is essential in code paths executed at module load time. `gettext_lazy` avoids it by not translating at all: it hands back a lazy object that carries the original string and resolves against the active language at the moment something asks it for text — which is during rendering, per request, per user. The cost of that cleverness is that a lazy object is not a `str`, and the places where that leaks are worth memorising rather than rediscovering. `str.format()` does not work when the format string or any argument is a lazy object, so `format_lazy` exists for that case. Concatenating lazily with `+` is the same trap. And serialising one — into JSON, into a cache, into a task argument — either raises or silently stores something you did not intend, because the whole point of the object is that it has not decided what it says yet. The workflow around the markers is deliberately dull: `makemessages` scans your source for the calls and writes `.po` files under `locale/<lang>/LC_MESSAGES/`, translators edit those, `compilemessages` turns them into the binary `.mo` files gettext actually reads. Two consequences follow from `makemessages` being a *scanner*. It can only find literal strings inside the marker calls, so a variable passed to `_()` extracts nothing. And because `.mo` is what gets read at runtime, a deploy that ships `.po` files without compiling them shows the original language with no error at all.

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

What we're doing: One model and one view, each importing the marker that matches when its code runs — and a count handled with `ngettext`.

billing/models.py + billing/views.pypython
# billing/models.py — class body runs ONCE, at import.
from django.db import models
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _


class Invoice(models.Model):
    STATUS = [("draft", _("Draft")), ("sent", _("Sent")), ("paid", _("Paid"))]

    status = models.CharField(max_length=8, choices=STATUS, verbose_name=_("Status"))
    reference = models.CharField(
        max_length=32,
        verbose_name=_("Reference"),
        help_text=format_lazy("{a} — {b}", a=_("Unique per customer"), b=_("case-insensitive")),
    )

    class Meta:
        verbose_name = _("Invoice")
        verbose_name_plural = _("Invoices")


# billing/views.py — the body runs PER REQUEST, with a language already active.
from django.utils.translation import gettext as _, ngettext


def invoice_summary(request):
    count = request.user.invoices.unpaid().count()
    headline = ngettext(
        "You have %(count)d unpaid invoice.",
        "You have %(count)d unpaid invoices.",
        count,
    ) % {"count": count}
    return render(request, "billing/summary.html", {"headline": headline})
4
Lazy in the model module. These lines execute at import, before any request — `gettext` here would freeze one language for the whole process lifetime.
8
`choices` is evaluated at import too, so the labels must be lazy. The admin and every `ModelForm` built from this model then render them in the reader's language.
14
`format_lazy` is the documented workaround: `str.format()` does not work when the format string or an argument is a lazy object. Plain `.format()` here would raise or produce the repr.
20
Non-lazy `gettext` in the view, because by the time this line runs `LocaleMiddleware` has already activated the request's language.
27–31
Both forms use the same placeholder name, `%(count)d`. Using different names across singular and plural is what makes `compilemessages` fail, and plural rules are not two-way everywhere — several languages have three or more forms.

Why this works: Import-time strings stay unresolved until render, request-time strings translate immediately, and the count string stays correct in languages whose plural rules are not the English pair.

Interpolating before marking

Wrong

python
message = _(f"Welcome back, {user.first_name}")
# the f-string runs first: makemessages sees a variable and extracts nothing

Better

python
message = _("Welcome back, %(name)s") % {"name": user.first_name}

What you see: The `.po` file has no entry for the string, translators never see it, and the line stays in English in every language — with no error anywhere.

Why: An f-string is evaluated before `_()` is called, so the marker receives a finished string containing one particular user's name. `makemessages` scans source code, not runtime values, so there is nothing literal for it to extract — and even if there were, the msgid would contain that name. Named placeholders keep the literal intact and let a translator reorder them, which positional `%s` does not: languages that need the name before the greeting cannot express that with positional arguments.

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.

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")`

Together

python
from django.utils.translation import gettext as _        # in views
from django.utils.translation import gettext_lazy as _   # in models/forms

class Invoice(models.Model):
    total = models.DecimalField(verbose_name=_("Total"), max_digits=9, decimal_places=2)

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

Together

bash
django-admin makemessages -l fr -l de
# translators edit locale/*/LC_MESSAGES/django.po
django-admin compilemessages       # MUST run in the build, not on a developer laptop

Remember: 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.

See also: the four settings that turn it on · aware datetimes utc and dst · custom tags filters and loading

Advertisement

Instants and readings

Naive vs aware, UTC storage, and the hour that happens twice every October.

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

coreadvanced

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.

Think of it as

Hold two different things apart: an *instant* and a *reading*. An instant is a point on the universal timeline, and UTC names it unambiguously. A reading is what a clock on a particular wall says, and it is a rendering of an instant through a timezone — a lossy one, because the same reading can correspond to more than one instant, or to none. That is not a rare edge case, it happens twice a year everywhere daylight saving is observed. When clocks go back, one hour of readings repeats; when they go forward, one hour of readings never occurs. Django's design follows directly: store the instant, render the reading. With `USE_TZ` on, `timezone.now()` returns an aware datetime and the database column holds UTC, so ordering, subtraction and comparison all work because every row is on the same timeline. Rendering happens per request against the activated zone, in templates automatically and in Python through `localtime()`. The mistakes cluster at the boundary where a reading enters or leaves the system. Input from a form or an API is a reading — the user typed a wall-clock time — so it must be attached to the zone it was written in before it becomes an instant. Output to a person is a reading, so it must be converted. Output to *another machine* is an instant, and should stay ISO-8601 with an offset. Between those boundaries, resist the urge to hold local times in variables; the moment a naive value exists in your code, some later line will compare it to an aware one and raise, or worse, will not. Two operations deserve specific care. Arithmetic across a DST boundary is not what people expect: adding `timedelta(days=1)` to an aware datetime adds exactly 24 hours of elapsed time, which lands on a different wall-clock reading on the two days a year the day is 23 or 25 hours long. And grouping by day for a report is a *local* question, so truncating in UTC quietly assigns some evening rows to the wrong day for readers in other zones.

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

What we're doing: Accept a wall-clock time from a user, store it as an instant, and produce a daily report that groups by the reader's day rather than by UTC's.

bookings/services.pypython
import zoneinfo
from django.db.models.functions import TruncDate
from django.utils import timezone


def schedule(user, reading, tzname):
    """`reading` is naive: what the user typed. It is not yet a moment."""
    tz = zoneinfo.ZoneInfo(tzname)
    instant = reading.replace(tzinfo=tz)

    # Ambiguous readings resolve by `fold`: 0 = the first occurrence (BST
    # here), 1 = the second (GMT). Guessing silently is how bookings land an
    # hour out twice a year, so ask instead of assuming.
    if instant.utcoffset() != reading.replace(tzinfo=tz, fold=1).utcoffset():
        raise AmbiguousTime(reading, tzname)

    return Booking.objects.create(user=user, starts_at=instant)


def daily_counts(tzname):
    """"Bookings per day" is a LOCAL question. Truncating in UTC assigns
    late-evening rows to the wrong day for anyone east or west of it."""
    return (
        Booking.objects
        .annotate(day=TruncDate("starts_at", tzinfo=zoneinfo.ZoneInfo(tzname)))
        .values("day")
        .annotate(n=Count("id"))
        .order_by("day")
    )


def next_slot(booking):
    # NOT starts_at + timedelta(days=1): that adds 24 hours of elapsed time,
    # which is a different wall-clock reading on the two days a year that
    # are 23 or 25 hours long.
    local = timezone.localtime(booking.starts_at)
    tomorrow = (local + timedelta(days=1)).replace(hour=local.hour, minute=local.minute)
    return tomorrow.astimezone(dt.timezone.utc)
8–9
The promotion from reading to instant. Attaching the zone the user was writing in is the only step that adds the missing information — everything downstream depends on it being right.
12–15
The ambiguity check. During the repeated hour, `fold=0` and `fold=1` give different UTC offsets for the same reading; when they differ, there is genuinely no way to know which the user meant.
19
Grouping is a local question. `TruncDate` takes a `tzinfo`, and passing it is what stops a 23:40 booking in Auckland being counted as the previous day because UTC says so.
31
Calendar arithmetic and elapsed-time arithmetic are different operations. "Same time tomorrow" means converting to local, moving the date, and converting back — adding 24 hours does not survive a clock change.

Why this works: Every stored value is an unambiguous instant, an unresolvable input is rejected rather than guessed at, and both the report and the "same time tomorrow" calculation answer the local question that was actually asked.

Using `datetime.now()` in a project with `USE_TZ` on

Wrong

python
Booking.objects.create(starts_at=datetime.now())
# RuntimeWarning: DateTimeField Booking.starts_at received a naive datetime
# (2026-10-25 01:30:00) while time zone support is active.

Better

python
Booking.objects.create(starts_at=timezone.now())   # aware, UTC

What you see: A `RuntimeWarning` in the logs, and a column where some rows are UTC instants and others are the server's local wall-clock readings — indistinguishable afterwards.

Why: `datetime.now()` returns the server's local wall-clock time with no zone attached. Django warns, then stores it anyway by assuming it was UTC — which is wrong by the server's offset. The rows still look plausible, which is why this survives review: the damage only shows up when you sort mixed rows, or when the server's zone changes, or across a clock change. `timezone.now()` is aware and in UTC, so it is correct on any machine in any zone.

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.

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

Together

python
naive = parse_datetime("2026-10-25 01:30")          # a reading
instant = naive.replace(tzinfo=ZoneInfo("Europe/London"))  # now an instant
Booking.objects.create(starts_at=instant)                  # stored as UTC

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

Together

python
from django.utils import timezone

timezone.localdate(order.placed_at)   # the reader's day, not UTC's day

Remember: 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.

See also: the four settings that turn it on · gettext and the translation workflow · database functions

Advertisement