Localization & Time Zones
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and
PostgreSQL 15 or newer, checked on 2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every listing
was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, a suite of 18 tests
(manage.py test), shell snippets and a curl session against runserver all passed, and the output shown is the real output.
Nothing here needs PostgreSQL or Redis; where a database behaves differently the text says so.
Two different problems hide behind the word "locale". Format localisation (l10n) decides how a number, a date or a time is
written for a reader: 1,234.50 or 1.234,50, 10/03/2026 or 03.10.2026. Time zones decide which instant a stored
datetime denotes and which wall clock it is shown on. They are configured by different settings and fail in different ways: a wrong
number format is ugly, a wrong time zone silently books the meeting an hour early twice a year. The translation of text, the third
member of the family, lives on Internationalization & Translation; the
settings file that switches all of this on is covered on Settings & Configuration.
The examples belong to the Bookshelf project that grows across this section: one app, catalog, with a Book that has a price
and a publication datetime.
In a hurry: what to use for what
| I need to… | Use | Section |
|---|---|---|
Show numbers and dates the way each visitor expects |
Leave |
|
Show a number or date unformatted (JavaScript, XML, an API field) |
|
|
Accept |
|
|
Change a locale’s date or separator format |
A |
|
Store and compare datetimes safely |
|
|
Show a datetime on the visitor’s wall clock |
|
|
Build an aware datetime from user input |
|
|
Leave pytz behind |
|
The settings involved
| Setting | Default in a new project | Effect |
|---|---|---|
|
|
Language (and therefore formats) used when no other language has been activated. |
|
|
Whether translation machinery is on; if it is off, formats still localise, using the formats of |
|
|
Whether numbers are grouped ( |
|
not set |
Packages that hold your own per-locale |
|
Django’s built-in format for the active locale |
The format-setting names that locale format files redefine. |
|
|
The default time zone, used for rendering when no other zone is active. |
|
|
Aware datetimes in code, UTC storage and zone conversion in forms and templates. |
|
not set |
The zone a database that stores local time (no zone information) uses; see A database that stores local time. |
Source: Django: Settings
Printing the active values in manage.py shell of the scratch project confirms what a freshly generated project starts with:
from django.conf import settings
print(settings.TIME_ZONE, settings.USE_TZ, settings.USE_I18N, settings.USE_THOUSAND_SEPARATOR)
UTC True True False
Source: Django: Time zones
Format localisation
Django’s formatting system displays dates, times and numbers in templates using the format of the current locale and handles
localised input in forms. Two visitors on the same page can therefore see different formats, depending on the language that is
active for their request. The current locale is the active language: the one LocaleMiddleware chose from the URL prefix,
cookie or Accept-Language header (see Internationalization & Translation),
or the one a block of code selected with translation.override().
Since Django 5.0 there is no switch: format localisation is always on and follows the active language, which is
LANGUAGE_CODE when translation is off. (The USE_L10N setting was introduced
to turn it off; see Where the books are outdated.) A short shell session shows the same value written for three languages. The formats module is
the public API behind templates and form fields.
import datetime
from decimal import Decimal
from django.utils import formats, translation
value = Decimal("1234567.89")
day = datetime.date(2026, 10, 3)
for lang in ("en", "de", "es"):
with translation.override(lang):
print(
lang,
formats.number_format(value, force_grouping=True),
formats.date_format(day),
formats.date_format(day, "SHORT_DATE_FORMAT"),
formats.get_format("DECIMAL_SEPARATOR"),
formats.get_format("DATE_INPUT_FORMATS")[:2],
)
en 1 234 567.89 3 Oct 2026 10/03/2026 . ['%Y-%m-%d', '%m/%d/%Y']
de 1.234.567,89 3. Oktober 2026 03.10.2026 , ['%d.%m.%Y', '%d.%m.%y']
es 1 234 567,89 3 de octubre de 2026 03/10/2026 , ['%d/%m/%Y', '%d/%m/%y']
(The English 3 Oct 2026 and the narrow-space grouping come from the custom format file shown in Custom format files; with Django’s
own English formats the first column would read Oct. 3, 2026.) The main entry points are:
Function (django.utils.formats) |
Purpose |
|---|---|
|
The value of a format setting such as |
|
Format a date or time; |
|
Format a number with the locale’s separators. |
|
Convert a value to a string for display, or for an input field (which uses a parseable format). |
Source: Django: Format localization
A subtlety that trips up hand-written parsers: Django uses different formats for display than for parsing. The parse formats
(DATE_INPUT_FORMATS and friends) cannot contain %a, %A, %b, %B or %p (weekday names, month names, AM/PM), so a field
displays "3. Oktober 2026" yet will not accept it back. A locale also lists several input formats, which is how a form guesses what
a visitor typed.
Controlling localisation in templates
Django tries to use the locale’s format whenever a value is output in a template. That is wrong for machine-readable output (JSON in
a <script> block, XML, an HTML attribute read by code), and sometimes you want localisation only in selected templates. The l10n
tag library gives fine control: the localize tag (on/off) for a block, and the localize and unlocalize filters for one
value. The intcomma filter of django.contrib.humanize is a separate, locale-independent way to group an integer; it only
needs django.contrib.humanize in INSTALLED_APPS.
{% load l10n tz humanize %}
{% get_current_timezone as TIME_ZONE %}
<p>Time zone: {{ TIME_ZONE }}</p>
<ul>
{% for book in books %}
<li>{{ book.title }} | {{ book.price }} | {{ book.published_at }} | {{ book.published_at|date:"DATE_FORMAT" }}
| raw: {{ book.price|unlocalize }} | {{ book.published_at|timezone:"Asia/Tokyo" }}</li>
{% endfor %}
</ul>
<p>{% localize off %}{{ 1234567.5 }}{% endlocalize %} vs {% localize on %}{{ 1234567.5 }}{% endlocalize %} vs {{ 1234567|intcomma }}</p>
Requesting the page with Accept-Language: de shows the same values localised (the price is 1234.50 in the database):
curl -s http://localhost:52411/books/ -H 'Accept-Language: de' | grep -E "Time zone|Dune|vs"
<p>Time zone: UTC</p>
<li>Dune | 1234,50 | 3. Oktober 2026 12:00 | 3. Oktober 2026
<p>1234567.5 vs 1234567,5 vs 1.234.567</p>
The localised price uses a comma, the unlocalize and localize off values keep the machine format, and intcomma groups the
integer even though USE_THOUSAND_SEPARATOR is off. With the default USE_THOUSAND_SEPARATOR = False a localised number is not
grouped; set it to True to get 1.234.567 from a plain {{ value }}. The test that pins this behaviour:
class FormatTests(TestCase):
def test_localize_tags_and_filters(self):
template = Template(
"{% load l10n %}{{ v }}|{{ v|unlocalize }}|{% localize off %}{{ v }}{% endlocalize %}"
)
with translation.override("de"):
self.assertEqual(template.render(Context({"v": Decimal("1234.5")})), "1234,5|1234.5|1234.5")
@override_settings(USE_THOUSAND_SEPARATOR=True)
def test_thousand_separator(self):
template = Template("{{ v }}")
with translation.override("de"):
self.assertEqual(template.render(Context({"v": 1234567})), "1.234.567")
Locale-aware input in forms
A form field parses what the visitor typed with the formats of the active locale. For numeric fields you opt in with the localize
argument (a model form can do it per field with Meta.localized_fields). Dates and times are localised without the flag: their
fields already try the locale’s input formats.
from django import forms
from .models import Book
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "price", "published_at"]
localized_fields = ["price"]
The behaviour, including the pitfall that a German 1.234,50 is rejected unless USE_THOUSAND_SEPARATOR is on, is pinned by a test:
def test_localized_input_is_parsed_with_locale_formats(self):
class PriceForm(forms.Form):
price = forms.DecimalField(max_digits=8, decimal_places=2, localize=True)
with translation.override("de"):
form = PriceForm({"price": "1234,50"})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data["price"], Decimal("1234.50"))
self.assertFalse(PriceForm({"price": "1.234,50"}).is_valid()) # grouping not accepted by default
with self.settings(USE_THOUSAND_SEPARATOR=True):
self.assertEqual(PriceForm({"price": "1.234,50"}).fields["price"].clean("1.234,50"), Decimal("1234.50"))
with translation.override("en"):
self.assertFalse(PriceForm({"price": "1234,50"}).is_valid())
The forms themselves are covered on Forms and Form
Validation & Validators. For country-specific fields, validators and formats that Django does not ship (postal codes, national
identity numbers), the official documentation points to the separate django-localflavor package.
Custom format files
Django ships format definitions for many locales. When your locale is missing, or you want to override a value, point
FORMAT_MODULE_PATH at one or more packages. Inside each package the files are not placed directly: they live in a directory named
after the locale and are called formats.py.
config/
formats/
__init__.py
en/
__init__.py
formats.py
# Overrides only the English formats; every other name keeps Django's default.
DATE_FORMAT = "j M Y"
THOUSAND_SEPARATOR = " " # narrow no-break space instead of a comma
FORMAT_MODULE_PATH = ["config.formats"]
The test in the suite asserts formats.date_format(day) == "3 Oct 2026" for English and the German page keeps Django’s
3. Oktober 2026, so the override is per locale. Keep these files free of sensitive data: the values are reachable through
get_format(), which the date filter uses. Switzerland (German, French) is a documented limitation: its conventions change with
context (Fr. 23.50 for money, 22,5 m for measurements), which the locale system cannot express, so Django’s Swiss locales use the generic
comma-and-space separators.
Time zones
With time zone support enabled, Django stores datetimes in UTC in the database, uses time-zone-aware datetime objects internally and
converts them to the end user’s zone in forms. Templates use the default time zone unless you activate the visitor’s. Even a site
visible in one zone should store UTC, because of daylight saving time (DST): clocks move twice a year, and code that works in
local time is wrong for an hour at each transition. Use UTC in code, and local time only when talking to people. Time zone support is
on by default and uses zoneinfo, part of the Python standard library; Django requires the tzdata package only on
Windows, where the operating system has no IANA database.
Naive and aware datetimes
A datetime.datetime has a tzinfo attribute. When it is set and describes an offset the datetime is aware; otherwise it is
naive. timezone.is_aware() and timezone.is_naive() tell them apart. With USE_TZ = False Django uses naive datetimes in local
time and you write datetime.datetime.now(). With USE_TZ = True every datetime you create must be aware, and the way to get the
current time is timezone.now(), which returns an aware UTC datetime.
Aware datetimes are instants: converting one to another zone changes how it is written, not what it is. A shell session shows
now(), the default and current zone, an override and a conversion:
import datetime
import zoneinfo
from django.utils import timezone
now = timezone.now()
print(timezone.is_aware(now), now.tzinfo)
print(timezone.get_current_timezone_name(), timezone.get_default_timezone_name())
paris = zoneinfo.ZoneInfo("Europe/Paris")
with timezone.override(paris):
print(timezone.get_current_timezone_name(),
timezone.localtime(datetime.datetime(2026, 10, 3, 12, 0, tzinfo=datetime.timezone.utc)))
print(timezone.get_current_timezone_name())
True UTC
UTC UTC
Europe/Paris 2026-10-03 14:00:00+02:00
UTC
Source: Django: Time zones - Concepts
The helpers in django.utils.timezone that matter in day-to-day code:
| Helper | Purpose |
|---|---|
|
Current time: aware UTC when |
|
Predicates for the two kinds. |
|
Attach a zone to a naive datetime (the current zone when |
|
Convert an aware datetime to a naive one in the given or current zone. |
|
Convert to the current zone and return a datetime (or a date). |
|
Select the current time zone for this thread or async context; |
|
Read the current and the default zone. |
Default and current time zone
The default time zone is the TIME_ZONE setting. The current time zone is the one used for rendering; it is the default until
you call activate(). activate() stores the zone in a context-local, so concurrent requests in threads or ASGI tasks do not see
each other’s choice, and deactivate() restores the default. Django also sets environment variables so that its process runs
in the default zone regardless of USE_TZ; that is a backwards-compatibility aid, not something to rely on: obtain epoch timestamps with
datetime.fromtimestamp(ts, tz=datetime.timezone.utc).
Making a datetime aware, and DST edges
make_aware() and naive.replace(tzinfo=zone) both attach a ZoneInfo. The standard-library warning applies: the tzinfo
argument of the datetime constructor does not work reliably for zones with DST, so prefer replace() or make_aware() over
datetime(…, tzinfo=zone) when you are in doubt, and use UTC wherever you can. Around a DST transition a local time can be
non-existent (clocks jump forward) or ambiguous (clocks go back); fold chooses between the two readings of an ambiguous time.
import datetime
import zoneinfo
from django.utils import timezone
paris = zoneinfo.ZoneInfo("Europe/Paris")
naive = datetime.datetime(2026, 10, 3, 14, 30)
print(timezone.make_aware(naive, paris))
print(timezone.make_naive(timezone.make_aware(naive, paris), paris))
ny = zoneinfo.ZoneInfo("America/New_York")
gap = datetime.datetime(2026, 3, 8, 2, 30) # does not exist: clocks jump from 02:00 to 03:00
print("gap", timezone.make_aware(gap, ny), timezone.make_aware(gap, ny).utcoffset())
amb = datetime.datetime(2026, 11, 1, 1, 30) # happens twice
print("fold0", amb.replace(tzinfo=ny, fold=0).utcoffset(), "fold1", amb.replace(tzinfo=ny, fold=1).utcoffset())
2026-10-03 14:30:00+02:00
2026-10-03 14:30:00
gap 2026-03-08 02:30:00-05:00 -1 day, 19:00:00
fold0 -1 day, 20:00:00 fold1 -1 day, 19:00:00
Read the offsets as -4 h (fold=0, summer time) and -5 h (fold=1, standard time). make_aware() does not raise for the
non-existent 02:30: it silently picks an offset, which is why input that comes from people must go through a form (below) that
reports such values as invalid. The test that checks the gap and the fold compares in UTC, because Python subtracts datetimes that
share a tzinfo object by wall clock and ignores their offsets:
def test_dst_gap_and_fold(self):
gap = datetime.datetime(2026, 3, 8, 2, 30) # does not exist in New York
self.assertEqual(timezone.make_aware(gap, NEW_YORK).utcoffset(), datetime.timedelta(hours=-5))
ambiguous = datetime.datetime(2026, 11, 1, 1, 30)
first = ambiguous.replace(tzinfo=NEW_YORK, fold=0)
second = ambiguous.replace(tzinfo=NEW_YORK, fold=1)
self.assertEqual(second.astimezone(UTC) - first.astimezone(UTC), datetime.timedelta(hours=1))
A common conversion is a string you know is in a given zone. Parse it to a naive datetime and attach the zone:
import zoneinfo
from django.utils.dateparse import parse_datetime
naive = parse_datetime("2012-02-21 10:28:45")
aware = naive.replace(tzinfo=zoneinfo.ZoneInfo("Europe/Helsinki"))
print(aware)
2012-02-21 10:28:45+02:00
Source: Django: Time zones - FAQ, Usage
Storage, queries and naive values
On PostgreSQL a datetime column is timestamp with time zone: values are converted to UTC on storage and to the connection’s
zone on retrieval, which is why you can switch USE_TZ freely there. Other backends store datetimes without zone information, in
UTC when USE_TZ is true. The test saves 23:30 New York time and reads the raw SQLite column back:
def test_stored_in_utc_and_shown_in_current_zone(self):
stored = datetime.datetime(2026, 10, 3, 23, 30, tzinfo=NEW_YORK) # 03:30 UTC next day
book = Book.objects.create(title="Dune", price=Decimal("9.99"), published_at=stored)
with connection.cursor() as cursor:
cursor.execute("SELECT published_at FROM catalog_book WHERE id = %s", [book.pk])
self.assertEqual(cursor.fetchone()[0], datetime.datetime(2026, 10, 4, 3, 30)) # naive, in UTC
template = Template("{% load tz %}{{ b.published_at }}|{{ b.published_at|timezone:'Asia/Tokyo' }}")
with timezone.override(PARIS):
book.refresh_from_db()
text = template.render(Context({"b": book}))
self.assertTrue(text.startswith("Oct. 4, 2026, 5:30 a.m."), text)
self.assertTrue(text.endswith("12:30 p.m."), text)
Date-based queries respect the current zone, which matters when "the same day" differs between zones. published_at__date and
TruncDate convert to the current time zone before cutting the date (TruncDate also accepts an explicit tzinfo):
@override_settings(TIME_ZONE="Europe/Paris")
def test_date_lookup_and_truncdate_use_current_zone(self):
Book.objects.create(title="Late", price=Decimal("1"),
published_at=datetime.datetime(2026, 10, 3, 23, 30, tzinfo=UTC)) # Oct 4 in Paris
self.assertEqual(Book.objects.filter(published_at__date=datetime.date(2026, 10, 4)).count(), 1)
days = Book.objects.annotate(day=TruncDate("published_at")).values("day").annotate(n=Count("pk"))
self.assertEqual(list(days), [{"day": datetime.date(2026, 10, 4), "n": 1}])
with timezone.override(datetime.timezone.utc):
self.assertEqual(Book.objects.filter(published_at__date=datetime.date(2026, 10, 3)).count(), 1)
If code hands the database layer a naive datetime while USE_TZ is on, Django makes it aware in the default zone and emits a
RuntimeWarning (DateTimeField Book.published_at received a naive datetime … while time zone support is active). Treat the
warning as a to-do list: during a migration you can turn it into an exception that carries a traceback.
import warnings
warnings.filterwarnings(
"error",
r"DateTimeField .* received a naive datetime",
RuntimeWarning,
r"django\.db\.models\.fields",
)
The suite confirms the warning and the default-zone interpretation:
def test_naive_datetime_warns_and_uses_default_zone(self):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
book = Book.objects.create(title="Naive", price=Decimal("1"),
published_at=datetime.datetime(2026, 1, 1, 12, 0))
self.assertTrue(any("received a naive datetime" in str(w.message) for w in caught))
book.refresh_from_db()
self.assertEqual(book.published_at, datetime.datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
Two further rules from the official FAQ. First, comparing a naive with an aware datetime raises
TypeError: can’t compare offset-naive and offset-aware datetimes; the fix is almost always to make your own value aware, not to
make Django’s naive. Second, datetime.time objects stay naive: Django raises an exception if you save an aware time, because a
zone without a date is meaningless.
def test_naive_aware_comparison_error(self):
aware = timezone.now()
naive = timezone.make_naive(aware)
with self.assertRaisesMessage(TypeError, "can't compare offset-naive and offset-aware datetimes"):
naive == aware or naive < aware
(The naive == aware comparison alone returns False without raising; the ordering comparison is what raises, which is why the
assertion above uses < as well.)
A database that stores local time
If Django must read a database that is not managed by it and stores local wall-clock times, set the TIME_ZONE option of that entry
in DATABASES: Django then interprets and writes the values in that zone. On MySQL, named zones in queries need the
time zone definition tables loaded (the MySQL notes in the official documentation explain how); if you see the error
"Are time zone definitions for your database installed?", that is the cause.
DATABASES = {
"legacy": {
"ENGINE": "django.db.backends.mysql",
"NAME": "legacy",
"TIME_ZONE": "Europe/Madrid",
},
}
(This listing is configuration only; the scratch project has no MySQL server, so it was checked against the settings reference and not run.)
Time zones in forms and templates
With time zone support on, Django interprets datetimes typed into forms in the current zone and returns aware datetimes in
cleaned_data. Times that do not exist or are ambiguous because of a DST transition are reported as invalid, rather than guessed:
def test_form_interprets_input_in_current_zone(self):
with timezone.override(PARIS):
form = BookForm({"title": "Dune", "price": "10.00", "published_at": "2026-10-03 14:30"})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data["published_at"], datetime.datetime(2026, 10, 3, 12, 30, tzinfo=UTC))
def test_form_rejects_nonexistent_local_time(self):
with timezone.override(NEW_YORK):
form = BookForm({"title": "Dune", "price": "10.00", "published_at": "2026-03-08 02:30"})
self.assertFalse(form.is_valid())
For the non-existent 2026-03-08 02:30 in New York the printed error is 2026-03-08 02:30:00 couldn’t be interpreted in time zone
America/New_York; it may be ambiguous or it may not exist. The same message appears for the ambiguous 2026-11-01 01:30: a
form field cannot tell which of the two readings the person meant.
In templates, aware datetimes are converted to the current zone when rendered, very much like format localisation. Naive datetimes are
not converted (they could be ambiguous, and your code should not produce them). The tz tag library gives control over the conversion,
because converting is wrong for output meant for computers:
| Tag or filter | Effect |
|---|---|
|
Turn conversion on or off for a block (ignores |
|
Set the current zone for a block; |
|
Put the name of the current zone in a variable (or enable the |
|
Force conversion of one value to the current zone, to UTC or to a named zone. These filters accept naive values, assuming the default zone. |
The date filter converts a datetime to the current zone before formatting. That is the safe way to show only the date; the
shell-level value.date() does not convert, and the same instant has different dates in different zones. Compare a Paris instant
with its New York reading, and convert to the zone you mean before taking .date():
import datetime
import zoneinfo
from django.utils import timezone
paris_tz = zoneinfo.ZoneInfo("Europe/Paris")
new_york_tz = zoneinfo.ZoneInfo("America/New_York")
paris = datetime.datetime(2012, 3, 3, 1, 30, tzinfo=paris_tz)
new_york = paris.astimezone(new_york_tz)
print(paris == new_york, paris.date() == new_york.date())
timezone.activate(zoneinfo.ZoneInfo("Asia/Singapore"))
local = paris.astimezone(timezone.get_current_timezone())
print(local, local.date())
timezone.deactivate()
True False
2012-03-03 08:30:00+08:00 2012-03-03
Source: Django: Time zones - FAQ, Usage
Per-user time zones
Django cannot detect the visitor’s time zone: unlike translation, there is no Accept-Language equivalent. You decide where the
zone comes from. Most sites ask the user and keep the answer in the profile (or, for anonymous visitors, in the session or a
cookie); an anonymous visitor otherwise gets the zone of the main audience, or UTC. zoneinfo.available_timezones() returns the set of valid IANA
keys on the current machine, which is the right list to validate against and to build a "region to zone" choice from.
The official example stores the zone in the session. Three small pieces cooperate: a middleware that activates the stored zone for every request, a view that sets it, and a form. The middleware below adds one guard the official example skips, an unknown key falls back to the default instead of raising.
import zoneinfo
from django.utils import timezone
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tzname = request.session.get("django_timezone")
if tzname:
try:
timezone.activate(zoneinfo.ZoneInfo(tzname))
except zoneinfo.ZoneInfoNotFoundError:
timezone.deactivate()
else:
timezone.deactivate()
return self.get_response(request)
It must sit after SessionMiddleware (it reads the session). Add it, plus the tz context processor if you want TIME_ZONE in
every template, to the settings (see Middleware for ordering rules):
MIDDLEWARE = [
# ...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
# ...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"catalog.middleware.TimezoneMiddleware",
# ...
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.tz",
# ...
],
},
},
]
The view accepts only names that zoneinfo knows. A state-changing request is a POST with the CSRF token (see
Security):
import zoneinfo
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods
COMMON_TIMEZONES = {
"London": "Europe/London",
"Paris": "Europe/Paris",
"New York": "America/New_York",
}
@require_http_methods(["GET", "POST"])
def set_timezone(request):
if request.method == "POST":
tzname = request.POST.get("timezone", "")
if tzname in zoneinfo.available_timezones():
request.session["django_timezone"] = tzname
return redirect("book-list")
return render(request, "catalog/set_timezone.html", {"timezones": COMMON_TIMEZONES})
{% load tz %}
{% get_current_timezone as TIME_ZONE %}
<form action="{% url 'set-timezone' %}" method="post">
{% csrf_token %}
<label for="timezone">Time zone:</label>
<select name="timezone" id="timezone">
{% for city, tz in timezones.items %}
<option value="{{ tz }}"{% if tz == TIME_ZONE %} selected{% endif %}>{{ city }}</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
Trying it with curl and a cookie jar (the page shown for the same stored instant, 12:00 UTC):
J=$(mktemp)
curl -s -c $J -b $J http://localhost:52411/books/ -H 'Accept-Language: en' | grep "Time zone"
curl -s -c $J -b $J http://localhost:52411/timezone/ -o /dev/null # sets the CSRF cookie
T=$(grep csrftoken $J | awk '{print $7}')
curl -s -c $J -b $J -o /dev/null -w "%{http_code}\n" -X POST -H "X-CSRFToken: $T" \
-d timezone=America/New_York http://localhost:52411/timezone/
curl -s -c $J -b $J http://localhost:52411/books/ -H 'Accept-Language: en' | grep -E "Time zone|Dune"
<p>Time zone: UTC</p>
302
<p>Time zone: America/New_York</p>
<li>Dune | 1234.50 | Oct. 3, 2026, 8 a.m. | 3 Oct 2026
The tests pin the same behaviour, including that an unknown zone is ignored and that the choice does not leak into another
visitor’s request, because activate() is context-local (it uses asgiref.local.Local, so it is correct under threads and ASGI):
class PerUserZoneTests(TestCase):
def test_session_zone_changes_rendering(self):
self.assertContains(self.client.get("/books/"), "Time zone: UTC")
response = self.client.post("/timezone/", {"timezone": "America/New_York"})
self.assertRedirects(response, "/books/")
page = self.client.get("/books/")
self.assertContains(page, "Time zone: America/New_York")
self.assertContains(page, "Oct. 3, 2026, 8 a.m.")
def test_unknown_zone_is_ignored(self):
self.client.post("/timezone/", {"timezone": "Mars/Olympus"})
self.assertContains(self.client.get("/books/"), "Time zone: UTC")
For logged-in users, the same middleware can read request.user.profile.timezone (a CharField validated against
zoneinfo.available_timezones(), see Custom User Model & Backends) instead of the session.
Two practical notes. A browser can report its zone with Intl.DateTimeFormat().resolvedOptions().timeZone; use it to pre-select a
default in the form, not to silently trust. And what you activate in a view is only for rendering: store and compare in UTC, and compute
"today" for a person with timezone.localdate() after activating their zone.
Testing time zone code
Use timezone.override() for a block, @override_settings(TIME_ZONE=…) for the default zone (as the TruncDate test above does),
and construct datetimes with an explicit tzinfo. Freezing the clock is separate: patch timezone.now, or use a library, but keep
the objects aware. Once a project has finished its migration, add the warnings.filterwarnings("error", …) call from Storage, queries and naive values to the test
settings, so that a naive datetime fails the build instead of only logging.
Migrating from pytz
pytz was the time zone library of Django 1.x to 3.x. Django 3.2 allowed non-pytz zones, 4.0 made zoneinfo the default and
deprecated pytz, and 5.0 removed support altogether. The two libraries are not equivalent: pytz zones needed localize() to attach
and normalize() after arithmetic, while a ZoneInfo is attached with replace(tzinfo=…) or make_aware() and needs no
normalize(). Running both side by side (pytz installed only into a temporary directory for this comparison) shows the differences:
import datetime
import sys
import zoneinfo
sys.path.insert(0, "pytz-only")
import pytz # only to show the old API; Django 5.0+ no longer accepts pytz zones
naive = datetime.datetime(2026, 3, 29, 1, 30)
# Before (Django 3.x): localize() and normalize()
old = pytz.timezone("Europe/Paris").localize(naive)
print("pytz localize:", old)
print("pytz normalize:", pytz.timezone("Europe/Paris").normalize(old + datetime.timedelta(hours=1)))
print("pytz tzinfo= pitfall:", datetime.datetime(2026, 3, 29, 1, 30, tzinfo=pytz.timezone("Europe/Paris")))
# After: zoneinfo attaches with replace() and arithmetic needs no normalize()
paris = zoneinfo.ZoneInfo("Europe/Paris")
new = naive.replace(tzinfo=paris)
print("zoneinfo replace:", new)
later = (new.astimezone(datetime.timezone.utc) + datetime.timedelta(hours=1)).astimezone(paris)
print("zoneinfo UTC arithmetic:", later)
print("zoneinfo wall-clock arithmetic:", new + datetime.timedelta(hours=1))
pytz localize: 2026-03-29 01:30:00+01:00
pytz normalize: 2026-03-29 03:30:00+02:00
pytz tzinfo= pitfall: 2026-03-29 01:30:00+00:09
zoneinfo replace: 2026-03-29 01:30:00+01:00
zoneinfo UTC arithmetic: 2026-03-29 03:30:00+02:00
zoneinfo wall-clock arithmetic: 2026-03-29 02:30:00+01:00
Three things to read in that output. The old tzinfo=pytz.timezone(…) pitfall produced the zone’s historical LMT offset
(+00:09), which is why pytz needed localize(). zoneinfo fixes that, but its wall-clock arithmetic does not normalise: adding
an hour to 01:30 on the night the clocks jump produced the non-existent 02:30+01:00. Do arithmetic in UTC and convert back, as
the third zoneinfo line does. Third, nothing in Django’s own API needs normalize() any more.
A migration checklist for a project older than Django 5.0:
-
Upgrade to 4.x first and keep
USE_DEPRECATED_PYTZ = Trueonly while you audit; thepytz_deprecation_shimpackage, written by the author ofzoneinfo, helps remove pytz safely. On 5.0 and later neither the setting nor the pytz support exists. -
Replace
pytz.timezone(name)withzoneinfo.ZoneInfo(name),pytz.utcanddjango.utils.timezone.utcwithdatetime.timezone.utc(the Django alias was deprecated in 4.1 and removed in 5.0). -
Delete every
localize()andnormalize(); usemake_aware()orreplace(tzinfo=…), and do arithmetic in UTC. -
Drop the
is_dstargument ofmake_aware(),QuerySet.datetimes()and theTrunc*functions; it was removed in 5.0, andfoldon the datetime now expresses the choice. -
Add
USE_TZ = Trueif the project still hasFalse, then fix naive datetimes using the warning described in Storage, queries and naive values. -
Convert old fixtures: a naive datetime such as
"2011-09-01T13:20:30"becomes aware (+03:00) once time zone support is on; regenerate withloaddatathendumpdata, or add the offset by hand (see Fixtures, Serialization & Initial Data). -
Switching
USE_TZfromFalsetoTrueon a database other than PostgreSQL requires converting stored local times to UTC, which is not deterministic if your local time has DST. PostgreSQL needs no data conversion.
Where the books are outdated
The books consulted for this section were written for Django 1.11–2.1; the settings and APIs below changed after them.
| Older advice | Today | Changed in |
|---|---|---|
Set |
The setting no longer exists; format localisation is always on and follows the active language (4.0 deprecated it and made |
Removed in 5.0 |
Install |
Use |
|
New projects: |
|
Default flipped in 5.0 (generated settings had |
|
|
Alias deprecated 4.1, removed 5.0 |
|
|
Argument removed 5.0 |
|
|
Python 3.12 |
Source: Django 5.0 release notes
Beyond that, books of the 1.x and 2.x era often mix time zone and language settings in the same breath. Keep them apart: language
(LANGUAGE_CODE, LocaleMiddleware) selects formats and translations per request; the time zone is its own piece of per-request state with
no HTTP header to derive it from.
What changed recently
| Version | Change |
|---|---|
3.2 |
Zones other than pytz are accepted; the first step toward |
4.0 |
|
4.1 |
The |
5.0 |
|
6.0 |
Migrations can serialise |
6.1 |
No change to time zone or format localisation APIs. |
Source: Django 6.0 release notes
Sync and async behaviour
activate() is context-local, so the same code works in sync and async views; it is the middleware that sets the zone per request,
whether the stack runs under WSGI or ASGI. In an async view, timezone.now(), conversions and the tz filters are plain
CPU-bound functions and need no sync_to_async. Remember that a zone activated in a background thread or a Celery task is not
inherited from the request: pass the zone name (or the user id) to the task and activate it there if the task renders text.
Pitfalls checklist
-
Creating datetimes with
datetime.datetime.now()orutcnow()whileUSE_TZis on; usetimezone.now(). -
Comparing naive and aware datetimes; the ordering comparison raises
TypeError, equality quietly returnsFalse. -
datetime.date()of an aware datetime without converting to the right zone first; use thedatefilter,localdate()orastimezone(). -
Wall-clock arithmetic across a DST change (
+ timedelta(hours=1)on a zoned value); compute in UTC. -
Accepting any string as a zone name; validate against
zoneinfo.available_timezones(). -
Switching
USE_TZon a non-PostgreSQL database without converting stored data. -
Localising machine-readable output (JSON in a
<script>, XML, attributes); useunlocalizeor{% localize off %}. -
Expecting
USE_THOUSAND_SEPARATOR = Falseto still accept grouped input in a form. -
Putting sensitive values in a
formats.py; its names are reachable throughget_format(). -
Storing a visitor’s zone as an offset (
+02:00) instead of an IANA name; offsets do not follow DST.