Chapter 14: Localization
1. One App, Many Languages
Your application is live. A customer in Berlin arrives and sees English. A customer in Tokyo sees English. A customer in São Paulo sees English.
Tina4 localization lets you write translations once and switch the active locale at runtime. Store JSON files in src/locales/, call t() at the point of use, and set the active language from a query parameter, a cookie, or an environment variable. No third-party libraries required.
2. Locale Files
Create one JSON file per locale in src/locales/. The filename is the locale code.
src/
locales/
en.json
de.json
ja.json
pt.jsonsrc/locales/en.json:
{
"welcome": "Welcome",
"greeting": "Hello, {name}!",
"order": {
"placed": "Your order has been placed.",
"total": "Order total: {currency}{amount}",
"item_count": "{count} item in your order",
"item_count_plural": "{count} items in your order"
},
"errors": {
"not_found": "The requested resource was not found.",
"unauthorized": "You are not authorized to access this resource."
}
}src/locales/de.json:
{
"welcome": "Willkommen",
"greeting": "Hallo, {name}!",
"order": {
"placed": "Ihre Bestellung wurde aufgegeben.",
"total": "Bestellsumme: {currency}{amount}",
"item_count": "{count} Artikel in Ihrer Bestellung",
"item_count_plural": "{count} Artikel in Ihrer Bestellung"
},
"errors": {
"not_found": "Die angeforderte Ressource wurde nicht gefunden.",
"unauthorized": "Sie sind nicht berechtigt, auf diese Ressource zuzugreifen."
}
}3. The t() Function
from tina4_python.i18n import t
# Simple key
message = t("welcome")
# "Welcome"
# Nested key with dot notation
error = t("errors.not_found")
# "The requested resource was not found."
# Interpolation with {placeholder}
greeting = t("greeting", name="Alice")
# "Hello, Alice!"
order_total = t("order.total", currency="$", amount="99.99")
# "Order total: $99.99"Keys use dot notation for nested structures. Placeholders in curly braces are replaced with keyword arguments.
4. The I18n Class
For more control — setting the locale per request, loading custom locale paths — use the I18n class directly:
from tina4_python.i18n import I18n
i18n = I18n(locale="de", path="src/locales")
print(i18n.t("welcome"))
# "Willkommen"
print(i18n.t("greeting", name="Max"))
# "Hallo, Max!"
# Switch locale at runtime
i18n.set_locale("en")
print(i18n.t("welcome"))
# "Welcome"The I18n class loads locale files lazily. Setting the locale to "de" loads src/locales/de.json the first time a translation is requested. Subsequent requests for the same locale use the in-memory cache.
5. Setting the Locale at Runtime
From a Query Parameter
The simplest approach: read ?lang= from the request and set the locale.
from tina4_python.core.router import get
from tina4_python.i18n import I18n
@get("/api/welcome")
async def welcome(request, response):
lang = request.params.get("lang", "en")
i18n = I18n(locale=lang, path="src/locales")
return response({
"message": i18n.t("greeting", name="World"),
"locale": lang
})curl "http://localhost:7145/api/welcome?lang=en"
# {"message": "Hello, World!", "locale": "en"}
curl "http://localhost:7145/api/welcome?lang=de"
# {"message": "Hallo, World!", "locale": "de"}From the Accept-Language Header
A more standard approach uses the browser's language preference:
from tina4_python.core.router import get
from tina4_python.i18n import I18n
def detect_locale(request, default="en"):
accept = request.headers.get("Accept-Language", default)
# "de-DE,de;q=0.9,en;q=0.8" -> "de"
primary = accept.split(",")[0].split("-")[0].strip()
supported = ["en", "de", "ja", "pt"]
return primary if primary in supported else default
@get("/api/dashboard")
async def dashboard(request, response):
locale = detect_locale(request)
i18n = I18n(locale=locale, path="src/locales")
return response({
"welcome": i18n.t("welcome"),
"locale": locale
})From a Session or Cookie
@get("/api/home")
async def home(request, response):
locale = request.session.get("locale", "en")
i18n = I18n(locale=locale, path="src/locales")
return response({"message": i18n.t("welcome")})
@post("/api/locale")
async def set_locale(request, response):
locale = request.body.get("locale", "en")
request.session["locale"] = locale
return response({"locale": locale})6. The TINA4_LOCALE Environment Variable
Set the application's default locale in .env:
TINA4_LOCALE=deWhen TINA4_LOCALE is set, calls to t() without an explicit locale use German as the default. This is the locale loaded at application startup.
from tina4_python.i18n import t
# Uses TINA4_LOCALE from .env (e.g., "de")
message = t("welcome")
# "Willkommen"7. Interpolation with
Placeholders are written as {name} in the JSON file and filled via keyword arguments to t():
from tina4_python.i18n import I18n
i18n = I18n(locale="en", path="src/locales")
# Single placeholder
greeting = i18n.t("greeting", name="Alice")
# "Hello, Alice!"
# Multiple placeholders
total = i18n.t("order.total", currency="€", amount="149.99")
# "Order total: €149.99"Missing placeholders are left as-is:
i18n.t("order.total", currency="$")
# "Order total: ${amount}" -- {amount} not suppliedExtra keyword arguments are silently ignored.
8. Fallback Behaviour
When a key is missing from the active locale, Tina4 falls back through a chain:
- Active locale (
de) - Base language (
deif locale wasde_AT) - Default locale (
enor whateverTINA4_LOCALE_FALLBACKis set to) - The raw key string itself
TINA4_LOCALE=de
TINA4_LOCALE_FALLBACK=en// en.json
{
"beta_feature": "This feature is in beta."
}
// de.json
{
"welcome": "Willkommen"
// beta_feature not yet translated
}i18n = I18n(locale="de", path="src/locales")
i18n.t("beta_feature")
# Falls back to en.json: "This feature is in beta."
i18n.t("completely.missing.key")
# Returns "completely.missing.key" -- the key itselfYour app never crashes on a missing translation. It returns the raw key string, which your translation team can search for to find gaps.
9. Translating Templates
In Frond templates, call t() directly:
<h1>{{ t("welcome") }}</h1>
<p>{{ t("greeting", name=user.name) }}</p>
<div class="order-summary">
<p>{{ t("order.placed") }}</p>
<p>{{ t("order.total", currency="$", amount=order.total) }}</p>
</div>Pass the locale through the template context:
@get("/dashboard")
async def dashboard(request, response):
locale = request.params.get("lang", "en")
i18n = I18n(locale=locale, path="src/locales")
return response.render("dashboard.html", {
"t": i18n.t,
"user": {"name": "Alice"},
"locale": locale
})10. Exercise: Multi-Language API Responses
Build an API that returns translated error messages and UI strings based on the request locale.
Requirements
Create locale files for
en,fr, andeswith these keys:errors.not_founderrors.validationuser.createduser.greeting
Create
GET /api/users/{user_id}that:- Detects locale from
?lang=param (default:en) - Returns a user or a translated 404 message
- Detects locale from
Create
POST /api/usersthat:- Validates required fields and returns translated validation errors
- Returns a translated success message on creation
Test with:
# English (default)
curl "http://localhost:7145/api/users/999"
# {"error": "The requested resource was not found."}
# French
curl "http://localhost:7145/api/users/999?lang=fr"
# {"error": "La ressource demandée est introuvable."}
# Spanish
curl "http://localhost:7145/api/users/999?lang=es"
# {"error": "El recurso solicitado no fue encontrado."}
# Create user in French
curl -X POST "http://localhost:7145/api/users?lang=fr" \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "name": "Alice"}'11. Solution
src/locales/en.json:
{
"errors": {
"not_found": "The requested resource was not found.",
"validation": "Validation failed: {fields}"
},
"user": {
"created": "User account created successfully.",
"greeting": "Hello, {name}!"
}
}src/locales/fr.json:
{
"errors": {
"not_found": "La ressource demandée est introuvable.",
"validation": "Échec de validation : {fields}"
},
"user": {
"created": "Compte utilisateur créé avec succès.",
"greeting": "Bonjour, {name} !"
}
}src/locales/es.json:
{
"errors": {
"not_found": "El recurso solicitado no fue encontrado.",
"validation": "Error de validación: {fields}"
},
"user": {
"created": "Cuenta de usuario creada con éxito.",
"greeting": "¡Hola, {name}!"
}
}src/routes/users_i18n.py:
from tina4_python.core.router import get, post
from tina4_python.i18n import I18n
USERS = {
1: {"id": 1, "name": "Alice", "email": "alice@example.com"},
2: {"id": 2, "name": "Bob", "email": "bob@example.com"}
}
SUPPORTED_LOCALES = ["en", "fr", "es"]
def get_i18n(request):
lang = request.params.get("lang", "en")
if lang not in SUPPORTED_LOCALES:
lang = "en"
return I18n(locale=lang, path="src/locales")
@get("/api/users/{user_id}")
async def get_user(request, response):
i18n = get_i18n(request)
user_id = int(request.params["user_id"])
user = USERS.get(user_id)
if user is None:
return response({"error": i18n.t("errors.not_found")}, 404)
return response({
"user": user,
"greeting": i18n.t("user.greeting", name=user["name"])
})
@post("/api/users")
async def create_user(request, response):
i18n = get_i18n(request)
body = request.body
missing = [f for f in ["email", "name"] if not body.get(f)]
if missing:
return response({
"error": i18n.t("errors.validation", fields=", ".join(missing))
}, 400)
new_user = {
"id": max(USERS.keys()) + 1,
"name": body["name"],
"email": body["email"]
}
USERS[new_user["id"]] = new_user
return response({
"message": i18n.t("user.created"),
"user": new_user
}, 201)12. Gotchas
1. Missing locale file raises an error
Problem: I18n(locale="zh", ...) crashes because src/locales/zh.json does not exist.
Fix: Set TINA4_LOCALE_FALLBACK=en. Always validate user-supplied locale values against a supported list before creating the I18n instance.
2. Placeholder name mismatch
Problem: JSON has {firstName} but code calls t("greeting", first_name="Alice"). The placeholder is not replaced.
Fix: Placeholder names in JSON and keyword argument names in Python must match exactly. Use consistent naming conventions — either camelCase or snake_case throughout your locale files.
3. TINA4_LOCALE not set, t() uses "en"
Problem: t() returns English strings even though your app targets a different language.
Fix: Set TINA4_LOCALE=de (or your target locale) in .env. Without it, t() defaults to "en".
4. Locale files not reloading during development
Problem: You updated a locale file but the app still serves the old translation.
Fix: The I18n class caches locale files in memory. Restart the dev server or set a shorter cache duration during development. In production this is the correct behaviour.