Skip to main content

Configurable locale fallback chains for Django's i18n system

Project description

django-locale-chain

Smart locale fallback chains for Django -- because pt-BR users deserve pt-PT, not English.

The Problem

Django's translation system falls back directly to the project's LANGUAGE_CODE when a regional locale variant is missing. There is no intermediate fallback. This is a long-standing limitation (Django Ticket #28636).

Example: A user's browser sends Accept-Language: pt-BR. Your Django project has pt-PT translations but no pt-BR locale. Django skips pt-PT entirely and shows English (or whatever your LANGUAGE_CODE is).

The same thing happens with es-MX -> es, fr-CA -> fr, de-AT -> de, and every other regional variant.

Your users see English when a perfectly good translation exists in a sibling locale.

The Solution

One middleware. Zero changes to your existing translation code.

django-locale-chain installs gettext fallback chains using Python's native gettext.GNUTranslations.add_fallback mechanism. Missing keys in the primary locale catalogue are resolved from fallback locales before reaching the project's default language. Your existing translation calls just work:

  • {% trans "key" %} in templates
  • gettext("key") and _("key") in Python code
  • ngettext() pluralization
  • All django.utils.translation functions

Installation

pip install django-locale-chain

Quick Start

1. Add the middleware

Add LocaleChainMiddleware to your MIDDLEWARE setting after Django's LocaleMiddleware:

# settings.py

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "django_locale_chain.middleware.LocaleChainMiddleware",  # <-- add this
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

That's it. All 75 default fallback chains are active. A pt-BR user will now see pt-PT translations when pt-BR is not available.

2. (Optional) Configure custom chains via settings

# settings.py

LOCALE_FALLBACK_CHAINS = {
    "pt-BR": ["pt-PT", "pt"],
    "es-MX": ["es-419", "es"],
    "fr-CA": ["fr"],
}

Your custom chains are merged with the defaults. Keys you specify replace the corresponding default chain.

3. (Optional) Configure programmatically

# In your AppConfig.ready() hook
from django_locale_chain import configure

class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self):
        configure()

Configuration Modes

Default (zero config)

Just add the middleware. Uses all 75 built-in fallback chains covering Chinese, Portuguese, Spanish, French, German, Italian, Dutch, English, Arabic, Norwegian, and Malay regional variants.

Django settings

# settings.py

# Custom chains merged with defaults
LOCALE_FALLBACK_CHAINS = {
    "pt-BR": ["pt-PT", "pt"],
    "ja-JP": ["ja"],
}

Programmatic API

from django_locale_chain import configure, reset

# Zero-config -- all 75 default chains
configure()

# Override specific chains while keeping all defaults
configure(overrides={"pt-BR": ["pt"]})

# Full custom map, merged with defaults
configure(fallbacks={"ja-JP": ["ja"]})

# Full custom map, no defaults
configure(fallbacks={"pt-BR": ["pt-PT"]}, merge_defaults=False)

# Restore default behaviour
reset()

API Reference

configure(overrides=None, fallbacks=None, merge_defaults=True)

Activate locale fallback chains programmatically.

Parameter Type Default Description
overrides dict | None None Additional or replacement chains merged on top of defaults. Ignored when fallbacks is provided.
fallbacks dict | None None A complete fallback map. When given, overrides is ignored.
merge_defaults bool True Whether to include the 75 built-in defaults as a base layer

Returns the resolved dict[str, list[str]] fallback map.

reset()

Remove all custom configuration and restore default behaviour. Safe to call multiple times.

LocaleChainMiddleware

Django middleware class. Place after django.middleware.locale.LocaleMiddleware in MIDDLEWARE. Reads configuration from configure(), the LOCALE_FALLBACK_CHAINS setting, or the built-in defaults (in that priority order).

DEFAULT_FALLBACKS

A dict[str, list[str]] containing all 75 built-in fallback chains. Importable from django_locale_chain.fallback_map for inspection or as a base for custom maps.

merge_fallbacks(overrides=None, base=None, merge_defaults=True)

Merge two fallback maps, returning a new dict. Entries in overrides replace same-key entries in base. Neither input is mutated.

Django Settings Reference

Setting Type Description
LOCALE_FALLBACK_CHAINS dict[str, list[str]] Custom fallback chains, merged with built-in defaults

Priority order (highest to lowest):

  1. configure() call (programmatic API)
  2. LOCALE_FALLBACK_CHAINS Django setting
  3. Built-in defaults (zero-config)

