Back to blog

Browser Language Detection: A Django Developer's Guide

2026-05-30 10 min read
Browser Language Detection: A Django Developer's Guide

Meta description: Browser language detection in Django breaks in subtle ways. Fix redirects, respect user choice, and wire detection into your translation pipeline.

You ship LocaleMiddleware, add i18n_patterns, test in English, and call it done. Then a German user lands on your app and gets English. A French user gets redirected to en-us/ because a proxy or crawler sent a weird header. Someone manually switches to Spanish, refreshes, and your app flips them back.

That's browser language detection in production. The bug usually isn't one thing. It's the mismatch between browser preference, server headers, URL prefixes, and explicit user choice.

For teams shipping multilingual Django apps, this matters because the web is still heavily concentrated around a few default languages. As of February 2025, English accounts for over 49.4% of websites, Spanish for 6.0%, and German for 5.6% according to Statista's language distribution data. If your app defaults to English, detection often decides whether users feel welcomed or misrouted on first load.

When Good Localization Goes Wrong

A common failure looks like this. Your app supports en, de, and fr. Django is configured correctly. Translations compile. URLs are prefixed. Yet users still see the wrong language because your app trusted the wrong signal at the wrong time.

One reason is that “preferred language” isn't a single setting. The browser exposes language hints on the client. The request sends Accept-Language on the server. The user may also have picked a language inside your app, which should beat both.

That's why browser language detection needs a policy, not just a helper function. If you need a refresher on the difference between translation and product adaptation, CartBoss has a decent primer on understanding language localization before you wire the logic into your request flow.

Practical rule: Auto-detection should pick a first guess. It should never overrule an explicit user choice.

The rest of the work is mechanical. Read the browser's ordered preferences correctly. Let Django parse headers where that helps. Then store the user's override and stop guessing after that.

Client-Side Detection with navigator.languages

If you want the browser's own ordered language preferences, use navigator.languages. Keep navigator.language as a fallback, not your only input. The practical method is to read the primary locale, then honor the ordered list, match the full tag first like en-US, and only then fall back to the base language like en, as described in this browser detection walkthrough.

A diagram demonstrating browser language detection using the navigator.languages API with a prioritized language list.

Match the full locale before the base language

If your app supports pt-BR and pt-PT, collapsing everything to pt too early gives users the wrong copy, wrong date formats, and sometimes the wrong legal text.

Use a matcher like this:

<script>
  function normalizeLocale(tag) {
    return String(tag || '').trim().replace('_', '-').toLowerCase();
  }

  function findBestLanguage(supported, browserLocales) {
    const supportedMap = new Map(
      supported.map(code => [normalizeLocale(code), code])
    );

    const requested = [];
    for (const locale of browserLocales || []) {
      const normalized = normalizeLocale(locale);
      if (!normalized) continue;
      requested.push(normalized);
    }

    for (const locale of requested) {
      if (supportedMap.has(locale)) {
        return supportedMap.get(locale);
      }
    }

    for (const locale of requested) {
      const base = locale.split('-')[0];
      if (supportedMap.has(base)) {
        return supportedMap.get(base);
      }
    }

    return null;
  }

  const supported = ['en', 'en-us', 'de', 'fr'];
  const browserLocales = navigator.languages?.length
    ? navigator.languages
    : [navigator.language];

  const best = findBestLanguage(supported, browserLocales);

  if (best) {
    document.documentElement.lang = best;
    window.__detectedLanguage = best;
  }
</script>

Where client-side detection helps, and where it hurts

Client-side detection is useful when your frontend owns routing or when you want to pass the browser's ordered list back to Django. It reflects what the user set in the browser UI more directly than a server-only guess.

It also has a real downside. If you render English first and then redirect in JavaScript, users see a flash of the wrong language. That's ugly in apps and worse on landing pages.

A better pattern is:

Approach What it gets right What breaks
JS redirect after render Sees browser preference order Causes flicker
JS sets cookie, server uses it next request Keeps future requests stable First request still needs a fallback
JS only updates UI hints Avoids redirect churn Doesn't solve server-rendered locale selection

If you're doing frontend-heavy locale switching too, keep your language list logic in one place. The patterns in this guide map well to SPA code as well, and the TranslateBot team has a separate post on translation in JavaScript apps.

Don't redirect from JavaScript on every page load. Detect once, persist once, then let the server respect that choice.

Server-Side Detection and Djangos LocaleMiddleware

Django already knows how to parse Accept-Language. That header can look like this:

