[Django] #37086: `set_language` silently fails when `next` URL prefix differs from active language cookie

4 views
Skip to first unread message

Django

unread,
May 6, 2026, 4:49:58 AM (4 days ago) May 6
to django-...@googlegroups.com
#37086: `set_language` silently fails when `next` URL prefix differs from active
language cookie
-------------------------------------+-------------------------------------
Reporter: Bugy Future | Type: Bug
Status: new | Component:
| Internationalization
Version: 6.0 | Severity: Normal
Keywords: | Triage Stage:
LocalePrefixPattern, | Unreviewed
set_language, |
get_language_from_path(), i18n |
Has patch: 1 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 1
-------------------------------------+-------------------------------------
= 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` ===

{{{
#!python
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 ===

{{{
#!python
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:

{{{
#!html
<form action="/i18n/setlang/" method="post">
{% csrf_token %}
<input type="hidden" name="next"
value="/fr/destination/thailand/pattaya/">
<select name="language">
<option value="ru">Русский</option>
</select>
</form>
}}}

4. 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 ===

{{{
#!python
# 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 ===

{{{
#!python
# 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 ===

{{{
#!python
# 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.

{{{
#!diff
# 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:

{{{
#!python
# 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')),
...
]
}}}

{{{
#!python
# 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`).
--
Ticket URL: <https://code.djangoproject.com/ticket/37086>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

Django

unread,
May 6, 2026, 3:45:00 PM (3 days ago) May 6
to django-...@googlegroups.com
#37086: `set_language` silently fails when `next` URL prefix differs from active
language cookie
-------------------------------------+-------------------------------------
Reporter: Bugy Future | Owner: Jason
| Judkins
Type: Bug | Status: assigned
Component: | Version: 6.0
Internationalization |
Severity: Normal | Resolution:
Keywords: | Triage Stage: Accepted
LocalePrefixPattern, |
set_language, |
get_language_from_path(), i18n |
Has patch: 1 | Needs documentation: 0
Needs tests: 1 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 1
-------------------------------------+-------------------------------------
Changes (by Jason Judkins):

* cc: Jason Judkins (added)
* needs_tests: 0 => 1
* owner: (none) => Jason Judkins
* stage: Unreviewed => Accepted
* status: new => assigned

Comment:

Reproduced on Django 6.05 / Python 3.12 with the reporter's minimal setup.

Confirmed translate_url returns the original URL unchanged because
LocalePrefixPattern.match rejects /fr/... while get_language() returns the
cookie value zh-hans.

[[Image()]]

Worth discussing whether the fix belongs in translate_url itself rather
than set_language — the function's contract suggests it should derive the
source language from the URL it's translating rather than relying on
caller-set context."
--
Ticket URL: <https://code.djangoproject.com/ticket/37086#comment:1>

Django

unread,
May 6, 2026, 3:45:34 PM (3 days ago) May 6
to django-...@googlegroups.com
#37086: `set_language` silently fails when `next` URL prefix differs from active
language cookie
-------------------------------------+-------------------------------------
Reporter: Bugy Future | Owner: Jason
| Judkins
Type: Bug | Status: assigned
Component: | Version: 6.0
Internationalization |
Severity: Normal | Resolution:
Keywords: | Triage Stage: Accepted
LocalePrefixPattern, |
set_language, |
get_language_from_path(), i18n |
Has patch: 1 | Needs documentation: 0
Needs tests: 1 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 1
-------------------------------------+-------------------------------------
Changes (by Jason Judkins):

* Attachment "Screenshot from 2026-05-06 15-41-37.png" added.

Django

unread,
May 6, 2026, 3:45:45 PM (3 days ago) May 6
to django-...@googlegroups.com
#37086: `set_language` silently fails when `next` URL prefix differs from active
language cookie
-------------------------------------+-------------------------------------
Reporter: Bugy Future | Owner: Jason
| Judkins
Type: Bug | Status: assigned
Component: | Version: 6.0
Internationalization |
Severity: Normal | Resolution:
Keywords: | Triage Stage: Accepted
LocalePrefixPattern, |
set_language, |
get_language_from_path(), i18n |
Has patch: 1 | Needs documentation: 0
Needs tests: 1 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 1
-------------------------------------+-------------------------------------
Changes (by Jason Judkins):

* Attachment "Screenshot from 2026-05-06 15-40-56.png" added.

Django

unread,
May 6, 2026, 9:39:17 PM (3 days ago) May 6
to django-...@googlegroups.com
#37086: `set_language` silently fails when `next` URL prefix differs from active
language cookie
-------------------------------------+-------------------------------------
Reporter: Bugy Future | Owner: Jason
| Judkins
Type: Bug | Status: assigned
Component: | Version: 6.0
Internationalization |
Severity: Normal | Resolution:
Keywords: | Triage Stage: Accepted
LocalePrefixPattern, |
set_language, |
get_language_from_path(), i18n |
Has patch: 1 | Needs documentation: 0
Needs tests: 1 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 1
-------------------------------------+-------------------------------------
Comment (by Jason Judkins):

https://github.com/django/django/pull/21240
--
Ticket URL: <https://code.djangoproject.com/ticket/37086#comment:2>

Django

unread,
3:26 AM (14 hours ago) 3:26 AM
to django-...@googlegroups.com
#37086: `set_language` silently fails when `next` URL prefix differs from active
language cookie
-------------------------------------+-------------------------------------
Reporter: Bugy Future | Owner: Jason
| Judkins
Type: Bug | Status: closed
Component: | Version: 6.0
Internationalization |
Severity: Normal | Resolution: duplicate
Keywords: | Triage Stage: Accepted
LocalePrefixPattern, |
set_language, |
get_language_from_path(), i18n |
Has patch: 1 | Needs documentation: 0
Needs tests: 1 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 1
-------------------------------------+-------------------------------------
Changes (by JaeHyuckSa):

* resolution: => duplicate
* status: assigned => closed

Comment:

Looking at this again, #35034 looks very close to this issue.

When the language cookie and URL prefix are out of sync, `translate_url()`
tries to resolve the URL using the request's current language. That fails,
and `set_language()` ends up redirecting back to the original URL.

Since #35034 was already closed as a duplicate of #28567, I think this
should probably be closed the same way. #37086 has a clearer repro, and
[https://github.com/django/django/pull/21240PR 21240] is already open, but
it looks like the same root cause. If this is c losed as a duplicate,
[https://github.com/django/django/pull/21240PR 21240] should probably
reference #28567 instead.
--
Ticket URL: <https://code.djangoproject.com/ticket/37086#comment:3>
Reply all
Reply to author
Forward
0 new messages