Flask i18n: Build a Multi-Language App with Flask-Babel

From gettext basics to production deployment: internationalize your Flask app with Flask-Babel, PO files, and automated AI translation.

1

Install Flask-Babel

Flask-Babel is the standard internationalization extension for Flask. It integrates GNU gettext with Flask and Jinja2 templates, providing translation functions, locale selection, and timezone support out of the box.

Flask-Babel wraps Babel (the Python i18n library) and integrates it with Flask's request lifecycle. It gives you gettext(), ngettext(), and lazy_gettext() functions, plus automatic locale detection from browser headers.
Terminal
pip install Flask-Babel
2

Configure Babel

Create a babel.cfg file to tell pybabel where to scan for translatable strings, then initialize Flask-Babel with a locale selector function that determines which language to serve for each request.

babel.cfg
# babel.cfg — tells pybabel where to find translatable strings
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
app.py
from flask import Flask, request
from flask_babel import Babel

app = Flask(__name__)
app.config['BABEL_DEFAULT_LOCALE'] = 'en'
app.config['BABEL_DEFAULT_TIMEZONE'] = 'UTC'
# Directory where translations live (default: "translations")
app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'translations'

def get_locale():
    # 1. Check URL parameter or session
    # 2. Fall back to browser Accept-Language header
    return request.accept_languages.best_match(['en', 'de', 'ja', 'es', 'fr'])

babel = Babel(app, locale_selector=get_locale)
The locale_selector function is called on every request. If it returns a locale that doesn't have a compiled .mo file, Flask-Babel silently falls back to the default locale. No error is raised — strings just appear untranslated.
3

Mark Strings for Translation

Wrap every user-facing string with gettext() in Python code and _() in Jinja2 templates. Use lazy_gettext() for strings defined at module load time (like form labels and configuration) that need to be translated later at request time.

app.py
from flask_babel import gettext, ngettext, lazy_gettext

# In views — gettext() for immediate translation
@app.route('/')
def index():
    flash(gettext('Your profile has been updated.'))
    return render_template('index.html',
        title=gettext('Home'))

# In forms/config — lazy_gettext() for deferred translation
class LoginForm(FlaskForm):
    username = StringField(lazy_gettext('Username'))
    password = PasswordField(lazy_gettext('Password'))
    submit = SubmitField(lazy_gettext('Sign In'))
