If your Python application already uses gettext, its translations probably live in .po files. Source code supplies a message ID, gettext looks up that ID in the catalog, and the application renders the translation with the values for this request.
That workflow is mature, but interpolation is often held together by convention:
# A translator must preserve the %(name)s placeholder.
_('Hello %(name)s') % {'name': name}
Python 3.14 introduced template strings (t-strings). Unlike an f-string, a t-string retains its literal text, expressions, and evaluated values as structured data:
name = "Ada"
template = t"Hello {name}"
That is attractive for translation: the message and its values are no longer fused into one ordinary string before the i18n layer sees them. But a t-string does not automatically become a gettext catalog entry, and an existing application cannot replace thousands of calls in one release.
I built gettext-tstrings, an alpha library that bridges that gap. Its goal is deliberately narrow:
Let an existing Python gettext application adopt t-strings one call site at a time while keeping its normal
.po/.potcatalog workflow.
This article explains the problem, separates standard Python/gettext behavior from what the library adds, and reports a Windows migration check against v0.1.0a8. It is not a claim that t-strings replace gettext, or that this alpha package is ready for every production system.
What needs to fit together
There are three independent pieces:
Python source code -> extraction -> .pot/.po catalog -> runtime lookup
| | | |
t"Hello {name}" stable message ID translator edits render values
-
gettext is the established translation system. A
.pofile maps a message ID (msgid) to each language's text. -
Babel is commonly used to extract translatable messages from Python source into a
.pottemplate. - t-strings are Python 3.14 structured templates. They are not a replacement for a translation catalog.
gettext-tstrings adds a small bridge: it turns an accepted t-string shape into a stable msgid, keeps translations as catalog data rather than executable expressions, and validates placeholders before a damaged translation reaches a user.
The standard pieces are not new. The project-specific question was whether old gettext calls and this new bridge could coexist in one real catalog without a flag-day migration.
The migration target: old and new calls in one file
The intended change is local. Existing calls keep working while a maintainer converts a selected call site:
from gettext_tstrings import tr
name = "Ada"
count = 2
old_style = _("Legacy message")
new_style = tr(t"Hello {name}")
plural = ngettext("One legacy file", "{count} legacy files", count)
The important promise is not that the syntax is shorter. It is that extraction still produces one catalog containing all three forms.
For the t-string extractor, the Babel mapping is small:
[gettext_tstrings: **.py]
encoding = utf-8
I used the normal Babel command rather than a special migration tool:
uv run --no-sync pybabel extract `
-F babel.cfg `
-o .verification/messages.pot `
examples
The resulting POT preserved the ordinary gettext entry:
#: examples/mixed_migration.py:6
msgid "Legacy message"
msgstr ""
It added the t-string as a standard brace-format message, with a marker that identifies the stronger contract:
#. gettext-tstrings
#: examples/app.py:31 examples/app.py:37 examples/mixed_migration.py:7
#, python-brace-format
msgid "Hello {name}"
msgstr ""
And it kept the existing plural in that same catalog:
#: examples/mixed_migration.py:8
#, python-brace-format
msgid "One legacy file"
msgid_plural "{count} legacy files"
msgstr[0] ""
msgstr[1] ""
That is the result I needed before considering a migration: no all-at-once replacement, no second catalog, and no change to how translators edit .po files.
What the library changes—and what it does not
The bridge intentionally accepts only simple named placeholders such as {name}. It does not try to serialize arbitrary Python expressions into translations.
That restriction makes the catalog entry readable and reviewable. It also allows the runtime to check whether a translator removed, added, or misspelled a placeholder. A caller can use a fallback behavior for a damaged catalog or request a strict failure, depending on the application's policy.
The library does not make a translation correct by itself. It does not replace Babel, GNU gettext tooling, translator review, locale testing, or a deployment team's own concurrency checks. Its role is smaller: preserve a structured template long enough to create and validate a conventional gettext message.
What I tested
I checked the exact released source below rather than describing an unpinned development checkout:
| Component | Version or environment |
|---|---|
gettext-tstrings |
0.1.0a8, commit 3b65baebfc710a750b943073a3c11b6596e396e3
|
| Python | CPython 3.14.6, 64-bit |
| Extraction | Babel 2.18.0 |
| OS | Windows 11 10.0.26200
|
| CPU | AMD Ryzen 5 3600XT, 6 cores / 12 logical processors |
| Environment tool | uv 0.11.28 with the committed lockfile |
I created that environment without allowing uv to download another Python interpreter:
uv sync `
--python "C:\Users\yusuk\AppData\Local\Python\pythoncore-3.14-64\python.exe" `
--no-python-downloads `
--frozen
I then ran the suite with coverage:
uv run --no-sync pytest `
--cov=gettext_tstrings `
--cov-report=term-missing
The result on that machine was:
452 tests collected
449 passed, 3 skipped in 8.52s
905 statements, 310 branches
100% coverage
The skips are part of the result, not a footnote. All three required GNU gettext tools, which were not installed on this Windows machine. Runtime, extraction, checker, conformance, retention, and locale-binding tests passed there; GNU msgfmt integration was not demonstrated.
If your release process uses GNU gettext, keep that compiler check in CI. A green Python suite is not proof that every external tool in a translation pipeline is configured correctly.
What the Windows benchmark did—and did not—show
I ran benchmarks/runtime.py in five separate processes. These medians help set expectations on this machine:
| Path | Median ns/op |
|---|---|
| f-string | 55.7 |
gettext(str).format |
316.2 |
compiled.render |
304.1 |
compile_template |
517.5 |
tr with one field |
901.6 |
tr with two fields |
1,271.4 |
Translator with one field |
1,046.0 |
ngettext with one field |
1,758.9 |
For the common one-field tr path, the five runs ranged from 896.0 to 960.9 ns/op. On this particular Windows/AMD system, validation and catalog rendering stayed below one microsecond for that path.
That is not a universal performance claim. An earlier Apple Silicon measurement was roughly 0.4 microseconds for a broadly similar operation; CPU, OS, Python patch release, and package version make that comparison unsuitable for capacity planning. Run the benchmark on the interpreter and hardware that will actually serve your users.
A practical adoption checklist
I would consider a gradual migration only when all of these are true:
- The application can run on Python 3.14 or later.
- The team can pin a released version or commit while the package is alpha.
- Old calls and new t-string calls extract into one POT, and the catalog diff is reviewed.
- Placeholder validation runs in both the Babel and GNU gettext stages that the release pipeline uses.
- Broken translations are tested in fallback and strict modes.
- Locale binding is tested under the application's actual thread, task, or request-concurrency model.
- Runtime cost is measured in the deployment environment rather than copied from this article.
Wait if you need arbitrary expressions in translations, cannot move to Python 3.14, or have not validated the same catalog compiler and locale behavior that your release process depends on.
The useful conclusion
The valuable result was not a synthetic benchmark or a new syntax. It was a smaller operational claim that the evidence supports:
In the tested
v0.1.0a8Windows environment, a gettext codebase could extract ordinary gettext calls, plural calls, and supported t-string calls into one catalog while retaining explicit placeholder validation.
That gives a maintainer a reversible migration path: choose one call site, inspect the POT diff, test the affected locale behavior, and continue only if the existing workflow remains intact.
The exact contract is in SPEC.md, and the release tested here is v0.1.0a8.
If you maintain a gettext-based Python project, what would you verify before moving the first call site: extraction, placeholder compatibility, catalog compilation, or locale behavior under concurrency?
Disclosure: I used an AI assistant to organize the public source material, structure the migration test report, and edit the prose. I ran and reviewed the commands, source references, and results included above before publication.
Top comments (0)