Opened 70 minutes ago
#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
- Set the language cookie to
zh-hans(visit/zh-hans/or POST tosetlang/). - Manually navigate to
/fr/destination/thailand/pattaya/(the URL now carries thefrprefix, but the cookie still sayszh-hans). - Use the language-switcher form on the page to switch to Russian:
- 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
Resolver404exception raised insidetranslate_urlis 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).