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 USE_I18N = True (default) and activate a language (LocaleMiddleware); Django picks that language’s formats.

Format localisation

Show a number or date unformatted (JavaScript, XML, an API field)

{{ value|unlocalize }} or {% localize off %} from {% load l10n %}.

Controlling localisation in templates

Accept 1234,50 in a German form

DecimalField(localize=True) or Meta.localized_fields.

Locale-aware input in forms

Change a locale’s date or separator format

A formats.py per locale found through FORMAT_MODULE_PATH.

Custom format files

Store and compare datetimes safely

USE_TZ = True (default since 5.0): aware datetimes, stored in UTC; timezone.now() for "now".

Naive and aware datetimes

Show a datetime on the visitor’s wall clock

timezone.activate(zoneinfo.ZoneInfo(name)) in middleware; templates convert automatically.

Per-user time zones

Build an aware datetime from user input

timezone.make_aware(naive, zone) or naive.replace(tzinfo=zone); let forms do it for you.

Making a datetime aware, and DST edges

Leave pytz behind

zoneinfo.ZoneInfo replaces pytz.timezone(); drop localize() and normalize().

Migrating from pytz

The settings involved

Setting Default in a new project Effect

LANGUAGE_CODE

"en-us"

Language (and therefore formats) used when no other language has been activated.

USE_I18N

True

Whether translation machinery is on; if it is off, formats still localise, using the formats of LANGUAGE_CODE.

USE_THOUSAND_SEPARATOR

False

Whether numbers are grouped (1 234 567) when output is localised; also affects which grouping input forms accept.

FORMAT_MODULE_PATH

not set

Packages that hold your own per-locale formats.py files.

DATE_FORMAT, DATETIME_FORMAT, SHORT_DATE_FORMAT, DATE_INPUT_FORMATS, …​

Django’s built-in format for the active locale

The format-setting names that locale format files redefine.

TIME_ZONE

"UTC" (the startproject template; the built-in default is "America/Chicago")

The default time zone, used for rendering when no other zone is active.

USE_TZ

True (since 5.0)

Aware datetimes in code, UTC storage and zone conversion in forms and templates.

DATABASES["default"]["TIME_ZONE"]

not set

The zone a database that stores local time (no zone information) uses; see A database that stores local time.

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)
Output
UTC True True False

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],
        )
Output
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

get_format(name, lang=None)

The value of a format setting such as DATE_FORMAT, DECIMAL_SEPARATOR or DATE_INPUT_FORMATS for the active (or given) locale.

date_format(value, format=None), time_format(value, format=None)

Format a date or time; format may be a format-setting name such as "SHORT_DATE_FORMAT".

number_format(value, decimal_pos=None, use_l10n=None, force_grouping=False)

Format a number with the locale’s separators.

localize(value), localize_input(value)

Convert a value to a string for display, or for an input field (which uses a parseable format).

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.

catalog/templates/catalog/book_list.html
{% 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"
Output
<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.

catalog/forms.py
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
config/formats/en/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
config/settings.py
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())
Output
True UTC
UTC UTC
Europe/Paris 2026-10-03 14:00:00+02:00
UTC

The helpers in django.utils.timezone that matter in day-to-day code:

Helper Purpose

now()

Current time: aware UTC when USE_TZ is true, naive local time otherwise (useful for reusable apps that must work with either).

is_aware(value), is_naive(value)

Predicates for the two kinds.

make_aware(value, timezone=None)

Attach a zone to a naive datetime (the current zone when timezone is omitted).

make_naive(value, timezone=None)

Convert an aware datetime to a naive one in the given or current zone.

localtime(value=None, timezone=None), localdate(…​)

Convert to the current zone and return a datetime (or a date).

activate(zone), deactivate(), override(zone)

Select the current time zone for this thread or async context; override is a context manager and decorator.

get_current_timezone(), get_default_timezone() and their *_name() forms

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())
Output
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)
Output
2012-02-21 10:28:45+02:00

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

{% localtime on %} / {% localtime off %}

Turn conversion on or off for a block (ignores USE_TZ inside the block).

{% timezone "Europe/Paris" %} / {% timezone None %}