templates/index.html
{# In Jinja2 templates, use _() shorthand for gettext #}
<h1>{{ _('Welcome to our app') }}</h1>

<p>{{ _('Hello, %(name)s!', name=user.name) }}</p>

<footer>
  {{ _('Copyright %(year)s Example Corp.', year=2026) }}
</footer>
Use _() in Jinja2 templates instead of gettext() — it's the standard gettext shorthand and keeps your templates clean. Flask-Babel registers _() as a Jinja2 global automatically.
4

Extract Messages

Run pybabel extract to scan your source code and templates for translatable strings. This creates a .pot (Portable Object Template) file. Then initialize catalogs for each target language, or update existing ones when source strings change.

Terminal
# Extract translatable strings from source code
pybabel extract -F babel.cfg -k lazy_gettext -o messages.pot .

# Initialize a new language (first time only)
pybabel init -i messages.pot -d translations -l de
pybabel init -i messages.pot -d translations -l ja
pybabel init -i messages.pot -d translations -l es

# Update existing catalogs when source strings change
pybabel update -i messages.pot -d translations
Always run pybabel update (not init) after the first extraction. Running init on an existing language directory overwrites all existing translations. The update command merges new strings while preserving existing translations.
5

Translate PO Files

Open the generated .po files and fill in the msgstr values for each msgid. PO files are plain text — you can edit them directly, use a PO editor like Poedit, or automate translation with AI tools.

translations/de/LC_MESSAGES/messages.po
# translations/de/LC_MESSAGES/messages.po
msgid "Welcome to our app"
msgstr "Willkommen in unserer App"

msgid "Hello, %(name)s!"
msgstr "Hallo, %(name)s!"

msgid "Your profile has been updated."
msgstr "Ihr Profil wurde aktualisiert."

msgid "Username"
msgstr "Benutzername"

msgid "Password"
msgstr "Passwort"

msgid "Sign In"
msgstr "Anmelden"
PO files include context comments (lines starting with #:) showing where each string is used in your source code. Translators use these to understand context. Keep them — they're generated automatically by pybabel extract.
6

Compile Translations

Compile your .po files into binary .mo files using pybabel compile. Flask-Babel reads .mo files at runtime — it cannot read .po files directly. You must recompile after every translation update.

Terminal
# Compile .po files to binary .mo files (required at runtime)
pybabel compile -d translations

# Flask-Babel reads .mo files, not .po files.
# You MUST compile after every translation update.
If translations don't appear after editing a .po file, you almost certainly forgot to run pybabel compile. This is the most common Flask-Babel issue. Add the compile step to your deployment script to avoid it in production.
7

Handle Plurals and Variables

Use ngettext() for plural-sensitive strings. It takes a singular form, a plural form, and the count. Babel automatically uses the correct plural rule for each language — English has 2 forms, but Russian has 3, Arabic has 6, and Japanese has 1.

app.py
from flask_babel import ngettext

@app.route('/cart')
def cart():
    count = len(session.get('cart_items', []))
    message = ngettext(
        '%(num)d item in your cart',     # singular
        '%(num)d items in your cart',    # plural
        count                             # determines which form
    )
    return render_template('cart.html', message=message)
Plural forms in .po files
# English: 2 forms (nplurals=2)
msgid "%(num)d item in your cart"
msgid_plural "%(num)d items in your cart"
msgstr[0] "%(num)d item in your cart"
msgstr[1] "%(num)d items in your cart"

# German: 2 forms (nplurals=2)
msgid "%(num)d item in your cart"
msgid_plural "%(num)d items in your cart"
msgstr[0] "%(num)d Artikel in Ihrem Warenkorb"
msgstr[1] "%(num)d Artikel in Ihrem Warenkorb"

# Japanese: 1 form (nplurals=1)
msgid "%(num)d item in your cart"
msgid_plural "%(num)d items in your cart"
msgstr[0] "カートに%(num)d個の商品があります"

# Russian: 3 forms (nplurals=3)
msgid "%(num)d item in your cart"
msgid_plural "%(num)d items in your cart"
msgstr[0] "%(num)d товар в вашей корзине"
msgstr[1] "%(num)d товара в вашей корзине"
msgstr[2] "%(num)d товаров в вашей корзине"
Never use if count == 1 for plural logic. Languages like French treat 0 as singular. Russian and Arabic have forms English doesn't have. Let ngettext() and Babel's CLDR plural rules handle the selection.
8

Add Locale Switching

Build a language selector that stores the user's choice in the Flask session. Update your locale_selector function to check the session first, then fall back to browser detection.

app.py
from flask import session, redirect, url_for, request
from flask_babel import refresh

@app.route('/set-language/<lang>')
def set_language(lang):
    session['lang'] = lang
    refresh()  # Force Flask-Babel to re-read the locale
    return redirect(request.referrer or url_for('index'))

# Update get_locale to check session first
def get_locale():
    # 1. Explicit user choice (stored in session)
    if 'lang' in session:
        return session['lang']
    # 2. Browser Accept-Language header
    return request.accept_languages.best_match(
        ['en', 'de', 'ja', 'es', 'fr']
    )
templates/components/language_switcher.html
{# Language switcher component #}
<nav class="language-switcher">
  {% for lang, name in [('en','English'),('de','Deutsch'),
                         ('ja','日本語'),('es','Español'),
                         ('fr','Français')] %}
    <a href="{{ url_for('set_language', lang=lang) }}"
       class="{{ 'active' if get_locale() == lang }}">
      {{ name }}
    </a>
  {% endfor %}
</nav>
After changing the session locale, call flask_babel.refresh() to force Flask-Babel to re-read the locale for the current request. Without refresh(), the old locale persists until the next request.
9

Automate Translations

With your Flask-Babel setup complete, translate your PO files using AI. Automate the extract-translate-compile cycle in your CI/CD pipeline to keep translations in sync with your source code.

Terminal
# Translate your PO files with AI directly from your IDE
# or use the CLI in CI/CD:
npx i18n-agent translate translations/de/LC_MESSAGES/messages.po \
  --source-lang en --target-lang de

# Bulk translate all languages at once:
npx i18n-agent translate messages.pot --lang de,ja,es,fr

# Then compile:
pybabel compile -d translations
Translate incrementally — when you add new strings and run pybabel update, translate only the new untranslated entries (empty msgstr values) rather than regenerating everything. This preserves human-reviewed translations.

Bonus: Smart Locale Fallback with flask-babel-locale-chain

By default, Flask-Babel falls back directly to the default locale when the user's preferred locale isn't available. A pt-BR user with only pt-PT translations sees English instead of Portuguese. flask-babel-locale-chain adds configurable fallback chains so related locales cascade naturally.

app.py
# pip install flask-babel-locale-chain
from flask_babel_locale_chain import LocaleChain

# Define fallback chains: pt-BR falls back to pt before en
locale_chain = LocaleChain({
    'pt-BR': ['pt-BR', 'pt', 'en'],
    'pt-PT': ['pt-PT', 'pt', 'en'],
    'zh-Hant': ['zh-Hant', 'zh-Hans', 'en'],
    'en-GB': ['en-GB', 'en', 'en-US'],
})

def get_locale():
    requested = request.accept_languages.best_match(
        ['en', 'pt-BR', 'pt', 'zh-Hant', 'zh-Hans']
    )
    # Returns the best available locale from the chain
    return locale_chain.resolve(requested)
flask-babel-locale-chain is an open-source Python package. View it on GitHub at github.com/i18n-agent/flask-babel-locale-chain.

Automate Translation Quality

Catch missing keys and broken placeholders before they ship with i18n-validate. Test your UI with pseudo-translations using i18n-pseudo before real translations arrive.

Common Pitfalls

Forgot to Compile .po to .mo

Flask-Babel reads compiled .mo files, not .po files. If translations don't appear after editing .po files, run pybabel compile -d translations. Add this step to your deployment script.

Using gettext() at Module Level

gettext() requires a request context. If you define translated strings at module level (class attributes, constants), use lazy_gettext() instead. It defers translation until the string is actually rendered in a request.

Extraction Misses Strings

pybabel extract only scans files matching patterns in babel.cfg. If strings aren't extracted: check your babel.cfg patterns match your file structure, add -k lazy_gettext to extract lazy calls, and ensure Jinja2 templates use the correct extension (.html, .jinja2).

PO File Encoding Errors

PO files must be UTF-8 encoded. If you see UnicodeDecodeError, check the Content-Type header in your .po file: it should say charset=UTF-8. Some editors save with different encodings — always verify after editing.

Recommended File Structure

Project Structure
my-flask-app/
├── app.py                          # Flask app with Babel config
├── babel.cfg                       # Extraction config
├── messages.pot                    # Template (extracted strings)
├── translations/
│   ├── de/
│   │   └── LC_MESSAGES/
│   │       ├── messages.po         # German translations (editable)
│   │       └── messages.mo         # Compiled binary (generated)
│   ├── ja/
│   │   └── LC_MESSAGES/
│   │       ├── messages.po
│   │       └── messages.mo
│   └── es/
│       └── LC_MESSAGES/
│           ├── messages.po
│           └── messages.mo
├── templates/
│   ├── base.html
│   ├── index.html
│   └── components/
│       └── language_switcher.html
├── requirements.txt
└── venv/

Try i18n Agent Now

Drop your translation file here

JSON, YAML, PO, XML, CSV, Markdown, Properties

or click to browse

Target languages

No signup requiredInstant estimate

Flask i18n FAQ