en-US,en;q=0.9,de;q=0.8

The browser sends an ordered preference list. Server-side, that ordering can include quality values like q=0.9, which rank fallbacks. LocaleMiddleware uses this signal together with URL prefixes, cookies, and your configured languages.

A five-step flowchart illustrating how a web server detects browser language settings to provide localized content.

What Django handles well

Django's built-in middleware is still the right starting point. If you already use LocaleMiddleware in Django's i18n stack, don't rip it out just because you hit one bad redirect.

For debugging, inspect what the request contains:

from django.http import JsonResponse

def language_debug_view(request):
    return JsonResponse({
        "accept_language": request.headers.get("Accept-Language", ""),
        "language_code": getattr(request, "LANGUAGE_CODE", ""),
        "path": request.path,
        "cookie_language": request.COOKIES.get("django_language", ""),
    })

That usually tells you whether the bug is header parsing, URL routing, or your own override logic.

For teams that mix locale, language, and region loosely, it helps to be precise about terminology. TranslateBot has a short explainer on what a locale is, and it's useful when your codebase starts juggling en, en-us, and en-gb.

Why Accept-Language is only a hint

The W3C i18n notes are blunt here. “Most users never set” Accept-Language, and the header often reflects the operating system default rather than an explicit user choice, as noted in the W3C language detection guidance.

That's the part many apps get wrong. They treat Accept-Language as intent. It isn't. It's a useful first guess.

Server-side detection is good at first contact. It's bad at remembering what the user told you five minutes later.

If your app has a language picker, the picker wins. Always.

Building a Production-Ready Django Language Selector

The pattern that holds up is boring, and that's good:

  1. Explicit user choice from cookie or session
  2. Language in the URL like /fr/dashboard/
  3. Accept-Language as a fallback
  4. Project default language if nothing matches

That ordering stops most i18n regressions.

A diagram illustrating the Django language selector architecture, showing how various inputs feed into custom middleware.

The middleware

from django.conf import settings
from django.http import HttpRequest
from django.urls import get_script_prefix
from django.utils import translation
from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import get_language_from_path, get_supported_language_variant

class PreferredLanguageMiddleware(MiddlewareMixin):
    def process_request(self, request: HttpRequest):
        language = self._get_language_from_cookie(request)
        if not language:
            language = self._get_language_from_path(request)
        if not language:
            language = translation.get_language_from_request(
                request, check_path=False
            )
            language = self._normalize_supported(language)

        if not language:
            language = settings.LANGUAGE_CODE

        translation.activate(language)
        request.LANGUAGE_CODE = translation.get_language()

    def process_response(self, request, response):
        language = getattr(request, "LANGUAGE_CODE", None)
        if language:
            translation.deactivate()
        return response

    def _get_language_from_cookie(self, request: HttpRequest):
        value = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
        return self._normalize_supported(value)

    def _get_language_from_path(self, request: HttpRequest):
        path_info = request.path_info
        script_prefix = get_script_prefix()
        if script_prefix and path_info.startswith(script_prefix):
            path_info = path_info[len(script_prefix):]
        value = get_language_from_path(path_info)
        return self._normalize_supported(value)

    def _normalize_supported(self, language_code):
        if not language_code:
            return None
        try:
            return get_supported_language_variant(language_code)
        except LookupError:
            base = language_code.split("-")[0]
            try:
                return get_supported_language_variant(base)
            except LookupError:
                return None

The settings that matter

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "yourproject.middleware.PreferredLanguageMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

LANGUAGE_CODE = "en"

LANGUAGES = [
    ("en", "English"),
    ("de", "German"),
    ("fr", "French"),
]

USE_I18N = True

LANGUAGE_COOKIE_NAME = "django_language"
LOCALE_PATHS = [
    BASE_DIR / "locale",
]

You also need your locale files where Django expects them:

locale/
  de/
    LC_MESSAGES/
      django.po
  fr/
    LC_MESSAGES/
      django.po

Later in the request cycle, your language picker should set the same django_language cookie Django already understands. Don't invent a parallel preference store unless you have a real reason.

Here's the architecture in one glance:

Why this order works

The URL is explicit. The cookie is explicit. The header is not.

If a user visits /fr/, you shouldn't bounce them to /en/ because the browser prefers English. If they selected German in your UI yesterday, don't override that because their laptop image changed.

That's the production rule. Guess once, remember forever, until the user changes it.

Common Pitfalls and Testing Strategies

Most browser language detection bugs show up in edge cases, not in the happy path.

