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): h
ttp://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:
- Configuration should be centralized – Keeping settings inside the main app file makes the code harder to manage as the project grows.
- Easier updates – If we need to change supported languages or the default locale, we don’t want to modify the application logic.
- 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 likeAPP_SUPPORTED_LOCALES
orAPP_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.
- Uses Babel’s
/messages
endpoint- Accepts
lang
(language code) andcount
(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.
- Accepts
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:
- Translations are correctly stored in RTL format.
- The FastAPI app handles RTL text properly.
- 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.
- Uses Babel’s
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
becomesen
). - 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 callsdetect_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!