DEV Community

Yusuke Hayashi
Yusuke Hayashi

Posted on

Can a Python gettext app adopt t-strings safely? A tested Python 3.14 migration path

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}
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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/.pot catalog 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
Enter fullscreen mode Exit fullscreen mode
  • gettext is the established translation system. A .po file maps a message ID (msgid) to each language's text.
  • Babel is commonly used to extract translatable messages from Python source into a .pot template.
  • 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The resulting POT preserved the ordinary gettext entry:

#: examples/mixed_migration.py:6
msgid "Legacy message"
msgstr ""
Enter fullscreen mode Exit fullscreen mode

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 ""
Enter fullscreen mode Exit fullscreen mode

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] ""
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I then ran the suite with coverage:

uv run --no-sync pytest `
  --cov=gettext_tstrings `
  --cov-report=term-missing
Enter fullscreen mode Exit fullscreen mode

The result on that machine was:

452 tests collected
449 passed, 3 skipped in 8.52s
905 statements, 310 branches
100% coverage
Enter fullscreen mode Exit fullscreen mode

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:

  1. The application can run on Python 3.14 or later.
  2. The team can pin a released version or commit while the package is alpha.
  3. Old calls and new t-string calls extract into one POT, and the catalog diff is reviewed.
  4. Placeholder validation runs in both the Babel and GNU gettext stages that the release pipeline uses.
  5. Broken translations are tested in fallback and strict modes.
  6. Locale binding is tested under the application's actual thread, task, or request-concurrency model.
  7. 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.0a8 Windows 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)