skip to content

In Django, in what order does LocaleMiddleware look for a request's language, and what happens when nothing matches?

level: middleimportance: must knowfreq 55%

answer

  1. four sources, first match wins
  2. path before storage before browser
  3. prefix only under i18n_patterns
  4. django_language cookie, then Accept-Language

basics

~10 s

LocaleMiddleware tries the URL language prefix (only under i18n_patterns), then the django_language cookie, then the Accept-Language header by q-value, and finally LANGUAGE_CODE. Each candidate must match a language in LANGUAGES.

solid answer

~30 s

`LocaleMiddleware` calls `get_language_from_request()`, which checks the URL prefix like `/fr/` if the root URLconf uses `i18n_patterns`, then the cookie named by `LANGUAGE_COOKIE_NAME` (`django_language`), then each `Accept-Language` entry in q-value order, and falls back to `LANGUAGE_CODE`. A candidate only counts if it matches `LANGUAGES`, with variant fallback such as `de-at` to `de`; an unsupported value just moves on to the next source. The chosen code is activated for the request and exposed as `request.LANGUAGE_CODE`, and the response gets `Content-Language` plus `Vary: Accept-Language` when the language was not taken from the URL. With `prefix_default_language=False`, an unprefixed URL goes straight to `LANGUAGE_CODE`.

code

python · 8 lines
python
from django.http import HttpResponse


def tour_detail(request, slug):
    # Set by LocaleMiddleware after URL prefix -> cookie -> Accept-Language -> LANGUAGE_CODE
    if request.LANGUAGE_CODE == "de":
        return HttpResponse("Tour auf Deutsch")
    return HttpResponse(f"Tour in {request.LANGUAGE_CODE}")

go deeper

for a junior

Recall the four sources in order: URL prefix, django_language cookie, Accept-Language header, LANGUAGE_CODE, and that request.LANGUAGE_CODE holds the result.

for a middle

Explain that only i18n_patterns enables the prefix check, that every candidate is filtered through LANGUAGES with variant fallback, and what Content-Language and Vary do.

for a senior

Diagnose wrong-language reports: the prefix_default_language=False shortcut, stale cookies outranking the header, and caches that ignore Vary on multilingual pages.

for a principal

Weigh explicit URLs against negotiated content for a public site: shareable, cacheable, indexable links versus automatic selection that varies per visitor.

