For twenty years, internationalizing Python code has meant writing this:
_("Hello %(name)s") % {"name": name} # % style
_("Hello {name}").format(name=name) # or .format style
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=...)
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
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
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
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}"
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
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=Truerestores 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'sgettext_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
Links: GitHub · PyPI · SPEC · related: CPython discussion, Babel #1206
Top comments (0)