Tutorials

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

Ilya Krukowski,Updated on February 19, 2025·19 min read
FastAPI visual

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, localized time, 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 translate JSON files, storing 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.

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

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 the Lokalise API, 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

Tutorials

Author

1517544791599.jpg

Lead of content, SDK/integrations dev

Ilya is a lead of content/documentation/onboarding at Lokalise, an IT tutor and author, web developer, and ex-Microsoft/Cisco specialist. His primary programming languages are Ruby, JavaScript, Python, and Elixir. He enjoys coding, teaching people and learning new things. In his free time he writes educational posts, participates in OpenSource projects, goes in for sports and plays music.

SRT scaled

What is an SRT file? Subtitle format explained

An SRT file is a plain text file used to add subtitles to videos. It’s one of the simplest and most common formats out there. If you’ve ever turned on captions on a YouTube video, there’s a good chance it was using an SRT file behind the scenes. People use SRT files for all kinds of things: social media clips, online courses, interviews, films, you name it. They’re easy to make, easy to edit, and they work pretty much everywhere without hassle. In this post, we’ll

Updated on June 19, 2025·Ilya Krukowski
Libraries for translating JavaScript apps

Libraries and frameworks to translate JavaScript apps

In our previous discussions, we explored localization strategies for backend frameworks like Rails and Phoenix. Today, we shift our focus to the front-end and talk about JavaScript translation and localization. The landscape here is packed with options, which makes many developers a

Updated on April 28, 2025·Ilya Krukowski
gitlab_hero

Syncing Lokalise translations with GitLab pipelines

In this guide, we’ll walk through building a fully automated translation pipeline using GitLab CI/CD and Lokalise. From upload to download, with tagging, version control, and merge requests. Here’s the high-level flow: Upload your source language files (e.g. English JSON files) to Lokalise from GitLab using a CI pipeline.Tag each uploaded key with your Git branch name. This helps keep translations isolated per feature or pull request

Updated on August 18, 2025·Ilya Krukowski

Stop wasting time with manual localization tasks.

Launch global products days from now.

  • Lokalise_Arduino_logo_28732514bb (1).svg
  • mastercard_logo2.svg
  • 1273-Starbucks_logo.svg
  • 1277_Withings_logo_826d84320d (1).svg
  • Revolut_logo2.svg
  • hyuindai_logo2.svg