Internacionalizar una aplicación no es añadir un diccionario de traducciones a mano — es establecer un pipeline que separa el código de los textos, permite que traductores humanos trabajen sin tocar Python, y carga las traducciones correctas en tiempo de ejecución según el locale del usuario. El módulo gettext de la stdlib implementa exactamente ese pipeline, siguiendo el estándar GNU gettext que llevan décadas usando proyectos de todo el mundo.
La pieza central es el marcador de strings traducibles: cuando escribes _('Hello, world') en tu código, no estás traduciendo nada todavía. Estás anotando que esa cadena puede traducirse, de modo que herramientas externas puedan encontrarla automáticamente. El underscore _ es por convención el nombre del callable que hace la traducción en tiempo de ejecución — si no hay traducción cargada, devuelve la cadena original sin modificarla. Eso significa que el código funciona en cualquier idioma incluso antes de que exista un solo archivo de traducción.
El flujo completo tiene tres fases bien diferenciadas. Primero, extracción: xgettext escanea tus .py buscando llamadas a _() y genera un archivo .pot (Portable Object Template) — una plantilla con todos los strings encontrados, sin traducciones. Segundo, traducción humana: los traductores copian ese .pot a archivos .po por locale (es_ES, fr_FR, etc.) y rellenan las traducciones. Tercero, compilación: msgfmt convierte cada .po a un archivo .mo binario que gettext puede mapear en memoria y buscar en O(1). En disco el .po es legible por humanos; el .mo está optimizado para lectura rápida por la máquina.
Si te saltaras la compilación a .mo y cargaras los .po directamente, cada lookup requeriría parsear texto plano. Si no marcaras los strings con _(), xgettext no podría encontrarlos y los traductores tendrían que adivinar qué strings existen. El contrato entre las tres fases es precisamente por qué el sistema escala.
Para formateo de fechas, números y monedas entra Babel, que implementa el estándar CLDR (Common Locale Data Repository). gettext solo traduce texto; Babel sabe que en España el decimal es coma y en EE.UU. es punto, o que en Japón las fechas van año-mes-día.
Ejemplo completo
La estructura de archivos que vas a necesitar:
myapp/
├── myapp.py
├── extract.cfg # config para pybabel
└── locale/
├── messages.pot # generado por xgettext / pybabel extract
└── es_ES/
└── LC_MESSAGES/
├── messages.po # editado por el traductor
└── messages.mo # compilado con msgfmt / pybabel compile
# myapp.py
import gettext
import locale
from pathlib import Path
from datetime import datetime
from decimal import Decimal
from babel.dates import format_datetime
from babel.numbers import format_currency
# ── Configuración del sistema de traducción ──────────────────────────────────
LOCALE_DIR = Path(__file__).parent / "locale"
def get_translator(lang: str) -> gettext.GNUTranslations:
"""
Carga el .mo para el locale dado. Si no existe, devuelve un traductor
nulo que retorna los strings originales sin modificación.
"""
try:
translation = gettext.translation(
domain="messages", # nombre base de los archivos .po/.mo
localedir=LOCALE_DIR,
languages=[lang],
)
except FileNotFoundError:
# Fallback seguro: no explota si el .mo no existe todavía
translation = gettext.NullTranslations()
return translation
def run_app(lang: str) -> None:
translation = get_translator(lang)
# Instala _() en el namespace de builtins para todo el proceso.
# Alternativa más explícita: _ = translation.gettext
translation.install()
# A partir de aquí, _() resuelve traducciones del locale cargado
print(_("Welcome to the application"))
print(_("You have %(count)d new messages") % {"count": 5})
# ── Babel: formateo según locale ─────────────────────────────────────────
babel_locale = lang.replace("_", "-") # gettext usa es_ES, Babel usa es-ES
now = datetime.now()
# format_datetime sabe que en España el orden es "martes, 10 de junio de 2025"
print(format_datetime(now, format="long", locale=babel_locale))
price = Decimal("1234567.89")
# format_currency aplica símbolo, separadores y posición según el locale
print(format_currency(price, "EUR", locale=babel_locale))
print(format_currency(price, "USD", locale="en-US"))
if __name__ == "__main__":
import sys
lang = sys.argv[1] if len(sys.argv) > 1 else "en_US"
run_app(lang)
El archivo .po para español que el traductor edita:
# locale/es_ES/LC_MESSAGES/messages.po msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" msgid "Welcome to the application" msgstr "Bienvenido a la aplicación" msgid "You have %(count)d new messages" msgstr "Tienes %(count)d mensajes nuevos"
Para generar y compilar los archivos desde la línea de comandos:
# Extraer strings marcados con _() a messages.pot pybabel extract -F extract.cfg -o locale/messages.pot . # Crear el .po para un nuevo locale (solo la primera vez) pybabel init -i locale/messages.pot -d locale -l es_ES # Después de que el traductor edite el .po, compilar a .mo pybabel compile -d locale # Con gettext puro (sin Babel) los comandos equivalentes son: # xgettext -o locale/messages.pot myapp.py # msgfmt locale/es_ES/LC_MESSAGES/messages.po -o locale/es_ES/LC_MESSAGES/messages.mo
Lo que hace cada pieza y por qué importa
gettext.translation() recibe el domain (que es simplemente el nombre de tus archivos .po/.mo), el directorio donde viven los locales, y la lista de idiomas en orden de preferencia. Retorna un objeto GNUTranslations que tiene el .mo mapeado en memoria. El NullTranslations como fallback es un patrón defensivo importante: durante desarrollo, cuando los .mo no están compilados todavía, el código sigue funcionando con los strings en inglés en lugar de lanzar una excepción.
translation.install() inyecta _ en builtins, lo que significa que cualquier módulo importado después ya puede usar _() sin importar nada. Es conveniente en aplicaciones de proceso único, pero en aplicaciones web con múltiples usuarios concurrentes puede ser un problema serio — vuelvo a eso en la sección de errores.
La interpolación %(count)d new messages usa formato por nombre en lugar de posicional (%d) porque los traductores pueden necesitar reordenar los elementos de la frase, y con formato posicional eso sería imposible. En Python 3 puedes usar también .format() pero debes marcar el string completo como una sola unidad traducible, incluyendo los placeholders.
Fíjate en la separación entre gettext y Babel: gettext solo hace lookup de strings en el .mo. Babel formatea datos según el locale usando el CLDR, que contiene reglas para más de 700 locales. format_datetime no solo cambia el orden de día/mes/año — también localiza el nombre del mes, el día de la semana, el separador AM/PM cuando aplica, y la zona horaria. format_currency maneja la posición del símbolo (en algunas culturas va después del número), el separador de miles, y el número de decimales según la moneda.
Errores que debes conocer
Error: Usar translation.install() en una aplicación web concurrente hace que todas las peticiones compartan el mismo _() global — si dos peticiones con locales distintos se solapan, una sobreescribe la traducción de la otra.
# ❌ Wrong — en un handler de Django/Flask/FastAPI
def handle_request(user_locale: str):
translation = get_translator(user_locale)
translation.install() # contamina builtins para todos los threads
return _("Welcome to the application")
# ✅ Right — usa el callable directamente, sin tocar builtins
def handle_request(user_locale: str):
translation = get_translator(user_locale)
_ = translation.gettext # local al scope de esta función
return _("Welcome to the application")
Vincula _ como variable local; así cada request tiene su propio callable y no hay estado compartido.
Error: Construir el string antes de marcarlo con _() impide que xgettext lo encuentre y rompe la interpolación de traducciones.
# ❌ Wrong — concatenación antes del marcador
msg = "Hello, " + username + "!"
print(_(msg)) # xgettext no puede extraer una variable
# ✅ Right — el template completo va dentro de _()
msg = _("Hello, %(name)s!") % {"name": username}
print(msg)
xgettext busca literales de string dentro de _(), no variables. Además, la concatenación hace imposible que el traductor reordene “Hello” y el nombre según la gramática de su idioma.
Error: Distribuir la aplicación sin los archivos .mo compilados porque estaban en .gitignore junto con otros artefactos generados.
# ❌ Wrong — .gitignore genérico que excluye binarios *.mo # ✅ Right — los .mo son artefactos de build, pero deben estar en el paquete # En setup.cfg o pyproject.toml incluye los .mo explícitamente: # [options.package_data] # myapp = locale/*/LC_MESSAGES/*.mo
Los .mo no son código fuente pero sí son parte del artefacto desplegable; exclúyelos del control de versiones si quieres, pero asegúrate de que el proceso de build los compile y el empaquetado los incluya — pybabel compile debe ser un paso en tu CI.