#37086 new Bug

`set_language` silently fails when `next` URL prefix differs from active language cookie

Reported by: Bugy Future Owned by:
Component: Internationalization Version: 6.0
Severity: Normal Keywords: LocalePrefixPattern, set_language, get_language_from_path(), i18n
Cc: Bugy Future Triage Stage: Unreviewed
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: yes

Description

Bug Report: set_language silently fails when next URL prefix differs from active language cookie

Ticket tracker: https://code.djangoproject.com/newticket
Component: Internationalization (django.views.i18n, django.urls)
Severity: Medium — silent data loss (language switch is a no-op for the user)


Summary

django.views.i18n.set_language silently fails to translate the redirect URL
whenever the language prefix already present in the next parameter differs
from the language currently active in get_language() (i.e. the cookie /
session value). The user is redirected back to the same URL in the old
language, and the language switch appears broken — even though the cookie is
set correctly.


Environment

Django 6.0.1 (reproduced on 4.2 LTS and 5.x as well — see Notes)
Python 3.13.12
Middleware django.middleware.locale.LocaleMiddleware (standard stack)
Setting prefix_default_language = False in i18n_patterns()

Steps to Reproduce

Minimal urls.py

from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include

urlpatterns = [
    path('i18n/', include('django.conf.urls.i18n')),
]

urlpatterns += i18n_patterns(
    path('destination/<slug:country>/<slug:city>/', some_view, name='city_detail'),
    prefix_default_language=False,
)

Settings

LANGUAGE_CODE = 'en'
LANGUAGES = [
    ('en', 'English'),
    ('fr', 'Français'),
    ('ru', 'Русский'),
    ('zh-hans', '简体中文'),
]
MIDDLEWARE = [
    ...
    'django.middleware.locale.LocaleMiddleware',
    ...
]

Reproduce

  1. Set the language cookie to zh-hans (visit /zh-hans/ or POST to setlang/).
  2. Manually navigate to /fr/destination/thailand/pattaya/ (the URL now carries the fr prefix, but the cookie still says zh-hans).
  3. Use the language-switcher form on the page to switch to Russian:
{% csrf_token %}
  1. Submit.

Expected result

Redirect → /ru/destination/thailand/pattaya/ with django_language=ru cookie.

Actual result

Redirect → /fr/destination/thailand/pattaya/ (unchanged URL) with
django_language=ru cookie.

The cookie is updated correctly, but the page URL is not translated.
On the next full page load the user lands on /fr/destination/... with a ru
cookie, which LocaleMiddleware then 302-redirects to /ru/destination/....
The net effect is one extra round-trip and a confusing flicker, but the root
cause is a silent no-op in translate_url.


Root Cause Analysis

The failure chain involves three components.

1. LocalePrefixPattern.language_prefix reads get_language() at call time

# django/urls/resolvers.py : 398
@property
def language_prefix(self):
    language_code = get_language() or settings.LANGUAGE_CODE
    if language_code == settings.LANGUAGE_CODE and not self.prefix_default_language:
        return ""
    else:
        return "%s/" % language_code

The prefix is not derived from the URL being resolved — it is derived from
whatever get_language() returns at the moment resolve() is called.

2. LocalePrefixPattern.match uses that prefix to strip the URL

# django/urls/resolvers.py : 406
def match(self, path):
    language_prefix = self.language_prefix   # e.g. 'zh-hans/'
    if path.startswith(language_prefix):     # '/fr/...' does NOT start with 'zh-hans/'
        return path.removeprefix(language_prefix), (), {}
    return None                              # → resolve() raises Resolver404

3. translate_url calls resolve() without aligning get_language() to the URL's actual prefix

# django/urls/base.py : 181
def translate_url(url, lang_code):
    parsed = urlsplit(url)
    try:
        match = resolve(unquote(parsed.path))   # ← get_language() still = 'zh-hans'
    except Resolver404:
        pass                                    # ← silently swallowed; url returned unchanged
    else:
        ...
        with override(lang_code):               # ← override only happens for reverse(),
            url = reverse(...)                  #   never reached if resolve() failed
    return url                                  # ← returns original url unmodified

Call graph of the failing case

set_language(POST next='/fr/destination/thailand/pattaya/', language='ru')
│
└─ translate_url('/fr/destination/thailand/pattaya/', 'ru')
     │
     └─ resolve('/fr/destination/thailand/pattaya/')
          │
          └─ LocalePrefixPattern.match('/fr/destination/thailand/pattaya/')
               │
               ├─ language_prefix = get_language() → 'zh-hans'   (stale cookie)
               ├─ '/fr/...'.startswith('zh-hans/') → False
               └─ return None  →  Resolver404
          ↑
          silently caught by translate_url; original URL returned unchanged

Why manual address-bar navigation works

When the user types /fr/destination/... directly, the browser makes a GET
request. LocaleMiddleware.process_request calls get_language_from_request,
which checks the URL prefix first (before the cookie), activates fr, and
updates the cookie. On the subsequent page load the cookie and URL prefix are
consistent. The problem only surfaces when a POST to setlang/ is processed
while the cookie and URL prefix are already out of sync.


Proposed Fix

The fix requires a single additional translation.override() call to align
get_language() with the language actually encoded in next_url before
translate_url calls resolve().

get_language_from_path() already exists in Django's public API for exactly
this purpose — extracting the language from a URL path — and is the same
function used by LocaleMiddleware itself.

translate_url already wraps its internal reverse() call in
override(lang_code), so no changes are needed for the target-language phase.

