Python i18n and Localization Guide

Localization
Ninad Pathak
31 Jan 2025

30 min. read

Contents

After covering localization with front-end frameworks like React, Vue, and Svelte, I decided to switch things up with Python.

Python is the third most-used programming language in the world, with 51% of developers using it. So this Python i18n and localization guide should be particularly helpful if you’re in that majority.

commonly used programming languages

To make the tutorial visually appealing, I’ll use a demo Flask web app. But nothing here ties you to Flask. You can apply the examples to your own implementation in Python.

The main Python localization lessons revolve around storing translations in an external format, retrieving them based on the user’s preferred language, and formatting more complex content like dates, numbers, and currency.

So grab some snacks and let’s localize your Python app!

Why localize a Python app?

Localization (L10n) in Python—just like localization in other languages—is about making your app culturally and linguistically adaptable to users from all over the world. To this end, localize the following elements of your Python app:

  1. UI strings: Make sure every bit of text is translatable and properly displayed in the user’s chosen language. This part is known as internationalization, or i18n, and it’s usually the step before you proceed to localizing your app.

  2. Dates: Ensure your date formats adapt to the right locale so you don’t end up with month-day-year for someone used to day-month-year.

  3. Numbers: Handle decimal separators, thousand separators, and groupings (i.e., 10,000.50 in the US vs. 10.000,50 in France) for proper number localization.

  4. Currencies: Display localized currency symbols and amounts.

  5. Right-to-left scripts: Support RTL languages like Arabic and Hebrew.

I’ll break down these localization areas step by step, so even if you’re new to Python localization, you’ll have a clear roadmap.

How to internationalize strings with Python?

One of the first questions you’ll tackle is how to manage all the strings that show up on your Python app’s UI for internationalization.

  • If it’s a small app, the easiest solution might seem to be translation dictionaries all across your code*.* But that quickly becomes unwieldy. (more on that later)

  • A better approach is to keep your translations in a single place (such as .json or .yaml translation files, or .po and .mo files if you use gettext).

This way, your code remains clean, and you have an external source of truth for translators.

Why do we need translation files for Python internationalization?

“Why not just store all strings in code?”

Well, this is what you’d end up with if you store all the strings in your code:

if user_lang == 'en':
    title = "Hello"
elif user_lang == 'de':
    title = "Hallo"
elif user_lang == 'fr':
    title = "Bonjour"

Imagine doing that for more languages and a lot of text on the page.

So here are a few reasons translation files are used 👇:

  1. Ease of editing: You don’t have to rebuild or restart your code just to update translations. In some setups, you could hot-reload the translations from a JSON or YAML file, letting translators tweak the output in near real-time.

  2. Involvement of non-developers: Translators, content specialists, or even marketing teams can be given direct access to the translation files. You could also bring the teams together on an intuitive translation management system like Centus instead of having them work on translation files.

  3. Searchability: If you suspect a mistranslation or want to quickly audit all your strings, you can open your translation file in a text editor or an online localization tool. No more searching the entire codebase for pieces of text.

  4. Scalability: For large applications that support dozens of locales, the code-based approach becomes messy. Meanwhile, external files let you systematically track translations for each locale.

Python localization in 5 simple steps

I’ll first create a simple app with hardcoded text. That will be step 0.

You can absolutely use your existing app while following this tutorial from step 1 onwards.

Step 0: Create the demo Python application for localization

Open up your terminal or command prompt and follow these steps:

Create a project folder (let’s call it centus-python-loocalization):

mkdir centus-python-loocalization
cd centus-python-loocalization

Create and activate a virtual environment (recommended):

python3 -m venv venv
# On Windows:
venv\Scripts\activate
# On macOS/Linux:
source venv/bin/activate

Now, you don’t really need Flask if you have a backend-only application. But I’ll be using Flask for demonstration.

pip install flask

Create an app.py file to hold our Flask code:

touch app.py

Now let’s put our minimal Flask code in app.py.

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def home():
    return render_template('home.html')

if __name__ == '__main__':
    app.run(debug=True)

This is our bare-bones Flask application. Next, we’ll create a templates folder and add our HTML template for the home page.

From inside centus-python-loocalization, create a folder named templates:

mkdir templates

Then create a file called home.html inside templates:

touch templates/home.html

Now let’s add our hero heading, subheading, CTA buttons, and navbar. We’ll also include some basic CSS (inline, for simplicity) to give us a nice textured background and a bit of styling so everything looks beautiful right from the start.