Default Fallback Map

Chinese (Traditional)

Locale Fallback Chain
zh-Hant-HK zh-Hant-TW -> zh-Hant -> (LANGUAGE_CODE)
zh-Hant-MO zh-Hant-HK -> zh-Hant-TW -> zh-Hant -> (LANGUAGE_CODE)
zh-Hant-TW zh-Hant -> (LANGUAGE_CODE)

Chinese (Simplified)

Locale Fallback Chain
zh-Hans-SG zh-Hans -> (LANGUAGE_CODE)
zh-Hans-MY zh-Hans -> (LANGUAGE_CODE)

Portuguese

Locale Fallback Chain
pt-BR pt-PT -> pt -> (LANGUAGE_CODE)
pt-PT pt -> (LANGUAGE_CODE)
pt-AO pt-PT -> pt -> (LANGUAGE_CODE)
pt-MZ pt-PT -> pt -> (LANGUAGE_CODE)

Spanish

Locale Fallback Chain
es-419 es -> (LANGUAGE_CODE)
es-MX es-419 -> es -> (LANGUAGE_CODE)
es-AR es-419 -> es -> (LANGUAGE_CODE)
es-CO es-419 -> es -> (LANGUAGE_CODE)
es-CL es-419 -> es -> (LANGUAGE_CODE)
es-PE es-419 -> es -> (LANGUAGE_CODE)
es-VE es-419 -> es -> (LANGUAGE_CODE)
es-EC es-419 -> es -> (LANGUAGE_CODE)
es-GT es-419 -> es -> (LANGUAGE_CODE)
es-CU es-419 -> es -> (LANGUAGE_CODE)
es-BO es-419 -> es -> (LANGUAGE_CODE)
es-DO es-419 -> es -> (LANGUAGE_CODE)
es-HN es-419 -> es -> (LANGUAGE_CODE)
es-PY es-419 -> es -> (LANGUAGE_CODE)
es-SV es-419 -> es -> (LANGUAGE_CODE)
es-NI es-419 -> es -> (LANGUAGE_CODE)
es-CR es-419 -> es -> (LANGUAGE_CODE)
es-PA es-419 -> es -> (LANGUAGE_CODE)
es-UY es-419 -> es -> (LANGUAGE_CODE)
es-PR es-419 -> es -> (LANGUAGE_CODE)

French

Locale Fallback Chain
fr-CA fr -> (LANGUAGE_CODE)
fr-BE fr -> (LANGUAGE_CODE)
fr-CH fr -> (LANGUAGE_CODE)
fr-LU fr -> (LANGUAGE_CODE)
fr-MC fr -> (LANGUAGE_CODE)
fr-SN fr -> (LANGUAGE_CODE)
fr-CI fr -> (LANGUAGE_CODE)
fr-ML fr -> (LANGUAGE_CODE)
fr-CM fr -> (LANGUAGE_CODE)
fr-MG fr -> (LANGUAGE_CODE)
fr-CD fr -> (LANGUAGE_CODE)

German

Locale Fallback Chain
de-AT de -> (LANGUAGE_CODE)
de-CH de -> (LANGUAGE_CODE)
de-LU de -> (LANGUAGE_CODE)
de-LI de -> (LANGUAGE_CODE)

Italian

Locale Fallback Chain
it-CH it -> (LANGUAGE_CODE)

Dutch

Locale Fallback Chain
nl-BE nl -> (LANGUAGE_CODE)

English

Locale Fallback Chain
en-GB en -> (LANGUAGE_CODE)
en-AU en-GB -> en -> (LANGUAGE_CODE)
en-NZ en-AU -> en-GB -> en -> (LANGUAGE_CODE)
en-IN en-GB -> en -> (LANGUAGE_CODE)
en-CA en -> (LANGUAGE_CODE)
en-ZA en-GB -> en -> (LANGUAGE_CODE)
en-IE en-GB -> en -> (LANGUAGE_CODE)
en-SG en-GB -> en -> (LANGUAGE_CODE)

Arabic

