DEV Community

Yusuke Hayashi
Yusuke Hayashi

Posted on

Python 3.14 t-strings finally fix gettext i18n — stdlib said no, so I built the bridge

For twenty years, internationalizing Python code has meant writing this:

_("Hello %(name)s") % {"name": name}          # % style
_("Hello {name}").format(name=name)           # or .format style
Enter fullscreen mode Exit fullscreen mode

And for twenty years, the same bugs have shipped: .format() applied inside _() so the catalog never matches; a translator's typo in %(name)s crashing production in exactly one locale; a placeholder that can't move to where Japanese or German grammar needs it. The string being translated and the values being interpolated are glued together by convention and hope.

Python 3.14's template strings (PEP 750) change the raw material. A t-string is not a string — it's a structured object that carries the static text, the expressions, and the evaluated values separately:

name = "Ada"
template = t"Hello {name}"   # Template(strings=('Hello ', ''), interpolations=...)
Enter fullscreen mode Exit fullscreen mode

That structure is exactly what an i18n layer always wanted. So the obvious question: can _(t"Hello {name}") just... work?

The gap nobody owned

It turns out there was no answer for what happens between a t-string and a .po file. CPython considered and declined t-string support in stdlib gettext — with a core developer noting that a convention with this many design choices belongs on PyPI, where it can iterate. Babel has an open issue for native support that's been stalled since 2025. Meanwhile Python 3.14 is climbing past 8% of PyPI downloads.

The missing piece is small but load-bearing: a convention for turning a t-string into a gettext msgid, plus enforcement that translations stay compatible with it. That's what gettext-tstrings is — not a new i18n framework, just the bridge.

import gettext
from gettext_tstrings import Translator

_ = Translator(gettext.translation("messages", localedir="locales", languages=["ja"]))

name = "Ada"
print(_(t"Hello {name}"))                     # -> "Adaさん、こんにちは"

n = 3
print(_.ngettext(t"One file", t"{n} files", n))  # plural rules per language
Enter fullscreen mode Exit fullscreen mode

The catalog receives the complete sentence Hello {name}. The Japanese translation {name}さん、こんにちは reorders the placeholder — the thing %-style positional formatting never allowed.

The convention: boring on purpose

The whole msgid derivation fits in a sentence: literal braces are escaped, each interpolation becomes {name}, and only simple variable names are allowed. This is valid:

tr(t"Total: {amount:,.2f}")    # msgid: "Total: {amount}" — format spec stays in code
Enter fullscreen mode Exit fullscreen mode

These are rejected, at extraction time and at runtime, with the same rule:

tr(t"Hello {user.name}")         # attribute access
tr(t"Hello {get_name()}")        # function call
Enter fullscreen mode Exit fullscreen mode

Compute the value first: name = user.display_name(). Why so strict? Because any scheme that derives names from expressions (user.name to user_name?) becomes permanent — change it later and every existing catalog breaks. And because a translation is data, never code: a .po file can reorder and repeat {name}, but {name.__class__}, {name!r}, and {name:>9999} in a translation are all rejected. The catalog can't execute anything, can't reformat anything, can't be an expression language. The full convention is written down as a versioned contract in SPEC.md, so other tools can implement it and produce identical catalogs.

Your existing toolchain already validates this

Here's the part I didn't expect. Babel automatically marks these msgids with the standard python-brace-format flag:

#, python-brace-format
msgid "Hello {name}"
msgstr "Bonjour {wrong}"
Enter fullscreen mode Exit fullscreen mode

That one flag lights up the entire existing toolchain. GNU msgfmt refuses to compile the broken translation above:

$ msgfmt --check-format -o /dev/null ja.po
ja.po:24: a format specification for argument 'name' doesn't exist in 'msgstr'
msgfmt: found 1 fatal error
Enter fullscreen mode Exit fullscreen mode

Weblate runs its Python-brace-format check as translators type. Crowdin, Transifex, and POEditor flag placeholder mismatches in their QA. Zero configuration. Your translation vendor's pipeline from 2015 validates your t-string catalogs today. Extraction is the normal Babel workflow (pybabel extract with an entry-point extractor); PO and MO files are completely standard.

The parts that make it production-shaped

  • A broken catalog never crashes a render. If a translation's placeholders don't match, you get the source text and a logged warning — mirroring gettext's own contract. strict=True restores fail-loud for CI.
  • Per-request language via a contextvar: with use_translations(ja): ... — safe under concurrency, built for web frameworks.
  • Lazy translation for module-level strings: SAVE = lazy_gettext(t"Save changes") renders in whatever language is active when it's used, like Django's gettext_lazy.
  • Sub-microsecond overhead: ~0.4us per rendered message on CPython 3.14 (Apple Silicon). The cached render path is actually faster than a naive gettext(...).format(...). The difference from a bare f-string buys you validation and safety — this optimizes safety per nanosecond, not benchmarks.

Honest limitations

Python 3.14+ only, by nature. Extraction requires Babel (xgettext and pygettext can't parse t-strings — the SPEC exists partly so they someday can). Only simple {name} placeholders, by design. And it's an alpha: the core contract is small and tested (94 tests, 3 OSes), but the API deserves real-world bruises before 1.0.

If you maintain a Python project with existing .po catalogs and you're eyeing 3.14 — this migrates one call site at a time, and the extractor handles mixed _("old style") and _(t"new style") codebases in one pass. I'd love issues, especially disagreements with the SPEC while it's still cheap to change.

pip install gettext-tstrings
Enter fullscreen mode Exit fullscreen mode

Links: GitHub · PyPI · SPEC · related: CPython discussion, Babel #1206

Top comments (0)