Table of Contents
Table of Contents
- Python i18n: From gettext to Modern Translation Workflows
- Python i18n Options at a Glance
- gettext: Python's Built-In i18n
- How gettext Works
- Basic gettext Usage
- Marking Strings for Extraction
- Babel: The Comprehensive Python i18n Library
- Number and Currency Formatting
- Date and Time Formatting
- Managing PO Files with Babel
- Plural Forms in Babel/gettext
- Django i18n
- Django i18n Setup
- Django Translation in Python Code
- Django Template Translation
- Django URL i18n Patterns
- Flask i18n with Flask-Babel
- Modern Approach: Fluent for Python
- Integrating with CI/CD
- Locale Detection Best Practices
- Take your app global with better-i18n
Python i18n: From gettext to Modern Translation Workflows
Python has one of the richest i18n ecosystems of any programming language, powered by decades of investment from large multilingual applications—particularly Django's globally deployed web framework and the widespread use of Python in scientific computing and data tools that must serve international communities.
This guide covers the full Python i18n landscape: from the classic gettext approach built into the standard library, through Babel's comprehensive locale data library, to framework-specific solutions for Django and Flask, and modern approaches using Fluent.
Python i18n Options at a Glance
| Library / Approach | Best For |
|---|---|
| gettext (stdlib) | Simple scripts and applications |
| Babel | Number, date, currency formatting; PO file management |
| Django i18n | Django web applications |
| Flask-Babel | Flask web applications |
| fluent.runtime | Applications using Mozilla's Fluent format |
| Babel + gettext | Most production Python web services |
gettext: Python's Built-In i18n
Python's standard library includes gettext, which implements the GNU gettext internationalization API. It's the foundation that many higher-level libraries build on.
How gettext Works
gettext uses .po (Portable Object) files for translation storage and compiles them to binary .mo (Machine Object) files for runtime loading.
Workflow:
- Mark strings in source code with
_()orgettext() - Extract marked strings with
xgettextorpybabelto a.pottemplate - Create locale-specific
.pofiles from the template - Translators fill in the translations
- Compile
.pofiles to.mofiles - Load at runtime based on user locale
Basic gettext Usage
import gettext
import locale
def setup_i18n(lang: str) -> gettext.GNUTranslations:
"""Load translations for the given language."""
translation = gettext.translation(
domain='messages',
localedir='locales',
languages=[lang],
fallback=True # Fall back to msgid (source string) if not found
)
return translation
# Setup for the application
trans = setup_i18n('fr')
_ = trans.gettext
ngettext = trans.ngettext # Plural-aware translation
# Usage
print(_("Hello, world!"))
print(_("Welcome, %(name)s!") % {"name": "Alice"})
# Plural forms
count = 3
print(ngettext(
"%(count)d item", # singular form
"%(count)d items", # plural form
count # the number that determines form
) % {"count": count})
Marking Strings for Extraction
# Direct marking with _()
title = _("Dashboard")
error = _("An error occurred: %(message)s") % {"message": str(e)}
# For strings that need to be defined before i18n is set up,
# use a deferred translation pattern:
def _(s):
return s # No-op at definition time
# These strings are extracted but not translated until runtime
ERROR_MESSAGES = {
"not_found": _("Resource not found"),
"unauthorized": _("You are not authorized to perform this action"),
}
# At runtime, translate with the actual _ function:
def get_error_message(key: str, translation_func) -> str:
return translation_func(ERROR_MESSAGES[key])
Babel: The Comprehensive Python i18n Library
Babel extends gettext with full CLDR-based locale data for numbers, currencies, dates, and more. It's the most comprehensive Python i18n library.
pip install Babel
Number and Currency Formatting
from babel.numbers import format_number, format_currency, format_percent
from babel import Locale
# Locale objects
en_us = Locale('en', 'US')
de_de = Locale('de', 'DE')
ja_jp = Locale('ja', 'JP')
# Number formatting
amount = 1234567.89
print(format_number(amount, locale='en_US')) # 1,234,567.89
print(format_number(amount, locale='de_DE')) # 1.234.567,89
print(format_number(amount, locale='en_IN')) # 12,34,567.89
# Currency formatting
print(format_currency(1234.56, 'USD', locale='en_US')) # $1,234.56
print(format_currency(1234.56, 'EUR', locale='de_DE')) # 1.234,56 €
print(format_currency(1234.56, 'JPY', locale='ja_JP')) # ¥1,235 (no decimals)
# Percentage
print(format_percent(0.8527, locale='en_US')) # 85%
print(format_percent(0.8527, '#.##%', locale='de_DE')) # 85,27%
Date and Time Formatting
from babel.dates import format_date, format_datetime, format_time, get_timezone
from datetime import datetime, date
dt = datetime(2024, 3, 15, 14, 30, 0)
d = date(2024, 3, 15)
# Date formats
print(format_date(d, locale='en_US')) # Mar 15, 2024
print(format_date(d, locale='de_DE')) # 15.03.2024
print(format_date(d, locale='ja_JP')) # 2024/03/15
print(format_date(d, format='full', locale='fr_FR')) # vendredi 15 mars 2024
# Datetime with timezone
tz = get_timezone('Europe/Berlin')
print(format_datetime(dt, locale='de_DE', tzinfo=tz))
# Relative time (e.g., "3 hours ago")
from babel.dates import format_timedelta
from datetime import timedelta
delta = timedelta(hours=-3)
print(format_timedelta(delta, locale='en_US', add_direction=True)) # 3 hours ago
print(format_timedelta(delta, locale='fr_FR', add_direction=True)) # il y a 3 heures
Managing PO Files with Babel
Babel provides pybabel, a command-line tool for managing translation files:
# 1. Extract translatable strings from Python source
pybabel extract -F babel.cfg -o messages.pot .
# babel.cfg configures which files to extract from:
# [python: **.py]
# [jinja2: **/templates/**.html]
# 2. Initialize a new language (creates locales/fr/LC_MESSAGES/messages.po)
pybabel init -i messages.pot -d locales -l fr
# 3. After making source changes, update existing .po files
pybabel update -i messages.pot -d locales
# 4. Compile .po files to .mo files for runtime use
pybabel compile -d locales
Plural Forms in Babel/gettext
# In .po file (French):
# msgid "%(count)d item"
# msgid_plural "%(count)d items"
# msgstr[0] "%(count)d élément"
# msgstr[1] "%(count)d éléments"
# In Python:
from babel.support import Translations
translations = Translations.load('locales', ['fr'])
ngettext = translations.ngettext
for count in [0, 1, 2, 10]:
msg = ngettext("%(count)d item", "%(count)d items", count) % {"count": count}
print(msg)
Django i18n
Django has comprehensive built-in i18n support, deeply integrated into templates, models, and the ORM.
Django i18n Setup
# settings.py
LANGUAGE_CODE = 'en-us'
LANGUAGES = [
('en', 'English'),
('fr', 'Français'),
('de', 'Deutsch'),
('ja', '日本語'),
('ar', 'العربية'),
]
USE_I18N = True
USE_L10N = True # Locale-aware number/date formatting
USE_TZ = True
LOCALE_PATHS = [BASE_DIR / 'locale']
MIDDLEWARE = [
'django.middleware.locale.LocaleMiddleware', # Sets language from request
# ... other middleware
]
TEMPLATES = [{
'OPTIONS': {
'context_processors': [
'django.template.context_processors.i18n',
# ...
],
},
}]
Django Translation in Python Code
from django.utils.translation import gettext as _, ngettext, gettext_lazy as _lazy
# Eager translation (evaluated immediately)
message = _("Welcome!")
# Lazy translation (evaluated when string is accessed - for model fields and class attrs)
class Article(models.Model):
title = models.CharField(max_length=200)
class Meta:
verbose_name = _lazy("article")
verbose_name_plural = _lazy("articles")
# Plural forms
def item_message(count: int) -> str:
return ngettext(
"You have %(count)d item",
"You have %(count)d items",
count
) % {"count": count}
# String formatting - use named parameters for translatability
def welcome(name: str) -> str:
return _("Welcome, %(name)s!") % {"name": name}
# Context-sensitive translation (same string, different meaning)
from django.utils.translation import pgettext
month = pgettext("month name", "May") # vs "May" as a verb
Django Template Translation
{% load i18n %}
{# Simple translation #}
<h1>{% trans "Dashboard" %}</h1>
{# Translation with variables #}
{% blocktrans with name=user.first_name %}
Welcome, {{ name }}!
{% endblocktrans %}
{# Plural forms in templates #}
{% blocktrans count count=items|length %}
You have {{ count }} item.
{% plural %}
You have {{ count }} items.
{% endblocktrans %}
{# Language switcher #}
{% get_available_languages as LANGUAGES %}
<ul>
{% for lang_code, lang_name in LANGUAGES %}
<li>
<a href="/{{ lang_code }}/">{{ lang_name }}</a>
</li>
{% endfor %}
</ul>
Django URL i18n Patterns
# urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include
urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')), # Language switcher endpoint
]
urlpatterns += i18n_patterns(
# These URLs get a language prefix: /en/about/, /fr/about/
path('about/', views.about, name='about'),
path('products/', include('products.urls')),
prefix_default_language=False, # /about/ redirects to /en/about/
)
Flask i18n with Flask-Babel
Flask-Babel brings Babel's power to Flask applications:
pip install Flask-Babel
# app.py
from flask import Flask, g, request
from flask_babel import Babel, _, ngettext, format_currency, format_datetime
app = Flask(__name__)
app.config['BABEL_DEFAULT_LOCALE'] = 'en'
app.config['BABEL_DEFAULT_TIMEZONE'] = 'UTC'
app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'translations'
def get_locale():
# 1. Check URL parameter
lang = request.args.get('lang')
if lang:
return lang
# 2. Check user preference in session/database
if hasattr(g, 'current_user') and g.current_user.language:
return g.current_user.language
# 3. Use Accept-Language header
return request.accept_languages.best_match(['en', 'fr', 'de', 'ja'])
babel = Babel(app, locale_selector=get_locale)
@app.route('/products/<int:product_id>')
def product_detail(product_id):
product = Product.query.get_or_404(product_id)
# These use the current locale automatically
price = format_currency(product.price, 'USD')
created = format_datetime(product.created_at, format='medium')
return render_template('product.html',
product=product,
price=price,
created=created,
title=_("Product: %(name)s") % {"name": product.name}
)
Modern Approach: Fluent for Python
Mozilla's Fluent is available in Python via fluent.runtime:
pip install fluent.runtime
from fluent.runtime import FluentBundle, FluentResource
# Load and use Fluent files
def create_bundle(locale: str, ftl_content: str) -> FluentBundle:
bundle = FluentBundle([locale])
resource = FluentResource(ftl_content)
errors = bundle.add_resource(resource)
if errors:
raise ValueError(f"FTL errors: {errors}")
return bundle
# FTL file content
en_ftl = """
welcome = Welcome to our app!
greeting = Hello, { $name }!
items =
{ $count ->
[0] No items
[one] { $count } item
*[other] { $count } items
}
"""
bundle = create_bundle("en-US", en_ftl)
def translate(bundle: FluentBundle, message_id: str, **kwargs) -> str:
msg = bundle.get_message(message_id)
if not msg or not msg.value:
return message_id
value, errors = bundle.format_pattern(msg.value, kwargs)
return value
print(translate(bundle, "welcome"))
print(translate(bundle, "greeting", name="Alice"))
print(translate(bundle, "items", count=0))
print(translate(bundle, "items", count=1))
print(translate(bundle, "items", count=5))
Integrating with CI/CD
Python i18n workflows integrate naturally with continuous localization pipelines:
# .github/workflows/i18n.yml
name: i18n
on:
push:
paths:
- '**.py'
- '**/templates/**.html'
jobs:
extract-and-update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Babel
run: pip install Babel
- name: Extract strings
run: pybabel extract -F babel.cfg -o messages.pot .
- name: Update translation files
run: pybabel update -i messages.pot -d locales
- name: Push to translation platform
run: |
# Upload messages.pot to your TMS
curl -X POST https://api.better-i18n.com/upload \
-F "file=@messages.pot" \
-H "Authorization: Bearer ${{ secrets.BETTER_I18N_API_KEY }}"
For comprehensive CI/CD i18n patterns, see i18n CI/CD pipeline automation.
Locale Detection Best Practices
from babel import Locale, UnknownLocaleError
from typing import Optional
SUPPORTED_LOCALES = ['en', 'fr', 'de', 'ja', 'ar', 'pt-BR']
def parse_accept_language(header: str) -> list[str]:
"""Parse Accept-Language header into ordered list of language codes."""
locales = []
for part in header.split(','):
parts = part.strip().split(';')
lang = parts[0].strip()
# Extract quality (q=0.9) or default to 1.0
q = 1.0
for param in parts[1:]:
if param.strip().startswith('q='):
try:
q = float(param.strip()[2:])
except ValueError:
pass
locales.append((lang, q))
# Sort by quality, highest first
locales.sort(key=lambda x: x[1], reverse=True)
return [lang for lang, _ in locales]
def negotiate_locale(accept_language: str, supported: list[str] = SUPPORTED_LOCALES) -> str:
"""Find the best matching supported locale for the given Accept-Language header."""
requested = parse_accept_language(accept_language)
for requested_lang in requested:
# Exact match first
if requested_lang in supported:
return requested_lang
# Language-only match (en-US → en)
base = requested_lang.split('-')[0]
if base in supported:
return base
# Find any supported locale with the same base
for supported_locale in supported:
if supported_locale.startswith(base + '-'):
return supported_locale
return 'en' # Default fallback
For deeper coverage of pluralization rules across languages, see pluralization rules across languages. For an overview of the translation management ecosystem, see translation management systems.
Take your app global with better-i18n
better-i18n combines AI-powered translations, git-native workflows, and global CDN delivery into one developer-first platform. Stop managing spreadsheets and start shipping in every language.