Locale Fallback Chain
ar-SA ar -> (LANGUAGE_CODE)
ar-EG ar -> (LANGUAGE_CODE)
ar-AE ar -> (LANGUAGE_CODE)
ar-MA ar -> (LANGUAGE_CODE)
ar-DZ ar -> (LANGUAGE_CODE)
ar-IQ ar -> (LANGUAGE_CODE)
ar-KW ar -> (LANGUAGE_CODE)
ar-QA ar -> (LANGUAGE_CODE)
ar-BH ar -> (LANGUAGE_CODE)
ar-OM ar -> (LANGUAGE_CODE)
ar-JO ar -> (LANGUAGE_CODE)
ar-LB ar -> (LANGUAGE_CODE)
ar-TN ar -> (LANGUAGE_CODE)
ar-LY ar -> (LANGUAGE_CODE)
ar-SD ar -> (LANGUAGE_CODE)
ar-YE ar -> (LANGUAGE_CODE)

Norwegian

Locale Fallback Chain
nb no -> (LANGUAGE_CODE)
nn nb -> no -> (LANGUAGE_CODE)

Malay

Locale Fallback Chain
ms-MY ms -> (LANGUAGE_CODE)
ms-SG ms -> (LANGUAGE_CODE)
ms-BN ms -> (LANGUAGE_CODE)

Example

A working Django project is included in the example/ directory:

cd example && pip install -r requirements.txt && python manage.py runserver 8111

Then test the fallback chain with:

curl -H "Accept-Language: pt-BR" http://localhost:8111/

See example/README.md for full details.

How It Works

  1. Django's LocaleMiddleware runs first and activates the user's preferred language via translation.activate().
  2. LocaleChainMiddleware reads the active language and looks up its fallback chain.
  3. For each fallback locale in the chain, a DjangoTranslation catalogue is loaded and linked via gettext.GNUTranslations.add_fallback().
  4. The original terminal fallback (your LANGUAGE_CODE) is preserved at the end of the chain.
  5. Subsequent gettext() / _() calls walk the chain automatically -- the fallback resolution is invisible to your application code.
  6. Django caches translation catalogues per language per process, so the fallback wiring happens once per language and subsequent requests are virtually free.

FAQ

Is this production-ready? Yes. The library uses Python's native gettext.GNUTranslations.add_fallback mechanism and Django's public DjangoTranslation API. No monkey-patching, no private API access beyond the standard _fallback attribute that is part of Python's gettext protocol.

Performance impact? Negligible. Fallback catalogues are loaded once per language per process. After the initial wiring, gettext() calls walk the fallback chain with zero additional overhead from this library -- it is Python's built-in gettext resolution.

Does it work with .po and .mo files? Yes. This library operates on Django's translation catalogues, which are compiled from .po files into .mo files. Any translation format that Django supports will work.

Can I use a non-English default locale? Yes. The fallback chains are independent of your project's LANGUAGE_CODE. They only control which sibling locales are checked before the default language.

Can I deactivate it? Yes. Call reset() to remove all custom configuration, or simply remove the middleware from your MIDDLEWARE setting.

Does it work with Django REST Framework? Yes. DRF uses Django's translation system under the hood, so fallback chains work automatically.

Minimum Django version? Django 4.2 (LTS). Also tested with Django 5.0 and 5.1.

Contributing

  • Open issues for bugs or feature requests.
  • PRs welcome, especially for adding new locale fallback chains.
  • Run tests with: pytest

License

MIT License - see LICENSE file.

Built by i18nagent.ai

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_locale_chain-1.0.0.tar.gz (20.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_locale_chain-1.0.0-py3-none-any.whl (13.3 kB view details)

Uploaded Python 3

File details

Details for the file django_locale_chain-1.0.0.tar.gz.

File metadata

  • Download URL: django_locale_chain-1.0.0.tar.gz
  • Upload date:
  • Size: 20.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for django_locale_chain-1.0.0.tar.gz
Algorithm Hash digest
SHA256 c4a4a108a676a282585c1696d28c5979789d47c0c2bcc14c929137ab33162477
MD5 644d106cde16a22da78af3456874c7e4
BLAKE2b-256 caa692b7283afa9069eda41a4d8cb9a66226cb7aaf828fc417fb2be5599e35c0

See more details on using hashes here.

File details

Details for the file django_locale_chain-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_locale_chain-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 66a28c8f897899ee1d5563d31ca4e707803ab8f38c7d9f745121a1b1cd5ef998
MD5 d23bdd33bbbee24d4df5339408990063
BLAKE2b-256 337b40f2add034bf5a81e9e284a9e44a419ee293af17c82ffca3bb68373bff64

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page