templates/home.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Centus - Demo application for Python</title>
  <style>
    /* A simple, subtle textured background using linear gradients */
    body {
        margin: 0;
        padding: 0;
        font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
        background: linear-gradient(to bottom, #8dc4fb, #ffffff);
        background-color: #87ceeb;
        height: 100vh;
     }

    nav {
      background-color: #333;
      padding: 1rem;
      display: flex;
      justify-content: space-around;
    }

    nav a {
      color: #ffffff;
      text-decoration: none;
      margin: 0 0.5rem;
      font-weight: bold;
    }

    nav a:hover {
      text-decoration: underline;
    }

    .hero {
      text-align: center;
      padding: 4rem 2rem;
      color: #333;
    }

    .hero h1 {
      font-size: 3rem;
      margin-bottom: 1rem;
    }

    .hero p {
      font-size: 1.2rem;
      margin-bottom: 2rem;
      color: #555;
    }

    .cta-buttons {
      display: flex;
      justify-content: center;
      gap: 1rem;
    }

    .btn {
      padding: 0.75rem 1.5rem;
      border: none;
      cursor: pointer;
      font-size: 1rem;
      border-radius: 4px;
      transition: background-color 0.3s ease;
    }

    .btn-primary {
      background-color: #007bff;
      color: #fff;
    }

    .btn-primary:hover {
      background-color: #0056b3;
    }

    .btn-secondary {
      background-color: #6c757d;
      color: #fff;
    }

    .btn-secondary:hover {
      background-color: #5a6268;
    }
  </style>
</head>
<body>

  <!-- Simple Navbar -->
  <nav>
    <a href="#">Home</a>
    <a href="#">Features</a>
    <a href="#">Pricing</a>
    <a href="#">Contact</a>
  </nav>

  <!-- Hero Section -->
  <section class="hero">
    <h1>Centus - Demo application for Python</h1>
    <p>We'll use this app to demonstrate how localization works in Python</p>
    <div class="cta-buttons">
      <button class="btn btn-primary">Get Started</button>
      <button class="btn btn-secondary">Learn More</button>
    </div>
  </section>

</body>
</html>

I’ve added some styling here, but please feel free to skip that and just add the text blocks to experiment with the localization.

Run the app to ensure that you have set everything up correctly.

python app.py

And once you’re set, this is what you should see on the localhost:5000 page.

creating a demo app for Python localization

Alright, we’re now ready to proceed.

Step 1: Installing the required libraries.

Before we can localize the text, we need to install some libraries that will make our lives easier. We’ll start with Babel. Later, we’ll add libraries for JSON or YAML if that’s how we decide to store our translations.

Install babel:

pip install babel

(Optional) Install pyyaml if you plan on storing translations in YAML:

pip install pyyaml

(Optional) Install python-bidi if you need right-to-left language support (for Arabic, Hebrew, etc.):

pip install python-bidi

With these libraries installed, we’re ready to begin localizing our text.

Step 2: Switching out the hardcoded text

Open up templates/home.html again. Notice that we have a lot of hardcoded text, like:

<h1>Centus - Demo application for Python</h1>
<p>We'll use this app to demonstrate how localization works in Python</p>
<button class="btn btn-primary">Get Started</button>
<button class="btn btn-secondary">Learn More</button>
...

We want to eventually store these strings in translation files.

But first, let’s pass them from our Flask route as variables. In app.py, modify the home function to look like this:

app.py

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def home():
    # We'll define the text as Python variables
    hero_title = "Centus - Demo application for Python"
    hero_subtitle = "We'll use this app to demonstrate how localization works in Python"
    nav_links = ["Home", "Features", "Pricing", "Contact"]
    cta_primary = "Get Started"
    cta_secondary = "Learn More"

    return render_template(
        'home.html',
        hero_title=hero_title,
        hero_subtitle=hero_subtitle,
        nav_links=nav_links,
        cta_primary=cta_primary,
        cta_secondary=cta_secondary
    )

if __name__ == '__main__':
    app.run(debug=True)

Then update home.html to use Jinja2 variables:

templates/home.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>{{ hero_title }}</title>
  <style>
    /* Same styling as before */
    body {
        margin: 0;
        padding: 0;
        font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
        background: linear-gradient(to bottom, #8dc4fb, #ffffff);
        background-color: #87ceeb;
        height: 100vh;
        }

    nav {
      background-color: #333;
      padding: 1rem;
      display: flex;
      justify-content: space-around;
    }
    nav a {
      color: #ffffff;
      text-decoration: none;
      margin: 0 0.5rem;
      font-weight: bold;
    }
    nav a:hover {
      text-decoration: underline;
    }
    .hero {
      text-align: center;
      padding: 4rem 2rem;
      color: #333;
    }
    .hero h1 {
      font-size: 3rem;
      margin-bottom: 1rem;
    }
    .hero p {
      font-size: 1.2rem;
      margin-bottom: 2rem;
      color: #555;
    }
    .cta-buttons {
      display: flex;
      justify-content: center;
      gap: 1rem;
    }
    .btn {
      padding: 0.75rem 1.5rem;
      border: none;
      cursor: pointer;
      font-size: 1rem;
      border-radius: 4px;
      transition: background-color 0.3s ease;
    }
    .btn-primary {
      background-color: #007bff;
      color: #fff;
    }
    .btn-primary:hover {
      background-color: #0056b3;
    }
    .btn-secondary {
      background-color: #6c757d;
      color: #fff;
    }
    .btn-secondary:hover {
      background-color: #5a6268;
    }
  </style>
</head>
<body>
  <nav>
    {% for link in nav_links %}
      <a href="#">{{ link }}</a>
    {% endfor %}
  </nav>

  <section class="hero">
    <h1>{{ hero_title }}</h1>
    <p>{{ hero_subtitle }}</p>
    <div class="cta-buttons">
      <button class="btn btn-primary">{{ cta_primary }}</button>
      <button class="btn btn-secondary">{{ cta_secondary }}</button>
    </div>
  </section>
</body>
</html>

Run your app again and you’ll see that the text is exactly the same.

The difference is that now it’s powered by variables instead of inline strings. This sets us up perfectly for the next step.

Step 3: Moving variables to translation files

Now that we have variables in app.py, we can store those variables in external files. Typically, you’d create a folder called translations or locales and put your JSON or YAML files there. For example:

mkdir locales
touch locales/en.json
touch locales/fr.json

Then in locales/en.json:

{
  "hero_title": "Centus - Demo application for Python",
  "hero_subtitle": "We'll use this app to demonstrate how localization works in Python",
  "nav_links": ["Home", "Features", "Pricing", "Contact"],
  "cta_primary": "Get Started",
  "cta_secondary": "Learn More"
}

locales/fr.json

{
    "hero_title": "Centus - Application de démonstration pour Python",
    "hero_subtitle": "Nous utiliserons cette application pour démontrer le fonctionnement de la localisation en Python",
    "nav_links": ["Accueil", "Fonctionnalités", "Tarifs", "Contact"],
    "cta_primary": "Commencer",
    "cta_secondary": "En savoir plus"
}

Similarly, you would need to add the translations in any other translation files you might have.

How to translate JSON files for Python apps

Manually handling Python app translation is messy. For one thing, you’d have to juggle spreadsheets, emails, and chats. For another, you’d have to deal with regular translation errors.

Because let’s face it, there’s no chance your translators will hand you JSON files with code intact.

Here’s what I had to deal with once:

{
    "titre_de_l_application": "TechPro",
    "message_de_bienvenue": "Bienvenue à TechPro!",
    "bouton_connexion": ["Connexion"],
    "bouton_inscription": "S'inscrire",
    "mot_de_passe_oublie": "Mot de passe oublié?"
}

See what I mean?

To make sure your translators don’t overwrite key names or mess up the syntax, give them a platform that preserves your code. Give them Centus.

Here’s what Centus Editor looks like:

Centus Editor

Here, your translators can work on strings without unintentionally breaking the code.

Centus is more than a simple editor—it’s the localization management platform for your entire team. Translators, editors, designers, and managers can use it side-by-side to ship your Python app faster.

Up to 90% faster!

If it sounds far-fetched, let me explain how Centus works.

With Centus, your team can automatically translate your JSON files using Google Translate, DeepL, or Microsoft Translators. Then, they can edit the automatic translations, leaving comments for each other.

Comments in Centus TMS

Automated translation with human editing saves you a lot of time and effort. You can further reduce the time it takes to localize your Python apps, by integrating Centus with your designers’ tools, like Figma.

Centus-Figma integration

Instead of manually copy-pasting and messing things up in the process, your designers can automatically push all localized content into their Figma design.

Similarly, you can integrate Centus with your code repositories to implement continuous localization, where strings are regularly pushed to Centus and pulled back into your repository as part of your regular development cycle.

Continuous localization via Centus API

Centus has many more time-saving features. Explore them now with Centus’ free trial!

Step 4: Implement code to use these translation files

However you choose to handle your translation files, you need a way for your app to fetch the appropriate strings and use them in the app.

Let’s create a Translator class file to do exactly that.

Create a file named translator.py (or whatever you’d like to call it) at the root of centus_demo to manage loading these JSON files. For instance:

touch translator.py
translator.py

Now add the following class code to the translator.py file:

import json
import os

class Translator:
    def __init__(self, locales_folder, default_locale='en'):
        self.locales_folder = locales_folder
        self.default_locale = default_locale
        self.translations = {}
        # Load all available JSON files in the folder
        for filename in os.listdir(self.locales_folder):
            if filename.endswith('.json'):
                locale_name = filename.replace('.json', '')
                with open(os.path.join(self.locales_folder, filename), 'r', encoding='utf8') as f:
                    self.translations[locale_name] = json.load(f)

    def set_locale(self, locale_code):
        if locale_code in self.translations:
            self.default_locale = locale_code
        else:
            print("Locale not supported, fallback to default.")

    def t(self, key):
        # Return translation for the current locale
        locale_data = self.translations.get(self.default_locale, {})
        return locale_data.get(key, f"[Missing {key}]")

    def t_list(self, key):
        # For lists like nav_links
        locale_data = self.translations.get(self.default_locale, {})
        val = locale_data.get(key, [])
        if not isinstance(val, list):
            return []
        return val

In this simple approach, t_list() handles an array of strings like nav_links, and t() handles single-string values.

Now, let’s update app.py to make use of our translator:

from flask import Flask, render_template, request
from translator import Translator

app = Flask(__name__)
translator = Translator(locales_folder='locales')

@app.route('/')
def home():
    # Let's get locale from query string: /?lang=en or /?lang=de
    user_locale = request.args.get('lang', 'en')
    translator.set_locale(user_locale)

    # We fetch text from translator
    hero_title = translator.t("hero_title")
    hero_subtitle = translator.t("hero_subtitle")
    nav_links = translator.t_list("nav_links")
    cta_primary = translator.t("cta_primary")
    cta_secondary = translator.t("cta_secondary")

    return render_template(
        'home.html',
        hero_title=hero_title,
        hero_subtitle=hero_subtitle,
        nav_links=nav_links,
        cta_primary=cta_primary,
        cta_secondary=cta_secondary
    )

if __name__ == '__main__':
    app.run(debug=True)

Now, if you run python app.py and browse to /?lang=en, you’ll see your English text. If you have a de.json and go to /?lang=de, you’ll see German text (assuming you’ve placed the translations in that file).

Step 5: Test the internationalized (translated) Python app

Fire up your Flask application once more:

python app.py

Visit localhost:5000/?lang=en to confirm the English translation.

Visit localhost:5000/?lang=fr (if you created a fr.json) to confirm the French translation.

If everything goes well, you’ll see localized text in each language.

Step 6: Adding a language switcher

To implement a language switcher, we’ll dynamically fetch available languages from the locales directory and display them in a dropdown menu. Each language option will include the flag emoji and the name of the language.

First, we need a method to retrieve available languages from the locales directory. Add this function to the Translator class:

def get_available_languages(self):
    languages = {}
    for locale, translations in self.translations.items():
        # Add friendly names and flags for the locales
        language_map = {
            "en": {"name": "English", "flag": "🇺🇸"},
            "fr": {"name": "Français", "flag": "🇫🇷"},
            "de": {"name": "Deutsch", "flag": "🇩🇪"},
            # Add more languages as needed
        }
        languages[locale] = language_map.get(locale, {"name": locale, "flag": ""})
    return languages

Use this function to fetch available languages and pass them to the template:

from flask import Flask, render_template, request
from translator import Translator

app = Flask(__name__)
translator = Translator(locales_folder='locales')

@app.route('/')
def home():
    # Get user's locale preference
    user_locale = request.args.get('lang', 'en')
    translator.set_locale(user_locale)

    # Fetch translations
    hero_title = translator.t("hero_title")
    hero_subtitle = translator.t("hero_subtitle")
    nav_links = translator.t_list("nav_links")
    cta_primary = translator.t("cta_primary")
    cta_secondary = translator.t("cta_secondary")

    # Fetch available languages
    available_languages = translator.get_available_languages()

    return render_template(
        'home.html',
        hero_title=hero_title,
        hero_subtitle=hero_subtitle,
        nav_links=nav_links,
        cta_primary=cta_primary,
        cta_secondary=cta_secondary,
        available_languages=available_languages,
        current_language=user_locale
    )

if __name__ == '__main__':
    app.run(debug=True)

Here’s what our home.html file now looks like.

I’ve added the locale switcher dropdown in the navbar along with some styling. Feel free to copy this entire code.

<!DOCTYPE html>
<html lang="en">
   <head>
      <meta charset="UTF-8" />
      <title>{{ hero_title }}</title>
      <style>
         /* Same styling as before */
         body {
         margin: 0;
         padding: 0;
         font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
         background: linear-gradient(to bottom, #8dc4fb, #ffffff);
         background-color: #87ceeb;
         height: 100vh;
         }
         nav {
         background-color: #333;
         padding: 1rem;
         display: flex;
         justify-content: space-between;
         align-items: center;
         }
         nav a {
         color: #ffffff;
         text-decoration: none;
         margin: 0 0.5rem;
         font-weight: bold;
         }
         nav a:hover {
         text-decoration: underline;
         }
         .hero {
         text-align: center;
         padding: 4rem 2rem;
         color: #333;
         }
         .hero h1 {
         font-size: 3rem;
         margin-bottom: 1rem;
         }
         .hero p {
         font-size: 1.2rem;
         margin-bottom: 2rem;
         color: #555;
         }
         .cta-buttons {
         display: flex;
         justify-content: center;
         gap: 1rem;
         }
         .btn {
         padding: 0.75rem 1.5rem;
         border: none;
         cursor: pointer;
         font-size: 1rem;
         border-radius: 4px;
         transition: background-color 0.3s ease;
         }
         .btn-primary {
         background-color: #007bff;
         color: #fff;
         }
         .btn-primary:hover {
         background-color: #0056b3;
         }
         .btn-secondary {
         background-color: #6c757d;
         color: #fff;
         }
         .btn-secondary:hover {
         background-color: #5a6268;
         }
         .nav-links a {
         color: #ffffff;
         text-decoration: none;
         margin: 0 0.5rem;
         font-weight: bold;
         }
         .nav-links a:hover {
         text-decoration: underline;
         }
         .language-switcher {
         margin-left: auto;
         }
         .language-switcher select {
         padding: 0.4rem;
         border-radius: 4px;
         border: 1px solid #ccc;
         font-size: 0.9rem;
         cursor: pointer;
         background-color: #f9f9f9;
         color: #333;
         }
         .language-switcher select:focus {
         outline: none;
         border-color: #007bff;
         }
      </style>
   </head>
   <body>
      <nav>
         <div class="nav-links">
            {% for link in nav_links %}
            <a href="#">{{ link }}</a>
            {% endfor %}
         </div>
         <!-- Language Switcher -->
         <div class="language-switcher">
            <form method="get" action="/">
               <select name="lang" onchange="this.form.submit()" aria-label="Language Selector">
               {% for lang_code, lang_info in available_languages.items() %}
               <option value="{{ lang_code }}" {% if lang_code == current_language %}selected{% endif %}>
               {{ lang_info.flag }} {{ lang_info.name }}
               </option>
               {% endfor %}
               </select>
            </form>
         </div>
      </nav>
      <section class="hero">
         <h1>{{ hero_title }}</h1>
         <p>{{ hero_subtitle }}</p>
         <div class="cta-buttons">
            <button class="btn btn-primary">{{ cta_primary }}</button>
            <button class="btn btn-secondary">{{ cta_secondary }}</button>
         </div>
      </section>
   </body>
</html>

Restart your Flask app: python app.py

Open the app in your browser: localhost:5000

Use the dropdown to switch between languages dynamically.

The language switcher will display a dropdown with the available languages (from the locales directory), along with their corresponding flags and names.

Congratulations! 🎉 Your Flask app now supports a fully functional language switcher.

Advanced localization in Python

So, we’ve built a functional Flask application that can load translation files, switch languages via a dropdown, and generally display localized UI strings. But, real-world apps often need to handle more than static text.

They deal with pluralization for different counts, gendered sentences for languages that change grammar based on gender, and the complexities of date/time, currency, and number formatting. Plus, some languages are written right-to-left (RTL), such as Arabic and Hebrew.

In this section, we’ll tackle each of these advanced localization features.

NOTE: I created some additional to demonstrate how the localization setups you’re creating for the Python app affect across all routes.

Pluralization in Python using Babel

Different languages have different rules for plurals. For instance:

  • English typically has 2 forms (singular “1 item” vs. plural “2 items”).

  • Some languages, like Arabic, can have 6 forms depending on the number.

  • Others, like Japanese, do not differentiate singular vs. plural in the same way at all.

A popular approach in Python is using Babel’s PluralRule or simply storing multiple forms in your JSON and selecting which one to display.

Let’s do something simple with Babel to illustrate how you might implement basic pluralization.

Open translator.py and add some new code for pluralization. (If Babel isn’t installed, remember to run pip install babel in your virtual environment.)

File: translator.py

import json
import os
from babel.plural import PluralRule

class Translator:
    def __init__(self, locales_folder, default_locale='en'):
        self.locales_folder = locales_folder
        self.default_locale = default_locale
        self.translations = {}
        self.plural_rules = {
            'en': {'one': 'n is 1'},  
            'fr': {'one': 'n in 0..1'},  
            # You could add more advanced rules or more locales here
        }
        self._load_locales()

    def _load_locales(self):
        for filename in os.listdir(self.locales_folder):
            if filename.endswith('.json'):
                locale_name = filename.replace('.json', '')
                with open(
                    os.path.join(self.locales_folder, filename),
                    'r',
                    encoding='utf8'
                ) as f:
                    self.translations[locale_name] = json.load(f)

    def set_locale(self, locale_code):
        if locale_code in self.translations:
            self.default_locale = locale_code
        else:
            print("Locale not supported, fallback to default.")

    def t(self, key):
        locale_data = self.translations.get(self.default_locale, {})
        return locale_data.get(key, f"[Missing {key}]")

    def t_list(self, key):
        locale_data = self.translations.get(self.default_locale, {})
        val = locale_data.get(key, [])
        if not isinstance(val, list):
            return []
        return val

    def t_plural(self, singular_key, plural_key, count):
        """
        Attempt a basic pluralization approach:
        - singular_key: JSON key for singular form
        - plural_key: JSON key for plural form
        - count: numeric count
        """
        text_singular = self.t(singular_key)
        text_plural = self.t(plural_key)

        # Pick the correct rule set for the current locale
        rule_data = self.plural_rules.get(self.default_locale, {'one': 'n is 1'})
        plural_rule = PluralRule(rule_data)

        # Babel's PluralRule returns 'one', 'other', etc.
        form = plural_rule(count)
        # If it's 'one', pick singular text, else pick plural text
        if form == 'one':
            return text_singular.format(count=count)
        else:
            return text_plural.format(count=count)

    def get_available_languages(self):
        # (Same logic as in previous steps)
        languages = {}
        for locale, translations in self.translations.items():
            language_map = {
                "en": {"name": "English", "flag": "🇺🇸"},
                "fr": {"name": "Français", "flag": "🇫🇷"},
                "de": {"name": "Deutsch", "flag": "🇩🇪"}
                # Add more languages or locales as needed
            }
            languages[locale] = language_map.get(locale, {"name": locale, "flag": ""})
        return languages

Now we need something to demonstrate the pluralization. Let’s say we want to show a message like “You have 1 message” vs. “You have N messages.” In locales/en.json:

{
  "hero_title": "Centus - Demo application for Python",
  "hero_subtitle": "We'll use this app to demonstrate how localization works in Python",
  "nav_links": ["Home", "Features", "Pricing", "Contact"],
  "cta_primary": "Get Started",
  "cta_secondary": "Learn More",

  "msg_singular": "You have {count} message",
  "msg_plural": "You have {count} messages"
}

Similarly, in locales/fr.json (if you have French), you might add:

{
  "hero_title": "Centus - Application de démonstration pour Python",
  "hero_subtitle": "Nous utiliserons cette application pour démontrer le fonctionnement de la localisation en Python",
  "nav_links": ["Accueil", "Fonctionnalités", "Tarifs", "Contact"],
  "cta_primary": "Commencer",
  "cta_secondary": "En savoir plus",

  "msg_singular": "Vous avez {count} message",
  "msg_plural": "Vous avez {count} messages"
}

Let’s create a quick route in app.py to see this in action. We’ll call it /plural-demo.

File: app.py

from flask import Flask, render_template, request
from translator import Translator

app = Flask(__name__)
translator = Translator(locales_folder='locales')

@app.route('/')
def home():
    # Get user's locale preference
    user_locale = request.args.get('lang', 'en')
    translator.set_locale(user_locale)
    
    # variables for pluralization
    count = request.args.get('count', 1, type=int)
    message_text = translator.t_plural("msg_singular", "msg_plural", count)

    # Fetch translations
    hero_title = translator.t("hero_title")
    hero_subtitle = translator.t("hero_subtitle")
    nav_links = translator.t_list("nav_links")
    cta_primary = translator.t("cta_primary")
    cta_secondary = translator.t("cta_secondary")

    # Fetch available languages
    available_languages = translator.get_available_languages()

    return render_template(
        'home.html',
        hero_title=hero_title,
        hero_subtitle=hero_subtitle,
        nav_links=nav_links,
        cta_primary=cta_primary,
        cta_secondary=cta_secondary,
        available_languages=available_languages,
        current_language=user_locale,
        message_text=message_text
    )

if __name__ == '__main__':
    app.run(debug=True)

Finally, add these lines of HTML on your page body:

<h2>{{ message_text }}</h2>
<p>Try appending ?count=5 or changing the language to see this in action!</p>

Start/restart the app:

python app.py
  • Go to localhost:5000/ → You should see: You have 1 message

  • Now go to localhost:5000/?count=5 → You have 5 messages

  • Switch language with localhost:5000/?lang=fr&count=5 → Vous avez 5 messages (in French)

Awesome! You now have working pluralization in your localized Python application.

Gendered sentences

Some languages, like French or Spanish, change articles, adjectives, or pronouns based on gender.

For instance, “He is happy” vs. “She is happy.”

Creating gendered localization works quite similar to how we handled plurals. Let me show that to you with a quick demo.

In locales/en.json:

{
  "...": "...",
  "msg_he_is": "He is {age} years old",
  "msg_she_is": "She is {age} years old"
}

In locales/fr.json:

{
  "...": "...",
  "msg_he_is": "Il a {age} ans",
  "msg_she_is": "Elle a {age} ans"
}

I’ll store separate keys for each gender as that’s a much simpler way to get genders to work. However, you can always create custom functions.

Let’s do the simplest approach: define separate keys.

Then, in your code, you just call the appropriate key. For example:

def t_gendered(self, male_key, female_key, gender, **kwargs):
    """
    Return male_key or female_key based on 'M' or 'F'.
    """
    if gender.upper() == 'M':
        text = self.t(male_key)
    else:
        text = self.t(female_key)
    return text.format(**kwargs)

Add the above function to the Translator class in translator.py.

In app.py:

@app.route('/gender-demo')
def gender_demo():
    user_locale = request.args.get('lang', 'en')
    translator.set_locale(user_locale)

    # Let's say we pass ?gender=F
    gender = request.args.get('gender', 'M')
    age = request.args.get('age', 25, type=int)

    gendered_text = translator.t_gendered("msg_he_is", "msg_she_is", gender, age=age)

    return f"""
    <h2>{gendered_text}</h2>
    <p>Try ?lang=fr&gender=F&age=30</p>
    """

Visit: localhost:5000/gender-demo?lang=fr&gender=F&age=30

localizing genders in Python

Expect: Elle a 30 ans (French, female form).

Date formatting

We usually want to format dates according to the user’s locale. The library of choice is often Babel, which provides format_date, format_datetime, and so on.

Update your translator.py by adding the following imports and the format_local_datetime function.

from datetime import datetime
from babel.dates import format_datetime

def format_local_datetime(self, date_obj, locale='en'):
    """
    Given a Python datetime object, return a localized string representation.
    """
    return format_datetime(date_obj, locale=locale, format='long')

(You can put this as a standalone function at the bottom of translator.py, or in a separate utility module.)

In app.py:

from datetime import datetime
from translator import Translator, format_local_datetime

@app.route('/date-demo')
def date_demo():
    user_locale = request.args.get('lang', 'en')
    translator.set_locale(user_locale)

    now = datetime.now()
    localized_date = translator.format_local_datetime(now, locale=user_locale)

    return f"""
    <h2>{localized_date}</h2>
    <p>Try ?lang=fr or ?lang=de</p>
    """

Restart and visit: localhost:5000/date-demo

localizing dates in Python

Switch ?lang=fr or ?lang=de to see how date/time changes.

One important thing you’ll notice here is that I’ve set the lang to de, which is German. But we never actually created a de.json file?

Yet we see the date formats and month names updated. That’s the magic of Babel which fills in the gaps and saves us time from having to manually add all the months and date formats.

Currency and number formatting

Similar to dates, countries across the world follow unique numbering and number representation formats. For instance, in the US we use a period to separate decimals and a comma to separate thousands.

  • Ten thousand dollars and 50 cents would be written as: $10,000.50

  • The same number in France would be written as: 10.000,50 $

Showing the incorrect format in either country would create a lot of confusion.

Luckily, Babel helps us here too.

Add the following imports and functions to the translator.py file.

from babel.numbers import format_currency, format_decimal

def format_local_currency(self, amount, currency_code='USD', locale=None):
    """
    Format a currency value according to the locale.
    If locale is None, use the default_locale from the Translator instance.
    """
    if locale is None:
        locale = self.default_locale
    return format_currency(amount, currency_code, locale=locale)

def format_local_number(self, num, locale=None):
    """
    Format a number according to the locale.
    If locale is None, use the default_locale from the Translator instance.
    """
    if locale is None:
        locale = self.default_locale
    return format_decimal(num, locale=locale)

Now, we’ll add a new route to our app.py:

@app.route('/currency-demo')
def currency_demo():
    user_locale = request.args.get('lang', 'en')
    translator.set_locale(user_locale)

    # Format currency and number
    price_usd = translator.format_local_currency(1234.56, 'USD', locale=user_locale)
    price_eur = translator.format_local_currency(1234.56, 'EUR', locale=user_locale)
    big_number = translator.format_local_number(1234567.89, locale=user_locale)

    return f"""
    <h2>Prices:</h2>
    <p>USD: {price_usd}</p>
    <p>EUR: {price_eur}</p>
    <h2>Formatted Number:</h2>
    <p>{big_number}</p>
    <p>Try ?lang=fr or ?lang=de!</p>
    """

Restart your Flask app and go to the currency-demo route.

localhost:5000/currency-demo?lang=fr will show “1 234,56 $US”, etc

localizing numbers in Python

Again, similar to the date formatting, you can change the lang= variable to any locale even if you haven’t created it, and Babel will handle the same.

Handling RTL (right-to-left) languages

Now we’re onto the trickier part of the tutorial. Arabic (ar), Hebrew (he), Farsi (fa), etc., are right-to-left. If you want to support them, you need to implement RTL support with python-bidi.

I’ll demo this in a simplistic manner right now to help you get the gist of it.

But to make the app look truly “polished”, you’ll want to flip your layout.

pip install python-bidi

Then in your app.py:

from bidi.algorithm import get_display

@app.route('/rtl-demo')
def rtl_demo():
    user_locale = request.args.get('lang', 'en')
    translator.set_locale(user_locale)

    # Suppose our JSON has a key "rtl_text" in ar.json with Arabic content
    text = translator.t("rtl_text")

    # Reverse it for proper RTL display
    display_text = get_display(text)

    return f"""
    <div style="direction: rtl; text-align: right;">
      <h2>{display_text}</h2>
      <p>Language code: {user_locale}</p>
    </div>
    """

You would also likely add a <body dir="rtl"> or a CSS rule with direction: rtl; for a complete RTL experience.

Parting thoughts

We looked at how Python and libraries like Babel and python-bidi can be used to solve hard translation problems like pluralization, gendered text, times, numbers, currencies, and right-to-left (RTL) languages.

We worked hard to give people from all over the world a smooth, culturally appropriate experience.

But localization doesn’t have to be this hard.

Centus, with its intuitive interface, real-time updates, and powerful integrations, helps developers and translators focus on what they do best without overcoming technical hurdles.

Ready to simplify your localization process? Try Centus now!

Get the week's best content!

By subscribing, you are agreeing to have your personal information managed in accordance with the terms of Centus Privacy Policy ->

Enjoyed the article?

Share it with your colleagues and partners 🤩