Chapter 15 · Internationalization and time zones
15.9 Time zones
How django keeps datetimes as instants and turns them into a local clock’s reading: USE_TZ, aware and naive datetimes, the default and the current time zone, django and override, localtime, make_aware, and a local time that the clocks skip or repeat.
An instant is the same everywhere; a clock’s reading of it is not, and where a zone keeps summer time it is not even one to one: an hour of readings never happens on the day the clocks go forward, and one happens twice on the day they go back. So Django keeps instants. With USE_TZ on, a datetime that Django makes, or reads from the database, carries its offset, and becomes a local reading at the edges, where a template writes it out and a form reads one in. The documentation’s reason: a program working in local time meets errors twice a year, when the clocks change.
Aware and naive
A datetime is aware when its datetime gives an offset, and naive when it gives None. django and is_naive test exactly that, and a comment says that they, make_aware and make_naive check nothing about their argument, by design: given None, they fail with an AttributeError.
django is the present: aware in UTC where USE_TZ is true, and where it is false a naive reading of the process’s clock, which is in TIME_ZONE where the settings object could set it (The settings object).
The default and the current time zone
The default time zone is TIME_ZONE’s, "America/ unless a project sets it. get_default_timezone makes a zoneinfo of it on its first call and keeps it, under functools; its comment says it is a function so that the setting is not read at import. A test that overrides TIME_ZONE empties the cache (Overriding settings).
The current time zone is the one a request is shown in. django holds it, an asgiref as the active language’s holder is: a value for each thread, or in asynchronous code for each context (The active language). get_current_timezone gives that value, or the default zone where there is none.
activate sets it: a tzinfo as it is, a string as a zoneinfo, so that an unknown name fails with zoneinfo, and anything else is refused with ValueError, "Invalid timezone: 5" for a 5. deactivate removes it, and the default applies. override, a context manager and a decorator, remembers on entry the value present and activates its zone, or deactivates for None; on exit it puts the remembered value back, or deactivates where there was none. {% timezone %} renders its body inside it. None of them touches the process: a comment says they neither change the environment variable "TZ" nor call the time module’s "tzset", because that is not thread-safe.
get_current_timezone_name and get_default_timezone_name name a zone through _get_timezone_name: by the name it gives without a date, as a fixed offset does, and otherwise as a string, which for a zoneinfo is its key, "Europe/. get_fixed_timezone makes a fixed offset named so, "+0200", from minutes or a datetime. The name is what a Trunc or an Extract gives the backend for its SQL (Transforms and database functions: Transform and Func).
Choosing the zone for a request
Django has no middleware that activates a time zone. The documentation’s reason: a request carries nothing to choose one by, as Accept-Language does a language. Nor does anything deactivate the zone after a response, so a middleware that changes it has to put it back. The harbour office’s takes a zone from a cookie:
# harbour/office/middleware.py
import zoneinfo
from django.utils import timezone
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
zone = zoneinfo.ZoneInfo(request.COOKIES["harbour_timezone"])
except (KeyError, ValueError):
zone = None
with timezone.override(zone):
return self.get_response(request)
A missing cookie and an unknown name are each a KeyError, a malformed name a ValueError, and each gives the default zone. After LocaleMiddleware, in a run of the example with the test client making four requests for the arrivals page on one thread, it writes the first ship:
no cookie Endeavour: March 29, 2026, 11:30 a.m., 1234.5 t
harbour_timezone=Europe/London Endeavour: March 29, 2026, 10:30 a.m., 1234.5 t
no cookie Endeavour: March 29, 2026, 11:30 a.m., 1234.5 t
harbour_timezone=Mars/Olympus Endeavour: March 29, 2026, 11:30 a.m., 1234.5 t
From an instant to a local time
django converts an aware datetime into the current zone, or one it is given, and with no value converts the present. It refuses a naive value with ValueError, which datetime would take to be in the process’s local time; the comment says this keeps that method’s behaviour before Python 3.6. localdate is the result’s date, which depends on the zone: 23:30 UTC on 28 March 2026 is the 29th in Amsterdam. make_naive converts the same way and drops the zone.
A template converts each aware datetime it writes, through template_localtime, unless USE_TZ or the template’s own flag says not to or a filter has converted it already; the tags are in The libraries to load: static, i18n, l10n, tz and cache.
From a local time to an instant
django takes a naive datetime as a reading of the current zone’s clock, or another zone’s, and attaches the zone with datetime, refusing an aware value with ValueError; a comment warns that this “may be wrong around DST changes”.
Twice a year in Amsterdam a reading does not name one instant. On 29 March 2026, at 01:00 UTC, the clock goes from 02:00 to 03:00 and the day has 23 hours: 02:30 never happens, and is imaginary. On 25 October, at 01:00 UTC, it goes from 03:00 back to 02:00 and the day has 25: 02:30 happens twice, and is ambiguous. The replacement refuses neither. The naive value’s datetime decides, as PEP 495 defines it and zoneinfo follows: 0, the default, takes the offset in force before the change, and 1 the offset after.
make_aware gives each an instant by its fold, and a form’s datetime field refuses both.The imaginary reading is the trap: make_aware returned a real instant, 01:30 UTC, that still reads 02:30, and localtime gives it back unchanged: make_aware attached the current zone’s own object, and datetime leaves a value alone when given the very object it carries. The same instant given in UTC is shown as 03:30.
_datetime_ambiguous_or_imaginary finds such readings: the zone gives the value one offset as it is and another with its fold flipped. Django asks it in two places. from_current_timezone, through which a form’s datetime field passes what it read, refuses such a reading with a ValidationError of code "ambiguous_timezone" (The built-in form fields). TimeFormat gives such a value no zone, so the format letters "e", "O", "T", "Z" and "I" write nothing (Writing numbers, dates and durations).
Nothing else asks. A model’s datetime field given a naive value warns and makes it aware in the default zone, not the current one; its comment expects an exception at a change of the clocks, and under zoneinfo none comes, so a naive 02:30 on 29 March becomes 01:30 UTC (The field classes).
Where else the current zone is read
The current zone plays no part in storing a value: a backend whose database keeps no time zone stores an aware datetime in UTC, or in the zone its connection names (Values in and out: adapters, converters and time zones). It matters where a query cuts a datetime into a calendar’s parts: a Trunc or an Extract given no tzinfo uses the current zone, and one written into a migration so is deprecated (Transforms and database functions: Transform and Func). The date views make each boundary of a period midnight in the current zone, for a datetime field (The date views).