﻿id	summary	reporter	owner	description	type	status	component	version	severity	resolution	keywords	cc	stage	has_patch	needs_docs	needs_tests	needs_better_patch	easy	ui_ux
37086	`set_language` silently fails when `next` URL prefix differs from active language cookie	Bugy Future		"= 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`)."	Bug	new	Internationalization	6.0	Normal		LocalePrefixPattern, set_language, get_language_from_path(),  i18n	Bugy Future	Unreviewed	1	0	0	0	0	1
