Flask Internationalization (i18n): A Flask-Babel Tutorial
This is the hands-on companion to our guide to website internationalization (i18n). That guide covers the ten decisions that apply to any stack: URL structure, hreflang, UTF-8, text externalization, and the rest. This one is narrower and practical. We’ll take a small Flask app and internationalize it end to end with Flask-Babel, so that adding a new language becomes a matter of translation rather than re-engineering.
If you haven’t settled the architectural questions yet (which URL structure, how many languages, who translates), read the pillar first. If you already know you’re shipping a Flask app and you just need the implementation, you’re in the right place.
What you’ll build
By the end of this tutorial you’ll have a Flask app that:
- Serves the same page in English and Spanish from a single codebase.
- Keeps every user-facing string in translation files, not hard-coded in templates.
- Lets a visitor switch languages from a dropdown, and remembers the choice.
- Can take on a new locale (German, Simplified Chinese, Arabic, anything) without touching the templates again.
The same workflow scales from this toy app to a production site. The principles are identical; only the volume of strings changes.
Prerequisites
- Python 3.8 or newer. Check with
python --version. If you don’t have it, grab it from python.org. - Basic comfort with the command line and a code editor. Any editor works; PyCharm and VS Code both handle Flask projects well. You don’t need either specifically.
- A little Flask familiarity. You don’t need to be an expert. If you can read a route and an HTML template, you’ll follow along fine.
We’ll install two libraries as we go:
- Flask — the lightweight Python web framework.
- Flask-Babel — the extension that adds i18n and l10n support to Flask, built on top of the Babel and gettext tooling.
A note on versions: This tutorial targets Flask-Babel 4.x, the current release line. Older tutorials you’ll find online use a @babel.localeselector decorator that was removed in Flask-Babel 3.0. If you copy code from one of those and hit an AttributeError, that’s why. The approach below uses the current API.
Step 1: Set up the project
Create a project folder with three subfolders (static, templates, and translations) plus an empty app.py. The translations folder is where Flask-Babel will look for your language files by default.
mkdir flask-i18n && cd flask-i18n
mkdir static templates translations
touch app.py
It’s good practice to work inside a virtual environment so your dependencies stay isolated:
python -m venv venv
# macOS / Linux
source venv/bin/activate
# Windows
venv\Scripts\activate
Now install Flask and Flask-Babel:
pip install Flask Flask-Babel Installing Flask-Babel also gives you the pybabel command-line tool, which you’ll use to extract and compile translations later.
Step 2: Tell Babel where to look
Create a babel.cfg file in your project root. This config tells the extraction tool which files to scan for translatable text.
[python: **.py]
[jinja2: **/templates/**.html]
The two lines define the file patterns for Python source and Jinja2 templates respectively. Jinja2 is Flask’s templating engine; it lets you embed Python-like expressions in HTML, which is how translated strings get rendered into the page.
Step 3: Build a page
Create index.html inside the templates folder. For the tutorial, a minimal page is enough to demonstrate the workflow, a heading, a paragraph, and a couple of UI labels:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Design better.</title>
</head>
<body>
<header>
<!-- language selector goes here later -->
</header>
<main>
<h1>Design better.</h1>
<p>Design mobile UI faster and better with our product, and produce
professional designs for your business.</p>
<button type="submit">Join the waitlist</button>
<p><small>Available on Android and iOS.</small></p>
</main>
</body>
</html>
Right now every string is hard-coded. That’s the problem we’re about to fix.
Step 4: Wire up Flask-Babel in app.py
Open app.py and add the following. Read the comments, they explain each piece.
from flask import Flask, render_template, request, session, url_for
from flask_babel import Babel
app = Flask(__name__)
# Sign session cookies. Use a real, random value in production.
app.config['SECRET_KEY'] = 'change-this-to-a-random-secret'
# The default language, used when nothing else matches.
app.config['BABEL_DEFAULT_LOCALE'] = 'en'
# The languages your site offers. Add to this dict as you grow.
app.config['LANGUAGES'] = {
'en': 'English',
'es': 'Español',
}
def get_locale():
# 1. If the user picked a language via ?lang=xx, store and use it.
if request.args.get('lang'):
session['lang'] = request.args.get('lang')
return session['lang']
# 2. Otherwise reuse their stored choice.
if session.get('lang'):
return session['lang']
# 3. Otherwise fall back to the browser's preferred language.
return request.accept_languages.best_match(app.config['LANGUAGES'].keys())
# Flask-Babel 3.0+ takes the selector function in the constructor.
# (The old @babel.localeselector decorator no longer exists.)
babel = Babel(app, locale_selector=get_locale)
@app.context_processor
def inject_languages():
# Makes `languages` and `current_language` available in every template.
return {
'languages': app.config['LANGUAGES'],
'current_language': get_locale(),
}
@app.route('/')
def index():
return render_template('index.html')
if __name__ == '__main__':
app.run(debug=True)
Two things worth pausing on:
1. The get_locale function is the heart of language selection. It decides, on every request, which language to serve. Our version checks an explicit choice first, then a remembered choice, then the browser’s Accept-Language header. This order matters: it respects the user’s decision above all, which is exactly the behavior the pillar guide recommends, let users choose, remember the choice, never force a redirect.
2. The context_processor exposes two variables to every template without you passing them in each route: languages (the dict of options) and current_language (whatever get_locale returned). We’ll use both to build the language dropdown.
Step 5: Mark strings for translation
Flask-Babel can only translate strings you’ve marked. In Jinja templates, you wrap translatable text in {{ _("...") }}. The underscore is a conventional alias for gettext, the function that looks up the right translation at render time.
Go back to templates/index.html and wrap every user-facing string:
<main>
<h1>{{ _("Design better.") }}</h1>
<p>{{ _("Design mobile UI faster and better with our product, and produce
professional designs for your business.") }}</p>
<button type="submit">{{ _("Join the waitlist") }}</button>
<p><small>{{ _("Available on Android and iOS.") }}</small></p>
</main>
The text you see here (the English) doubles as the lookup key. When no translation exists for the active language, Flask-Babel falls back to this original string, so your site never shows a blank.
The same marker works in Python too, for strings that live in your routes rather than templates:
from flask_babel import gettext
flash(gettext("Your changes have been saved."))
Step 6: Extract the strings
Now collect every marked string into a template catalog. From your project root, run:
pybabel extract -F babel.cfg -o messages.pot .
This scans the files matched by babel.cfg, finds every _() and gettext() call, and writes them to messages.pot — a Portable Object Template. The .pot file is the master list of source strings. You don’t translate it directly; it’s the source you generate each language file from.
Step 7: Create a catalog for each language
Generate a Spanish catalog from the template:
pybabel init -i messages.pot -d translations -l es
The -d translations flag points at the directory Flask-Babel reads from; -l es is the language code. This creates translations/es/LC_MESSAGES/messages.po, which looks like this:
#: templates/index.html:10
msgid "Design better."
msgstr ""
#: templates/index.html:11
msgid ""
"Design mobile UI faster and better with our product, and produce "
"professional designs for your business."
msgstr ""
msgid is the source string (your key). msgstr is where the translation goes, empty for now.
To add another language later, repeat this one command with a different code. German would be -l de; Simplified Chinese, -l zh. The process is identical regardless of the language, including right-to-left languages like Arabic (-l ar), though RTL also needs layout work on the front end, which the pillar guide covers.
Step 8: Translate
Fill in each msgstr in translations/es/LC_MESSAGES/messages.po:
#: templates/index.html:10
msgid "Design better."
msgstr "Diseña mejor."
#: templates/index.html:11
msgid ""
"Design mobile UI faster and better with our product, and produce "
"professional designs for your business."
msgstr ""
"Diseña interfaces móviles más rápido y mejor con nuestro producto, y crea "
"diseños profesionales para tu negocio."
#: templates/index.html:12
msgid "Join the waitlist"
msgstr "Únete a la lista de espera"
#: templates/index.html:13
msgid "Available on Android and iOS."
msgstr "Disponible en Android e iOS."
You can edit .po files by hand for a few strings, but for anything real, use a dedicated editor like Poedit or push the file into a translation management system. At production scale this is the point where the work leaves the developer’s hands and goes to translators, more on that at the end.
Step 9: Compile the translations
Flask doesn’t read .po files directly. It reads compiled, binary .mo files. Compile them with:
pybabel compile -d translations This produces a messages.mo alongside each messages.po. One thing that trips people up: compiled translations load when the server starts. If you change a translation, recompile and restart the Flask server to see the update.
Step 10: Add a language selector
Finally, give users a way to switch. Add a dropdown to the <header> of index.html that loops over the languages dict we exposed earlier:
<header>
<nav>
<span>{{ current_language }}</span>
<ul>
{% for code, name in languages.items() %}
<li>
<a href="{{ url_for('index', lang=code) }}">{{ name }}</a>
</li>
{% endfor %}
</ul>
</nav>
</header>
Each link points back to the same route with a ?lang= query parameter. When clicked, get_locale picks up that parameter, saves it to the session, and serves the matching language. Because languages and current_language come from the context processor, this works on every page without extra wiring.
Start the server:
flask run
Visit http://127.0.0.1:5000, click between English and Español, and the page content swaps in place. That’s a working internationalized Flask app.
Step 11: Add more languages as you grow
Adding a locale is now a three-command loop, and notably none of those commands touch your templates:
# 1. Re-extract (picks up any new strings you've marked since)
pybabel extract -F babel.cfg -o messages.pot .
# 2. Initialize the new language (German shown here)
pybabel init -i messages.pot -d translations -l de
# 3. Translate the new .po file, then compile
pybabel compile -d translations
Then add the language to your LANGUAGES dict in app.py so it appears in the dropdown:
app.config['LANGUAGES'] = {
'en': 'English',
'es': 'Español',
'de': 'Deutsch',
}
When you add strings to existing pages, you don’t re-initialize, you update, which merges new strings into existing catalogs while keeping the translations you already have:
pybabel extract -F babel.cfg -o messages.pot .
pybabel update -i messages.pot -d translations
pybabel compile -d translations
After an update, some entries may be flagged fuzzy. Babel’s guess that a slightly changed string matches an old translation. Review fuzzy entries by hand and remove the flag before compiling, or the guessed translation won’t be used.
Where the developer’s job ends
You’ve now done the engineering half of internationalization: the app is structured so that a new language is a translation task, not a code change. That’s the whole point of i18n, and it’s the foundation everything else sits on.
The other half (the translation itself) is where quality is won or lost. Marking strings and compiling .mo files is mechanical; producing translations that read naturally, respect each market’s conventions, and carry your brand voice is not. Raw machine translation will get you a draft, but for anything customer-facing, that draft needs professional review: terminology consistency, cultural adaptation, and the kind of checking that catches a fluent-sounding sentence that means the wrong thing.
That’s the work a localization partner takes on once your .po files are ready. If you’ve internationalized your Flask app and you’re deciding how to handle the translation layer, talk to our team, we can plug into the workflow you just built.
Flask internationalization FAQ
Flask-Babel adds internationalization (i18n) and localization (l10n) support to a Flask application. It handles two things: translating marked strings via gettext, and formatting dates, numbers, and currencies for each locale. It builds on the Babel library and the standard gettext tooling, exposing them through a Flask-friendly interface.
Internationalization (i18n) is the structural work: marking strings, setting up extraction, and wiring the locale selector so the app can serve multiple languages. Localization (l10n) is filling in the actual translations for each market. This tutorial covers the i18n setup; the localization is the content you put in each .po file.
The usual causes, in order: you didn’t run pybabel compile after editing the .po file; you didn’t restart the server after compiling (compiled translations load at startup); the msgstr is empty so it fell back to the source string; or the string was flagged fuzzy and Babel skipped it. Check each in that order.
No. It was removed in Flask-Babel 3.0. In current versions you pass your selector function into the Babel() constructor as locale_selector=get_locale, as shown in Step 4. Tutorials that still use the decorator predate that change.
There’s no practical limit. Each language is one more .po catalog and one entry in your LANGUAGES dict. The work scales linearly with the number of languages and the volume of strings, which is exactly why externalizing strings up front matters, it keeps each new language cheap.
Yes. Alongside translation, Flask-Babel provides format_datetime, format_number, format_currency, and related helpers that render values according to the active locale. This matters because locale formatting (date order, decimal separators, currency placement) differs between markets even when the language is the same.
The pillar guide covers why that formatting layer is as important as the translation itself.