A checklist infographic detailing common pitfalls in browser language detection and recommended strategies for effective testing.

Regional fallbacks break quietly

Symptom: en-GB users get a 404 or land in your default locale, even though you support en.

Fix: Match full locale first, then fall back to the base language. Don't strip the region before checking supported variants.

Test it with curl:

curl -H 'Accept-Language: en-GB,en;q=0.9' http://localhost:8000/
curl -H 'Accept-Language: fr-CA,fr;q=0.9,en;q=0.8' http://localhost:8000/
curl -H 'Accept-Language: de-CH,de;q=0.9,en;q=0.8' http://localhost:8000/

Crawlers don't behave like browsers

Recent analysis notes that many search engine bots historically don't send Accept-Language, while some AI crawlers do, often with en-US,en;q=0.9, which can route them into the wrong locale and skew indexing or retrieval toward English, as described in MERJ's analysis of Accept-Language redirects and crawlers.

That's one reason forced redirects on / are risky.

If a crawler hits /, serve a stable page or a chooser. Don't assume the header is meaningful just because it exists.

Redirect loops are usually self-inflicted

You'll see this when middleware redirects /en/ to /en-us/, some other layer canonicalizes back, and users bounce forever.

Keep these checks in place:

Test like the bug already happened

Manual browser testing still matters here. Change the browser language order, not just the UI language. For parallel account and environment testing in Firefox, proxy isolation helps when you're trying to reproduce locale and session issues across multiple profiles. Sota Proxy has a practical guide on managing Firefox proxy for multi-accounts that fits that workflow.

For backend coverage, add request tests around your precedence rules:

from django.test import RequestFactory, TestCase
from django.utils import translation

from yourproject.middleware import PreferredLanguageMiddleware

class PreferredLanguageMiddlewareTests(TestCase):
    def setUp(self):
        self.factory = RequestFactory()
        self.middleware = PreferredLanguageMiddleware(lambda request: None)

    def test_cookie_beats_accept_language(self):
        request = self.factory.get("/", HTTP_ACCEPT_LANGUAGE="fr,en;q=0.9")
        request.COOKIES["django_language"] = "de"

        self.middleware.process_request(request)

        self.assertEqual(request.LANGUAGE_CODE, "de")
        translation.deactivate()

    def test_accept_language_falls_back_to_base_language(self):
        request = self.factory.get("/", HTTP_ACCEPT_LANGUAGE="fr-CA,fr;q=0.9")
        self.middleware.process_request(request)

        self.assertEqual(request.LANGUAGE_CODE, "fr")
        translation.deactivate()

If you want a broader testing checklist for localization regressions, TranslateBot also has a useful post on localization in testing.

How to Wire Detection into Automated Translation

A common production failure looks like this: language detection works, users arrive with Accept-Language: es, Django falls back to English, and the team spends another sprint tweaking redirects. The redirect is not the problem. Missing translations are.

Detection should feed release work. If Spanish keeps showing up in logs, support queues, or signup traffic, add Spanish to the product and ship it through the same pipeline you use for code. That keeps language support reviewable, testable, and reversible.

The handoff is straightforward:

  1. Add the language to LANGUAGES
  2. Create or update message files with makemessages
  3. Translate missing strings
  4. Compile and deploy
python manage.py makemessages --locale=es
python manage.py compilemessages

I treat browser preference data as demand signals, not as instructions to guess. If the signal is clear, add the locale. If it is noisy, hold the fallback and wait for better evidence. That matches the caution raised in the WebMachineLearning translation API ambiguity thread, where language output is not always cleanly assignable and forcing a choice can create the wrong behavior.

For Django teams, the practical setup is simple. Keep .po files in Git, generate them in CI or release prep, and run translation as a repeatable step instead of editing files by hand. Tools like TranslateBot can handle that translation step from a manage.py command and write back to your locale files, but the tool is secondary. The part that matters is the pipeline.

A minimal flow looks like this:

python manage.py makemessages --locale=es
python manage.py translate --locale=es
python manage.py compilemessages

That is where browser language detection starts paying off. It stops being a dashboard metric and becomes an input to product delivery. New locale support turns into a normal pull request with generated diffs, reviewer checks, compiled messages, and a deploy path your team already trusts.

If your current process still depends on manual translation passes right before release, fix that first. Detection is only useful when the rest of the system can respond to it predictably.

Stop editing .po files manually

TranslateBot automates Django translations with AI. One command, all your languages, pennies per translation.