Set the current zone for a block; None selects the default zone.

{% get_current_timezone as TIME_ZONE %}

Put the name of the current zone in a variable (or enable the tz context processor and use TIME_ZONE).

value|localtime, value|utc, value|timezone:"Asia/Tokyo"

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()
Output
True False
2012-03-03 08:30:00+08:00 2012-03-03

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.

catalog/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):
        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):

config/settings.py (excerpt)
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):

catalog/views.py (excerpt)
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})
catalog/templates/catalog/set_timezone.html
{% 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"
Output
<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))
Output
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:

  1. Upgrade to 4.x first and keep USE_DEPRECATED_PYTZ = True only while you audit; the pytz_deprecation_shim package, written by the author of zoneinfo, helps remove pytz safely. On 5.0 and later neither the setting nor the pytz support exists.

  2. Replace pytz.timezone(name) with zoneinfo.ZoneInfo(name), pytz.utc and django.utils.timezone.utc with datetime.timezone.utc (the Django alias was deprecated in 4.1 and removed in 5.0).

  3. Delete every localize() and normalize(); use make_aware() or replace(tzinfo=…​), and do arithmetic in UTC.

  4. Drop the is_dst argument of make_aware(), QuerySet.datetimes() and the Trunc* functions; it was removed in 5.0, and fold on the datetime now expresses the choice.

  5. Add USE_TZ = True if the project still has False, then fix naive datetimes using the warning described in Storage, queries and naive values.

  6. Convert old fixtures: a naive datetime such as "2011-09-01T13:20:30" becomes aware (+03:00) once time zone support is on; regenerate with loaddata then dumpdata, or add the offset by hand (see Fixtures, Serialization & Initial Data).

  7. Switching USE_TZ from False to True on 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 USE_L10N = True to turn formats on, or False to turn them off

The setting no longer exists; format localisation is always on and follows the active language (4.0 deprecated it and made True the default).

Removed in 5.0

Install pytz, use pytz.timezone(), localize(), normalize()

Use zoneinfo.ZoneInfo; USE_DEPRECATED_PYTZ and pytz support are gone.

zoneinfo default 4.0, pytz removed 5.0

New projects: USE_TZ = False (the default until recently), naive datetimes everywhere

USE_TZ defaults to True; store UTC, use aware datetimes.

Default flipped in 5.0 (generated settings had True since 1.4)

from django.utils.timezone import utc

datetime.timezone.utc from the standard library.

Alias deprecated 4.1, removed 5.0

make_aware(value, is_dst=…​)

make_aware(value, timezone); control ambiguous times with fold on the datetime.

Argument removed 5.0

datetime.now() or datetime.utcnow() in views

timezone.now() (aware, UTC). Python itself deprecated utcnow(), which returns a naive value.

Python 3.12

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

4.0

zoneinfo is the default; USE_DEPRECATED_PYTZ appears as a transition aid; USE_L10N deprecated and defaults to True; django.utils.timezone.utc becomes an alias of datetime.timezone.utc.

4.1

The django.utils.timezone.utc alias is deprecated.

5.0

USE_L10N, USE_DEPRECATED_PYTZ, pytz support, the is_dst arguments and the utc alias are removed; USE_TZ defaults to True.

6.0

Migrations can serialise zoneinfo.ZoneInfo instances (for example a default on a field).

6.1

No change to time zone or format localisation APIs.

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() or utcnow() while USE_TZ is on; use timezone.now().

  • Comparing naive and aware datetimes; the ordering comparison raises TypeError, equality quietly returns False.

  • datetime.date() of an aware datetime without converting to the right zone first; use the date filter, localdate() or astimezone().

  • 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_TZ on a non-PostgreSQL database without converting stored data.

  • Localising machine-readable output (JSON in a <script>, XML, attributes); use unlocalize or {% localize off %}.

  • Expecting USE_THOUSAND_SEPARATOR = False to still accept grouped input in a form.

  • Putting sensitive values in a formats.py; its names are reachable through get_format().

  • Storing a visitor’s zone as an offset (+02:00) instead of an IANA name; offsets do not follow DST.