## What LocaleMiddleware is for `django.middleware.locale.LocaleMiddleware` turns Django's translation from *static* (one `LANGUAGE_CODE` for everybody) into *dynamic*: on every request it inspects the request, picks a language, and **activates** it for the code that runs while that request is handled. Everything that translates lazily or at render time (templates, `gettext` calls in views, form error messages, `reverse()` under `i18n_patterns`) then uses that language. ## The discovery order In `process_request` the middleware calls `translation.get_language_from_request()`, which tries the sources in a fixed order and stops at the first one that yields a supported language: 1. **The URL language prefix**, such as the `fr` in `/fr/tours/`. This is checked **only when the root URLconf uses `i18n_patterns()`**; otherwise path prefixes mean nothing. 2. **The language cookie**, named by `LANGUAGE_COOKIE_NAME` (default `django_language`). The `set_language` view writes it; so can your own code. 3. **The `Accept-Language` header**, parsed and sorted by q-value, each entry tried in turn. A `*` entry stops the scan; malformed entries are skipped. 4. **`LANGUAGE_CODE`**, the installation-wide fallback. | Source | Who sets it | Consulted when | |---|---|---| | URL prefix | the link the visitor followed | root URLconf uses `i18n_patterns` | | `django_language` cookie | `set_language` or your view | always | | `Accept-Language` | the browser's language preferences | always | | `LANGUAGE_CODE` | settings | nothing above matched | ## Matching a candidate against LANGUAGES Every candidate, whatever its source, is filtered through the `LANGUAGES` setting, and a catalog must exist for it: - An **exact** match wins: `de` is served as `de`. - A **generic** match comes next: `de-at` is served as `de` when only `de` is listed. - A **sibling variant** is the last try: when neither `fr-fr` nor `fr` is listed but `fr-ca` is, `fr-ca` is used. - A value that matches nothing is ignored and the **next source** is tried; an unsupported cookie never blocks the header from being read. Header and code lengths are capped so an oversized, attacker-controlled header cannot exhaust the parser's cache. ## What the middleware leaves behind - `translation.activate()` has been called, so `translation.get_language()` returns the chosen code for the rest of the request. - `request.LANGUAGE_CODE` holds the same code for views and templates to read. - On the way out, `process_response` sets a `Content-Language` header (unless the view already set one). - It adds `Vary: Accept-Language` unless the language came from a URL prefix under `i18n_patterns`, so shared caches do not hand one visitor's language to another. - Under `i18n_patterns` with the default `prefix_default_language=True`, an unprefixed URL that 404s is **redirected** to the same path under the detected language, and that redirect carries `Vary: Accept-Language, Cookie`. ## A worked example on a tourism site Assume `LANGUAGES` lists `en`, `fr` and `de`, `LANGUAGE_CODE = "en"`, and the root URLconf wraps the tour pages in `i18n_patterns()` with default arguments: | Request | Cookie | `Accept-Language` | Activated | Decided by | |---|---|---|---|---| | `/de/tours/` | `fr` | `en` | `de` | URL prefix | | `/tours/` (then redirected) | `fr` | `de` | `fr` | cookie | | `/tours/` (then redirected) | none | `es, de;q=0.8` | `de` | header, second entry | | `/tours/` (then redirected) | none | `es` | `en` | `LANGUAGE_CODE` | Two details stand out. The Spanish entry is skipped rather than failing the request, because it is not in `LANGUAGES`. And a stale cookie outranks a changed browser preference, which is why "I switched my browser to German and the site is still French" is usually a cookie left by an earlier visit to the switcher. ## Two exceptions interviewers like - **`prefix_default_language=False`.** On an unprefixed URL the middleware activates `LANGUAGE_CODE` directly, **without** reading the cookie or the header, because the unprefixed URL *is* the default language's URL. - **The session is not a source.** Older write-ups list the session between the URL and the cookie. `LocaleMiddleware` stopped reading it in Django 3.0, and `set_language` stopped writing it in 4.0; the cookie is the only stored preference Django consults. ## Where it sits Its position in `MIDDLEWARE` matters too, but that is a question about the middleware stack; here the point is the order of the *sources* inside one middleware.

  • Why does a visitor with a German browser still see English at /tours/ when i18n_patterns uses prefix_default_language=False?
    Because for an unprefixed URL under that option, `LocaleMiddleware` activates `LANGUAGE_CODE` directly and skips the cookie and header. The unprefixed URL is defined as the default language's page. To offer German you link to `/de/tours/`, typically through a switcher that posts to `set_language`.
  • What happens when the django_language cookie holds a code that is not in LANGUAGES?
    Django first tries a supported variant of it, for example a generic `de` for `de-at`. If none matches, the cookie is ignored and the `Accept-Language` header is tried next, then `LANGUAGE_CODE`. An invalid cookie never raises and never blocks the later sources.
  • Why does LocaleMiddleware add Vary: Accept-Language to responses?
    The same URL can render in different languages depending on that header, so a shared cache must key on it. Django skips the header when the language came from a URL prefix, because then the URL alone determines the language.

saying these in an interview costs you the question

  • The cookie beats the URL prefix because it is the user's choice
  • LocaleMiddleware reads the language from the session
  • Only the first Accept-Language entry is ever tried
  • Any language code in the header is activated, listed or not
  • URL prefixes are checked even without i18n_patterns
  • An unsupported cookie value makes Django skip the header too