# django/views/i18n.py  —  proposed patch

 from django.utils.http import url_has_allowed_host_and_scheme
+from django.utils.translation import check_for_language, get_language_from_path
+from django.utils import translation
+from urllib.parse import urlsplit

 def set_language(request):
     next_url = request.POST.get("next", request.GET.get("next"))
     if (
         next_url or request.accepts("text/html")
     ) and not url_has_allowed_host_and_scheme(...):
         ...

     response = HttpResponseRedirect(next_url) if next_url else HttpResponse(status=204)

     if request.method == "POST":
         lang_code = request.POST.get(LANGUAGE_QUERY_PARAMETER)
         if lang_code and check_for_language(lang_code):
             if next_url:
-                next_trans = translate_url(next_url, lang_code)
+                # Fix: detect the language prefix already present in next_url
+                # so that LocalePrefixPattern.match() can resolve() it correctly,
+                # regardless of what get_language() currently returns from the cookie.
+                path = urlsplit(next_url).path
+                source_lang = get_language_from_path(path) or settings.LANGUAGE_CODE
+                with translation.override(source_lang):
+                    next_trans = translate_url(next_url, lang_code)
+
                 if next_trans != next_url:
                     response = HttpResponseRedirect(next_trans)
             response.set_cookie(...)
     return response

Why override(source_lang), not override(target_lang)

Using override(target_lang) (the language being switched to) would fix the
case where next_url is a bare, prefix-less path — but would still fail for
any URL that carries a different existing prefix:

next_url = '/fr/destination/paris/'   target = 'ru'

override('ru'):
  language_prefix = 'ru/'
  '/fr/...'.startswith('ru/') → False → Resolver404  ✗

override('fr'):   ← source_lang from get_language_from_path()
  language_prefix = 'fr/'
  '/fr/...'.startswith('fr/') → True → resolve() OK
  then translate_url internally does override('ru') for reverse()  ✓

Correctness across all cases

next_url cookie source_lang result
/fr/destination/paris/ zh-hans fr /ru/destination/paris/
/ru/destination/paris/ en ru /fr/destination/paris/
/zh-hans/destination/paris/ fr zh-hans /destination/paris/
/destination/paris/ zh-hans en /ru/destination/paris/
/ any en /ru/

Workaround (for projects that cannot wait for a patch)

Override the set_language URL before Django's own i18n/ include and point
it at a custom view that applies the fix:

# urls.py
from myapp.views import set_language_fixed

urlpatterns = [
    path('i18n/setlang/', set_language_fixed, name='set_language'),
    path('i18n/', include('django.conf.urls.i18n')),
    ...
]
# myapp/views.py
from urllib.parse import urlsplit
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import translate_url
from django.utils import translation
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import check_for_language, get_language_from_path
from django.views.i18n import LANGUAGE_QUERY_PARAMETER


def set_language_fixed(request):
    next_url = request.POST.get('next', request.GET.get('next'))
    if (
        next_url or request.accepts('text/html')
    ) and not url_has_allowed_host_and_scheme(
        url=next_url,
        allowed_hosts={request.get_host()},
        require_https=request.is_secure(),
    ):
        next_url = request.META.get('HTTP_REFERER')
        if not url_has_allowed_host_and_scheme(
            url=next_url,
            allowed_hosts={request.get_host()},
            require_https=request.is_secure(),
        ):
            next_url = '/'

    response = HttpResponseRedirect(next_url) if next_url else HttpResponse(status=204)

    if request.method == 'POST':
        lang_code = request.POST.get(LANGUAGE_QUERY_PARAMETER)
        if lang_code and check_for_language(lang_code):
            if next_url:
                path = urlsplit(next_url).path
                source_lang = get_language_from_path(path) or settings.LANGUAGE_CODE
                with translation.override(source_lang):
                    next_trans = translate_url(next_url, lang_code)
                if next_trans != next_url:
                    response = HttpResponseRedirect(next_trans)
            response.set_cookie(
                settings.LANGUAGE_COOKIE_NAME,
                lang_code,
                max_age=settings.LANGUAGE_COOKIE_AGE,
                path=settings.LANGUAGE_COOKIE_PATH,
                domain=settings.LANGUAGE_COOKIE_DOMAIN,
                secure=settings.LANGUAGE_COOKIE_SECURE,
                httponly=settings.LANGUAGE_COOKIE_HTTPONLY,
                samesite=settings.LANGUAGE_COOKIE_SAMESITE,
            )

    return response

Notes

  • The bug is present in all Django versions that use LocalePrefixPattern (introduced in Django 2.0). Verified on 4.2 LTS, 5.2, and 6.0.1.
  • The condition that triggers the bug — cookie language ≠ URL prefix language — is common in any multilingual site where users mix manual URL editing with the language switcher UI, or follow external links to a page in a different language than their last visited language.
  • The Resolver404 exception raised inside translate_url is intentionally caught and swallowed (the function contract is "return original URL on failure"). This makes the failure mode silent: no exception reaches the caller, no log entry is produced, the cookie is set, the redirect is issued — but to the wrong URL. This compounding of silent failure makes the bug particularly hard to diagnose in production.
  • The fix is backwards-compatible and adds no new public API surface. get_language_from_path() is already part of Django's public i18n API.
  • A test covering the "cookie language ≠ URL prefix language" scenario does not currently exist in Django's test suite (tests/i18n/test_extraction.py, tests/view_tests/tests/test_i18n.py).

Change History (0)

Note: See TracTickets for help on using tickets.
Back to Top