FastAPI i18n: A step-by-step guide with examples

Internationalization (FastAPI i18n) is essential when developing applications for a global audience. Whether you’re building an API for a multilingual website or supporting multiple locales in a SaaS product, handling translations efficiently ensures a seamless user experience.

This guide provides a practical, step-by-step approach to adding i18n support to your FastAPI app. No overcomplicated theory—just a working solution you can implement immediately.

By the end, you’ll have a fully localized FastAPI application that can handle multiple languages, pluralization, datetime formatting, currency localization, and user language preferences.

The source code for this tutorial can be found on GitHub.

    Prerequisites and assumptions for FastAPI i18n

    This guide assumes you’re familiar with Python and FastAPI basics. You don’t need to be an expert, but you should know how to create a FastAPI app and run it.

    We’ll use Poetry for dependency management. You should also have Python 3.9+ installed.

    Setting up a new FastAPI app with Poetry

    First, make sure you have Poetry installed. If you don’t, follow installation instructions on the official website for additional info.

    Now, create a new FastAPI project:

    poetry new fastapi-i18n-demo
    cd fastapi-i18n-demo

    This creates a new project folder with a basic structure. Next, we configure Poetry to use FastAPI:

    poetry add fastapi uvicorn
    • fastapi is the main framework.
    • uvicorn is an ASGI server that runs FastAPI apps.

    To verify that everything works, create a new file main.py inside the fastapi_i18n_demo folder and add this:

    from fastapi import FastAPI
    
    app = FastAPI()
    
    @app.get("/")
    def read_root():
        return {"message": "Hello, world!"}

    Now, run the app:

    poetry run uvicorn fastapi_i18n_demo.main:app --reload

    Open your browser at 127.0.0.1:8000, and you should see:

    {"message": "Hello, world!"}

    Great!

    Adding simple translations

    To implement fastapi i18n, we need a way to translate text dynamically based on the user’s language. FastAPI doesn’t have built-in i18n support, so we’ll use Babel, a common Python library for managing translations.

    Installing Babel

    First, install Babel with Poetry by running the following command:

    poetry add Babel

    This will help us handle translations properly as we expand our app.

    Setting up translation files

    We’ll store our translations in JSON files, one per language. Inside your project, create a locales folder:

    mkdir -p locales/en locales/fr

    Now, inside each language folder, create a messages.json file. These will store key-value pairs for our translations.

    English translation file (locales/en/messages.json)

    {
      "greeting": "Hello, world!"
    }

    French translation file (locales/fr/messages.json)

    {
      "greeting": "Bonjour, le monde!"
    }

    We’ll use literal translations in this tutorial. This way, our app can load and return the correct message strings based on the selected language.

    Updating the FastAPI app to support translations

    Now, modify main.py to load these translations and use them dynamically:

    from fastapi import FastAPI, Query
    import json
    import os
    
    app = FastAPI()
    
    # Load translations
    translations = {}
    
    for lang in ["en", "fr"]:
        path = os.path.join("locales", lang, "messages.json")
        with open(path, encoding="utf-8") as f:
            translations[lang] = json.load(f)
    
    @app.get("/")
    def read_root(lang: str = Query("en", title="Language", description="Language code (en or fr)")):
        """
        A simple endpoint that returns a localized greeting message.
        """
        return {"message": translations.get(lang, translations["en"])["greeting"]}
    • The app loads all translations from the locales/ directory at startup.
    • The lang query parameter (?lang=en or ?lang=fr) determines which translation to use.
    • If an unsupported language is provided, it defaults to English.

    Running and testing

    Start the app:

    poetry run uvicorn fastapi_i18n_demo.main:app --reload

    Now, open these URLs in your browser:

    • English: http://127.0.0.1:8000/?lang=en{"message": "Hello, world!"}
    • French: http://127.0.0.1:8000/?lang=fr{"message": "Bonjour, le monde!"}
    • Invalid language (defaults to English): http://127.0.0.1:8000/?lang=de{"message": "Hello, world!"}

    This is a basic yet functional FastAPI i18n setup.

    Making the FastAPI i18n setup more robust

    Right now, our fastapi i18n setup has some hardcoded values inside main.py. Specifically, the list of supported languages and the default locale are defined directly in the file. This isn’t ideal because:

    1. Configuration should be centralized – Keeping settings inside the main app file makes the code harder to manage as the project grows.
    2. Easier updates – If we need to change supported languages or the default locale, we don’t want to modify the application logic.
    3. Environment-based flexibility – If we want different settings for development and production, having a separate config system helps.

    To solve this, we’ll use Pydantic’s BaseSettings to create a proper configuration file.

    Step 1: Install Pydantic

    We’ll need to install Pydantic-settings first:

    poetry add pydantic-settings

    Step 2: Create a config.py file

    Inside fastapi_i18n_demo/, create a new file called config.py. This will store our fastapi i18n settings.

    touch fastapi_i18n_demo/config.py

    Now, open config.py and define the configuration class:

    from pydantic_settings import BaseSettings
    from typing import List
    
    class Settings(BaseSettings):
        """
        Configuration settings for the FastAPI i18n setup.
        """
        supported_locales: List[str] = ["en", "fr"]
        default_locale: str = "en"
    
        class Config:
            env_prefix = "APP_"  # Prefix for environment variables
    
    # Create a settings instance
    settings = Settings()
    • supported_locales lists all available languages.
    • default_locale defines the fallback language.
    • BaseSettings allows loading values from environment variables (useful in real-world deployment).
    • env_prefix = "APP_" means we can override these settings using environment variables like APP_SUPPORTED_LOCALES or APP_DEFAULT_LOCALE.

    Step 3: Use the config in main.py

    Now, update main.py to load the settings from config.py:

    from fastapi import FastAPI, Query
    import json
    import os
    from fastapi_i18n_demo.config import settings
    
    app = FastAPI()
    
    # Load translations dynamically based on config settings
    translations = {}
    
    for lang in settings.supported_locales:
        path = os.path.join("locales", lang, "messages.json")
        with open(path, encoding="utf-8") as f:
            translations[lang] = json.load(f)
    
    
    @app.get("/")
    def read_root(
        lang: str = Query(
            settings.default_locale, title="Language", description="Language code"
        )
    ):
        """
        A simple endpoint that returns a localized greeting message.
        """
        return {
            "message": translations.get(lang, translations[settings.default_locale])[
                "greeting"
            ]
        }
    • No more hardcoded locales – We load the list from config.py.
    • Cleaner and scalable – If we want to add new languages, we modify one file instead of searching through the code.
    • Supports environment variables – If we deploy this app in different environments, we can set values dynamically.

    Step 4 (optional): Override settings with environment variables

    By default, our app uses en and fr, but we can override this without changing the code.

    Run the app with a different default locale:

    APP_DEFAULT_LOCALE=fr poetry run uvicorn fastapi_i18n_demo.main:app --reload

    Now, when you access the root endpoint without specifying ?lang=, it will default to French.

    Handling pluralization in FastAPI i18n

    So far, we’ve translated simple static texts, but real-world apps often need pluralization—handling different word forms depending on a number.

    For example, we want to return:

    • “You have 0 new messages”
    • “You have 1 new message”
    • “You have 5 new messages”

    This depends on the count parameter, which we’ll pass as a GET query parameter.

    Step 1: Update translation files

    Plural rules differ between languages. English has simple singular/plural forms, while some languages have more complex rules (like Russian or Arabic).

    We’ll store pluralization patterns in messages.json using a {count} placeholder.

    English (locales/en/messages.json)

    {
      "messages": {
        "one": "You have {count} new message",
        "other": "You have {count} new messages"
      }
    }

    French (locales/fr/messages.json)

    {
      "messages": {
        "one": "Vous avez {count} nouveau message",
        "other": "Vous avez {count} nouveaux messages"
      }
    }

    Step 2: Use Babel for pluralization

    Update your main.py file with the following changes:

    from fastapi import FastAPI, Query
    from babel.core import Locale
    import json
    import os
    from fastapi_i18n_demo.config import settings
    
    # initial loading
    
    
    def get_plural_form(lang: str, count: int) -> str:
        """
        Determines the correct pluralization form using Babel's Locale class.
        """
        try:
            locale = Locale.parse(lang)  # Load locale-specific plural rules
            plural_form = locale.plural_form(count)  # Get the correct plural category
    
            return plural_form  # Expected values: one, two, few, many, other
        except Exception:
            return "other"  # Fallback to 'other' if something goes wrong
    
    
    # root endpoint here...
    
    
    @app.get("/messages")
    def get_messages(
        lang: str = Query(
            settings.default_locale, title="Language", description="Language code"
        ),
        count: int = Query(1, title="Count", description="Number of messages"),
    ):
        """
        Returns a localized message with correct pluralization.
        """
        plural_form = get_plural_form(lang, count)
        messages_dict = translations.get(lang, translations[settings.default_locale])[
            "messages"
        ]
    
        # Ensure we default to "other" if no exact match
        message_template = messages_dict.get(plural_form, messages_dict["other"])
    
        return {"message": message_template.format(count=count)}
    • get_plural_form(lang, count)
      • Uses Babel’s Locale class to determine the correct pluralization category (don’t forget to import it!)
      • Calls locale.plural_form(count), which returns “one”, “other”, or other plural categories based on CLDR rules.
      • If an error occurs, it defaults to “other” to prevent crashes.
    • /messages endpoint
      • Accepts lang (language code) and count (number of messages).
      • Calls get_plural_form() to get the correct pluralization category.
      • Retrieves the correct translation from the JSON files based on the plural key (one, other, etc.).
      • Defaults to "other" if no match is found.

    Brilliant, now our plurals are good to go!

    Adding support for RTL (right-to-left) languages in FastAPI i18n

    Some languages, like Hebrew (he), Arabic (ar), and Persian (fa), use right-to-left (RTL) writing. When adding a new language like Hebrew, we need to ensure that:

    1. Translations are correctly stored in RTL format.
    2. The FastAPI app handles RTL text properly.
    3. Text direction is considered in responses if needed.

    Step 1: Add Hebrew translations

    Create a new folder for Hebrew translations:

    mkdir -p locales/he

    Then, create a messages.json file inside locales/he/ with proper Hebrew translations:

    {
      "greeting": "שלום, עולם!",
      "messages": {
        "one": "יש לך {count} הודעה חדשה",
        "other": "יש לך {count} הודעות חדשות"
      }
    }

    In Hebrew, the structure of the sentence is different, and {count} should remain in place so that FastAPI can replace it dynamically.

    Step 2: Update config.py to support Hebrew

    Modify settings inside config.py:

    from pydantic_settings import BaseSettings
    from typing import List
    
    
    class Settings(BaseSettings):
        """
        Configuration settings for the FastAPI i18n setup.
        """
    
        supported_locales: List[str] = ["en", "fr", "he"]  # Added "he"
        default_locale: str = "en"
    
        class Config:
            env_prefix = "APP_"
    
    
    # Create a settings instance
    settings = Settings()

    This ensures that Hebrew is recognized as a valid language.

    Step 3: Test the Hebrew translation

    Now, restart the FastAPI app:

    poetry run uvicorn fastapi_i18n_demo.main:app --reload

    Test Hebrew responses, for example:

    curl "http://127.0.0.1:8000/?lang=he"

    Should give you:

    {"message": "שלום, עולם!"}

    Step 4: Handling RTL in responses (Optional)

    By default, FastAPI does not modify text direction, but if we want to explicitly mark RTL text, we can include a direction indicator in our responses.

    Modify read_root and get_messages to return text direction along with translations:

    def get_text_direction(lang: str) -> str:
        """
        Determines if a language is Right-to-Left (RTL) or Left-to-Right (LTR).
        """
        rtl_languages = {"ar", "he", "fa", "ur"}  # Common RTL languages
        return "rtl" if lang in rtl_languages else "ltr"
    
    
    @app.get("/")
    def read_root(
        lang: str = Query(
            settings.default_locale, title="Language", description="Language code"
        )
    ):
        """
        A simple endpoint that returns a localized greeting message.
        """
        return {
            "message": translations.get(lang, translations[settings.default_locale])[
                "greeting"
            ],
            "direction": get_text_direction(lang), # <=====
        }
    
    
    @app.get("/messages")
    def get_messages(
        lang: str = Query(
            settings.default_locale, title="Language", description="Language code"
        ),
        count: int = Query(1, title="Count", description="Number of messages"),
    ):
        """
        Returns a localized message with correct pluralization.
        """
        plural_form = get_plural_form(lang, count)
        messages_dict = translations.get(lang, translations[settings.default_locale])[
            "messages"
        ]
    
        # Ensure we default to "other" if no exact match
        message_template = messages_dict.get(plural_form, messages_dict["other"])
    
        return {
            "message": message_template.format(count=count),
            "direction": get_text_direction(lang),  # <=====
        }

    This is it!

    Adding datetime localization in FastAPI i18n

    When working with international users, it’s important to display dates and times in the correct format for each locale. Different languages and regions use different date formats (e.g., MM/DD/YYYY in the US vs. DD/MM/YYYY in Europe).

    To handle this, we’ll use Babel’s datetime formatting to properly localize dates.

    Step 1: Create a localize_datetime function

    Modify main.py to include a new function for localizing datetime values:

    from fastapi import FastAPI, Query
    from babel.core import Locale
    from babel.dates import format_datetime
    from datetime import datetime
    import json
    import os
    from fastapi_i18n_demo.config import settings
    
    def localize_datetime(lang: str) -> str:
        """
        Returns the current datetime formatted according to the given locale.
        """
        try:
            locale = Locale.parse(lang)  # Parse locale
            now = datetime.now()  # Get current time
            localized_time = format_datetime(now, locale=locale)  # Format datetime
            return localized_time
        except Exception:
            return str(
                datetime.now()
            )  # Fallback: Return raw datetime if localization fails
    • localize_datetime(lang: str) -> str
      • Uses Babel’s Locale class to parse the requested language.
      • Calls datetime.now() to get the current system time.
      • Uses format_datetime(now, locale=locale) to properly format the datetime based on the locale.
      • If something goes wrong (e.g., an unsupported language), it falls back to returning raw datetime as a string.

    Step 2: Create a /datetime endpoint

    Now, we add an endpoint to return the localized current datetime.

    @app.get("/datetime")
    def get_localized_datetime(
        lang: str = Query(
            settings.default_locale, title="Language", description="Language code"
        ),
    ):
        """
        Returns the current datetime formatted according to the given locale.
        """
        return {"datetime": localize_datetime(lang)}
    • Defines the /datetime endpoint, which returns the current time formatted based on the requested language.
    • Reads the lang query parameter, defaulting to the app’s configured locale.
    • Calls localize_datetime(lang) to format the datetime correctly for the given locale.
    • Returns a JSON response with the localized datetime string.

    Formatting currencies in FastAPI i18n

    Different regions display currencies in different formats. For example:

    • US Dollars: $1,234.56
    • French Euros: 1 234,56 €
    • Japanese Yen: ¥1,234

    To ensure correct formatting, we use Babel’s currency formatting utilities.

    Step 1: Create a function to format currencies

    Modify main.py to include currency localization:

    from babel.numbers import format_currency
    
    DEFAULT_CURRENCIES = {
        "en": "USD",
        "fr": "EUR",
        "he": "ILS",
    }
    
    def localize_currency(
        amount: float,
        currency_code: str | None,
        lang: str,
        currency_digits: bool = True,
        format_type: str = "standard",
    ) -> str:
        """
        Formats a given amount into the correct currency format for a given locale.
        If no currency code is provided, a default is selected based on the locale.
        """
        try:
            locale = lang  # Babel can handle short locale codes like "en", "fr"
    
            # If no currency is provided, use the default for the given language
            if not currency_code:
                currency_code = DEFAULT_CURRENCIES.get(lang[:2], "USD")
    
            return format_currency(
                amount,
                currency_code,
                locale=locale,
                currency_digits=currency_digits,
                format_type=format_type,
            )
        except Exception:
            return f"{amount} {currency_code or 'USD'}"  # Fallback if localization fails
    • Uses Babel’s format_currency() to format monetary values correctly based on the given locale.
    • DEFAULT_CURRENCIES dictionary provides a default currency for each language if none is specified.
    • Automatically selects a default currency by checking the first two letters of the locale (e.g., "fr""EUR").
    • Supports optional parameters:
      • currency_digits: Controls whether to show decimal places (useful for JPY).
      • format_type: Allows different currency display formats (e.g., standard, accounting).
    • Handles errors gracefully, falling back to a simple {amount} {currency} format if localization fails.

    Step 2: Add a new endpoint for currency localization

    Let’s add a new endpoint now:

    @app.get("/currency")
    def get_localized_currency(
        lang: str = Query(
            settings.default_locale, title="Language", description="Language code"
        ),
        amount: float = Query(1000.50, title="Amount", description="Monetary amount"),
        currency: str | None = Query(
            None,
            title="Currency",
            description="Currency code (optional, auto-selected if not provided)",
        ),
        currency_digits: bool = Query(
            True,
            title="Currency Digits",
            description="Whether to show decimal places (e.g., JPY has no cents)",
        ),
        format_type: str = Query(
            "standard",
            title="Format Type",
            description="Currency format type: standard, accounting, etc.",
        ),
    ):
        """
        Returns the given amount formatted as currency based on the requested locale.
        If no currency is provided, the default for that locale is used.
        """
        return {
            "formatted_currency": localize_currency(
                amount, currency, lang, currency_digits, format_type
            )
        }
    • Defines the /currency endpoint to return a formatted monetary value based on the given locale.
    • Accepts query parameters:
      • lang: The requested language (defaults to the app’s configured locale).
      • amount: The monetary value to format.
      • currency: The currency code (optional, defaults based on locale).
      • currency_digits: Controls whether to show decimal places (e.g., JPY usually has none).
      • format_type: Allows specifying the currency format style (standard, accounting, etc.).
    • Calls localize_currency() to properly format the amount according to the requested locale.
    • Returns a JSON response with the correctly formatted currency string.

    Automatically detecting the user’s preferred locale

    Instead of requiring users to pass ?lang=xx in every request, we can automatically detect the preferred language from the Accept-Language HTTP header. This allows us to serve localized content without extra effort from the user.

    To avoid writing custom parsing logic, we’ll use the langcodes package, which simplifies language detection and matching.

    Step 1: Implement locale detection

    Modify main.py to include a helper function for detecting the best language:

    from fastapi import FastAPI, Query, Request
    
    def detect_best_locale(request: Request) -> str:
        """
        Detects the user's preferred language based on the Accept-Language header.
        Strips regional codes (e.g., 'en-US' -> 'en') and matches the best available locale.
        """
        accept_language = request.headers.get("Accept-Language", "")
    
        # Extract language codes from header and strip regions (e.g., 'en-US' -> 'en')
        parsed_languages = [tag.split("-")[0] for tag in accept_language.split(",")]
    
        # Find the first matching language from our supported locales
        for lang in parsed_languages:
            if lang in settings.supported_locales:
                return lang
    
        return settings.default_locale  # Fallback if no match is found
    

    This function extracts the preferred language from the Accept-Language HTTP header and matches it against the supported locales.

    • Retrieves the Accept-Language header from the request.
    • Splits the header into language tags and removes any regional codes (e.g., en-US becomes en).
    • Iterates through the extracted language codes and returns the first match from the supported locales.
    • If no match is found, falls back to the default locale.

    This approach ensures that users receive content in their preferred language without needing to manually specify it.

    Step 2: Adjust your endpoints

    Now you’ll need to adjust your endpoints to avoid immediately reading the default locale if the lang GET param is missing:

    @app.get("/")
    def read_root(
        request: Request,
        lang: str = Query(None, title="Language", description="Language code (optional)"),
    ):
        """
        A simple endpoint that returns a localized greeting message.
        """
        detected_lang = lang or detect_best_locale(request)
    
        return {
            "message": translations.get(
                detected_lang, translations[settings.default_locale]
            )["greeting"],
            "direction": get_text_direction(detected_lang),
        }

    This endpoint returns a localized greeting message while automatically detecting the user’s preferred language.

    • Accepts an optional lang query parameter for manual language selection.
    • If lang is not provided, it calls detect_best_locale(request) to determine the best-matching language from the request headers.
    • Retrieves the correct translation from the stored translations, falling back to the default locale if necessary.
    • Calls get_text_direction(detected_lang) to determine whether the language is left-to-right (LTR) or right-to-left (RTL) and includes this in the response.

    Preserving the user’s preferred locale

    Once a user selects a language, we should remember their preference to avoid requiring ?lang=xx in every request. The best way to do this is by storing the selected locale in a cookie.

    Step 1: Modify the / endpoint to set a language cookie

    We update detect_best_locale to prioritize sources in the correct order:

    • Use the default locale if no valid language is found.
    • Check the lang query parameter. If it’s valid, use it.
    • Check the stored cookie (preferred_locale). If it’s valid, use it.
    • Check the Accept-Language header and extract the best match.
    def detect_best_locale(request: Request, lang: str | None) -> str:
        """
        Determines the user's preferred locale by checking:
        1. Query parameter (`?lang=xx`)
        2. Stored cookie (`preferred_locale`)
        3. Accept-Language header
        4. Default locale (fallback)
        """
        # Step 1: Check if `lang` is provided and valid
        if lang and lang in settings.supported_locales:
            return lang
    
        # Step 2: Check if a language cookie is already set and valid
        preferred_locale = request.cookies.get("preferred_locale")
        if preferred_locale and preferred_locale in settings.supported_locales:
            return preferred_locale
    
        # Step 3: Check Accept-Language header and match best available locale
        accept_language = request.headers.get("Accept-Language", "")
        parsed_languages = [tag.split("-")[0] for tag in accept_language.split(",")]
    
        for detected_lang in parsed_languages:
            if detected_lang in settings.supported_locales:
                return detected_lang
    
        # Step 4: Fall back to default locale
        return settings.default_locale

    Step 2: Create a separate function to store the detected locale

    Instead of setting the cookie inside the endpoint, we define a dedicated function to handle language persistence. This function will:

    • Return the modified response
    • Create a response object
    • Set the preferred_locale cookie with the detected language
    from fastapi.responses import JSONResponse
    
    def store_locale(response: JSONResponse, lang: str) -> JSONResponse:
        """
        Stores the selected locale in a cookie for 30 days.
        """
        response.set_cookie(key="preferred_locale", value=lang, max_age=60 * 60 * 24 * 30)
        return response

    Step 3: Modify the / endpoint to use store_locale

    Now, the root endpoint only detects the locale, and we store it separately before returning the response.

    @app.get("/")
    def read_root(
        request: Request,
        lang: str = Query(None, title="Language", description="Language code (optional)"),
    ):
        """
        A simple endpoint that returns a localized greeting message and ensures the preferred language is stored.
        """
        detected_lang = detect_best_locale(request, lang)
    
        response = JSONResponse(
            {
                "message": translations.get(
                    detected_lang, translations[settings.default_locale]
                )["greeting"],
                "direction": get_text_direction(detected_lang),
            }
        )
    
        return store_locale(response, detected_lang)

    How this works

    • detect_best_locale(request, lang) determines the correct language.
    • store_locale(response, detected_lang) ensures the preferred language is stored for future requests.

    Final tweaks to FastAPI i18n

    We’ll move all i18n-related functions to a separate module and keep main.py focused only on defining endpoints.

    Step 1: Create an i18n module

    Inside your project, create a new directory i18n/ and add a file utils.py:

    mkdir i18n
    touch i18n/utils.py

    Step 2: Move all i18n-related logic to i18n/utils.py

    Move all localization functions, language detection, and currency/date formatting logic into i18n/utils.py:

    from fastapi import Request
    from fastapi.responses import JSONResponse
    from babel.core import Locale
    from babel.dates import format_datetime
    from babel.numbers import format_currency
    from datetime import datetime
    import json
    import os
    from fastapi_i18n_demo.config import settings
    
    DEFAULT_CURRENCIES = {
        "en": "USD",
        "fr": "EUR",
        "he": "ILS",
    }
    
    # Load translations dynamically based on config settings
    translations = {}
    
    for lang in settings.supported_locales:
        path = os.path.join("locales", lang, "messages.json")
        with open(path, encoding="utf-8") as f:
            translations[lang] = json.load(f)
    
    
    def store_locale(response: JSONResponse, lang: str) -> JSONResponse:
        """
        Stores the selected locale in a cookie for 30 days.
        """
        response.set_cookie(key="preferred_locale", value=lang, max_age=60 * 60 * 24 * 30)
        return response
    
    
    def detect_best_locale(request: Request, lang: str | None) -> str:
        """
        Determines the user's preferred locale by checking:
        1. Query parameter (`?lang=xx`)
        2. Stored cookie (`preferred_locale`)
        3. Accept-Language header
        4. Default locale (fallback)
        """
        if lang and lang in settings.supported_locales:
            return lang
    
        preferred_locale = request.cookies.get("preferred_locale")
        if preferred_locale and preferred_locale in settings.supported_locales:
            return preferred_locale
    
        accept_language = request.headers.get("Accept-Language", "")
        parsed_languages = [tag.split("-")[0] for tag in accept_language.split(",")]
    
        for detected_lang in parsed_languages:
            if detected_lang in settings.supported_locales:
                return detected_lang
    
        return settings.default_locale
    
    
    def localize_currency(
        amount: float,
        currency_code: str | None,
        lang: str,
        currency_digits: bool = True,
        format_type: str = "standard",
    ) -> str:
        """
        Formats a given amount into the correct currency format for a given locale.
        If no currency code is provided, a default is selected based on the locale.
        """
        try:
            locale = lang
            if not currency_code:
                currency_code = DEFAULT_CURRENCIES.get(lang[:2], "USD")
    
            return format_currency(
                amount,
                currency_code,
                locale=locale,
                currency_digits=currency_digits,
                format_type=format_type,
            )
        except Exception:
            return f"{amount} {currency_code or 'USD'}"
    
    
    def localize_datetime(lang: str) -> str:
        """
        Returns the current datetime formatted according to the given locale.
        """
        try:
            locale = Locale.parse(lang)
            now = datetime.now()
            return format_datetime(now, locale=locale)
        except Exception:
            return str(datetime.now())
    
    
    def get_text_direction(lang: str) -> str:
        """
        Determines if a language is Right-to-Left (RTL) or Left-to-Right (LTR).
        """
        rtl_languages = {"ar", "he", "fa", "ur"}
        return "rtl" if lang in rtl_languages else "ltr"
    
    
    def get_plural_form(lang: str, count: int) -> str:
        """
        Determines the correct pluralization form using Babel's Locale class.
        """
        try:
            locale = Locale.parse(lang)
            return locale.plural_form(count)
        except Exception:
            return "other"

    Step 3: Update main.py to import from i18n.utils

    Now, main.py will only contain FastAPI endpoints and import i18n-related functions from i18n.utils.

    from fastapi import FastAPI, Query, Request
    from fastapi.responses import JSONResponse
    from i18n.utils import (
        detect_best_locale,
        store_locale,
        get_text_direction,
        get_plural_form,
        localize_datetime,
        localize_currency,
        translations,
    )
    from fastapi_i18n_demo.config import settings
    
    app = FastAPI()
    
    
    @app.get("/")
    def read_root(
        request: Request,
        lang: str = Query(None, title="Language", description="Language code (optional)"),
    ):
        """
        A simple endpoint that returns a localized greeting message and ensures the preferred language is stored.
        """
        detected_lang = detect_best_locale(request, lang)
    
        response = JSONResponse(
            {
                "message": translations.get(
                    detected_lang, translations[settings.default_locale]
                )["greeting"],
                "direction": get_text_direction(detected_lang),
            }
        )
    
        return store_locale(response, detected_lang)
    
    
    @app.get("/messages")
    def get_messages(
        request: Request,
        lang: str = Query(None, title="Language", description="Language code (optional)"),
        count: int = Query(1, title="Count", description="Number of messages"),
    ):
        """
        Returns a localized message with correct pluralization.
        """
        detected_lang = detect_best_locale(request, lang)
        plural_form = get_plural_form(detected_lang, count)
        messages_dict = translations.get(
            detected_lang, translations[settings.default_locale]
        )["messages"]
    
        message_template = messages_dict.get(plural_form, messages_dict["other"])
    
        response = JSONResponse(
            {
                "message": message_template.format(count=count),
                "direction": get_text_direction(detected_lang),
            }
        )
    
        return store_locale(response, detected_lang)
    
    
    @app.get("/datetime")
    def get_localized_datetime(
        request: Request,
        lang: str = Query(None, title="Language", description="Language code (optional)"),
    ):
        """
        Returns the current datetime formatted according to the given locale.
        """
        detected_lang = detect_best_locale(request, lang)
    
        response = JSONResponse({"datetime": localize_datetime(detected_lang)})
    
        return store_locale(response, detected_lang)
    
    
    @app.get("/currency")
    def get_localized_currency(
        request: Request,
        lang: str = Query(None, title="Language", description="Language code (optional)"),
        amount: float = Query(1000.50, title="Amount", description="Monetary amount"),
        currency: str | None = Query(
            None,
            title="Currency",
            description="Currency code (optional, auto-selected if not provided)",
        ),
        currency_digits: bool = Query(
            True,
            title="Currency Digits",
            description="Whether to show decimal places (e.g., JPY has no cents)",
        ),
        format_type: str = Query(
            "standard",
            title="Format Type",
            description="Currency format type: standard, accounting, etc.",
        ),
    ):
        """
        Returns the given amount formatted as currency based on the requested locale.
        If no currency is provided, the default for that locale is used.
        """
        detected_lang = detect_best_locale(request, lang)
    
        response = JSONResponse(
            {
                "formatted_currency": localize_currency(
                    amount, currency, detected_lang, currency_digits, format_type
                )
            }
        )
    
        return store_locale(response, detected_lang)

    Brilliant job!

    Simplifying FastAPI i18n with Lokalise

    Translating all your application text is often the most time-consuming part of FastAPI internationalization. However, using a translation management system like Lokalise can streamline the process and save you from manually handling translations. Lokalise allows you to upload, manage, and download translations efficiently. You can collaborate with your team, order professional translations, take advantage of automatic translations, and many more.

    • Start by signing up at Lokalise and getting a free trial to explore the platform.
    • Install Lokalise CLI (command-line interface) tool.
    • Next, open the Lokalise website and navigate to the API tokens section in your profile. Generate a new read/write token, which will be used to interact with Lokalise from the command line.
    • Now, create a new Lokalise translation project with the “Web and mobile” type and set English as the base language. Choose other languages (French and Hebrew in our case) as target. Open the project settings and copy the project ID.
    • To upload your FastAPI translation files, run: lokalise2 file upload --token --project-id --lang_iso en --file locales/en/messages.json. Repeat this process for other locales.
    • After the upload is complete, go to your Lokalise dashboard and you will see all your translation keys and values. You can edit, delete, or add new translations easily using the web interface. Lokalise also provides powerful filtering tools, allowing you to quickly find untranslated keys.
    • Once your translations are finalized, you can download them back into your FastAPI project: lokalise2 file download --token --project-id --format json --dest locales/.

    Lokalise supports multiple platforms and file formats. It also offers features like professional translation services, screenshot text recognition, and integrations with development tools. By using Lokalise, you can eliminate manual translation file management and keep your FastAPI i18n workflow efficient and scalable.

    Conclusion

    Internationalizing a FastAPI application doesn’t have to be complicated. By structuring translations properly, handling pluralization, datetime, and currency localization, and automatically detecting and persisting user preferences, we’ve built a robust and flexible i18n system.

    We also explored Lokalise as a way to simplify translation management, making it easy to edit, update, and sync translations without manually handling JSON files. With tools like Babel for formatting, cookies for persistence, and FastAPI’s powerful query handling, your application can seamlessly serve content in multiple languages.

    With this foundation in place, your FastAPI app is now fully ready for multilingual users. As you scale, you can expand translations, integrate external translation services, or even localize UI elements dynamically. The key is to automate as much as possible while maintaining full control over language behavior.

    Now it’s time to ship your multilingual FastAPI app and make it accessible to a global audience!

    Further reading

    Related articles
    Stop wasting time with manual localization tasks. 

    Launch global products days from now.