How It Works
This page documents the technical internals of django-live-translations. We believe in being transparent about what the package does to your Django process, especially the parts that rely on monkey-patching and private APIs.
Gettext monkey-patching
The core mechanism is a monkey-patch applied to Django's translation system. On AppConfig.ready(), the package replaces _trans.gettext and _trans.pgettext on Django's internal django.utils.translation._trans module.
The patched functions wrap the originals:
- When
lt_activeisFalse(regular users), they call the original function and return immediately. The overhead is a single contextvar lookup - no markers are appended, no string registry is built, no assets are injected, and no response content is modified. - When
lt_activeisTrue(authorized users), they append an invisible marker to the translated string before returning it.
The middleware sets lt_active to True only when the request passes the configured permission check. For regular users, the gettext path is a near-zero-cost pass-through and the response is not modified - no markers, no JavaScript, no DOM changes. The only work that runs unconditionally is ensure_current() for the database backend (a single cache read per request).
Lazy variants (gettext_lazy, pgettext_lazy) are automatically covered because their proxies delegate to _trans.gettext / _trans.pgettext on evaluation.
Private API dependency
We patch django.utils.translation._trans, which is not a public Django API. This has been stable across Django 4.2 through 5.x, but could break in a future Django release. We test against all supported Django and Python versions in CI.
Zero-width character markers
To track which string on the rendered page corresponds to which msgid, the patched gettext appends an invisible 18-character marker to each translated string.
The marker format is:
FEFF + 16 × (200B | 200C) + FEFF
U+FEFF(byte order mark) serves as a boundary delimiterU+200B(zero-width space) represents bit0U+200C(zero-width non-joiner) represents bit1- The 16 bits encode a string-table ID (0--65535), which is a per-request index into the string registry
These characters are invisible in rendered HTML and survive Django's autoescaping, capfirst, html.escape, and string formatting operations.
Where markers can leak
The middleware strips markers from non-HTML responses (JSON, plain text, etc.) using a regex. However, markers could theoretically appear in:
- Cached template fragments that were rendered for an authorized user
- Signals or side effects triggered during response rendering
- Third-party middleware that captures response content before our middleware runs
If you encounter ZWC characters in unexpected places, ensure LiveTranslationsMiddleware is positioned after any middleware that might cache rendered content.
Middleware response processing
The middleware (LiveTranslationsMiddleware) has three jobs:
- API dispatch - requests to
/__live-translations__/*are routed directly to view functions, bypassing Django's URL resolver. Nourls.pyconfiguration is needed. - Asset injection - for HTML responses from authorized users, the middleware injects three elements before
</body>:- A
<link>tag loading the widget's CSS (edit mode outlines, modal styles, hint bar) - An inline
<script>containingwindow.__LT_CONFIG__(API base URL, CSRF token, configured languages, keyboard shortcuts) andwindow.__LT_STRINGS__(the per-request string registry mapping ZWC marker IDs to theirmsgidand context - this is how the client-side widget knows what each marker represents) - A
<script>tag loading the widget JavaScript
- A
- Marker stripping - for non-HTML responses (JSON APIs, file downloads), ZWC markers are stripped via regex to prevent them from leaking into API consumers.
The middleware skips Django admin URLs (/admin/) entirely to avoid interfering with the admin interface.
graph TD
A[Request arrives] --> B{API or admin path?}
B -->|Yes| C[Return early<br>API dispatch or pass through]
B -->|No| D{User is translator?}
D -->|No| E[Return response unmodified]
D -->|Yes| F["Gettext monkey-patch appends<br>ZWC marker to each translation"]
F --> G[Django renders response]
G --> H{HTML response?}
H -->|No| I[Strip ZWC markers<br>return clean response]
H -->|Yes| J[Middleware injects<br>string table + widget assets]
J --> K[Browser renders page]
K --> L["Widget decodes ZWC markers,<br>resolves msgids from string table"]
L --> M{Text node or attribute?}
M -->|Text node| N["Wrap in <lt-t> element"]
M -->|Attribute| O["Store msgid in data-lt-attrs"]
N --> P[Widget ready for editing]
O --> P
Catalog injection (database backend)
The database backend needs to make DB-stored overrides visible to Django's gettext() without modifying .po files. It does this by writing directly into Django's internal translation catalog objects.
Specifically, it accesses DjangoTranslation._catalog (a dict-like object that maps msgid strings to translations) and inserts overrides:
# Simplified version of what happens
catalog[msgid] = override_msgstr
# For context-based translations:
catalog[f"{context}\x04{msgid}"] = override_msgstr
Private API dependency
DjangoTranslation._catalog is an internal implementation detail of django.utils.translation.trans_real. The \x04 separator for context-based lookups is also an internal convention inherited from GNU gettext. Both have been stable for many Django versions, but are not guaranteed.
Cross-process synchronization
When running multiple processes (e.g. gunicorn workers), each process has its own copy of the translation catalogs. The database backend uses Django's cache framework to keep them in sync:
- When a translation is saved, a new UUID version is written to the cache
- On each request, the middleware calls
ensure_current(), which compares the local version against the cache - If stale, the process clears its translation catalogs (via Django's
translation_file_changedsignal) and re-injects all DB overrides
This means there's a brief window after a save where other processes may serve stale translations - typically just the duration of one request cycle.
PO file pending translations
The PO backend needs to support inactive translations (when TRANSLATION_ACTIVE_BY_DEFAULT is False) while keeping the .po file format valid. It does this by encoding the pending translation as base64 in the entry's translator comment:
# ltpending:SGVsbG8gV29ybGQ=
msgid "Hello"
msgstr "original translation"
The ltpending: prefix (deliberately without a hyphen - polib's textwrap breaks at hyphens) marks the comment as a pending override. The base64 encoding avoids issues with special characters in the comment field.
When the translation is activated, the pending value replaces msgstr and the comment is removed.
Note
These comments are visible if you inspect .po files directly. They are stripped from the translator hint displayed in the editing widget.
Frontend widget injection
The widget is a single JavaScript file (~2500 lines, vanilla JS, zero dependencies) bundled as a Django static file. It is injected as a <script> tag only for authorized users.
The widget:
- Walks the DOM looking for ZWC boundary characters (
U+FEFF) - Decodes the 16-bit ID from the ZWC sequence
- Looks up the
msgidand context inwindow.__LT_STRINGS__ - Strips the markers and wraps the text node in an
<lt-t>custom element (an inert inline element with no shadow DOM) - Attaches click handlers for edit mode
The <lt-t> element was chosen because it's an unknown HTML element that browsers treat as an inline <span>-like element, with no default styling or semantic meaning. All widget styles are prefixed with .lt- to avoid conflicts with the host page.
Test suite
The package is tested at multiple levels to catch regressions across the full stack:
- 440+ unit tests covering all core modules, backends, views, admin, and error handling
- 270+ end-to-end tests using Playwright against a real Django dev server
- E2E tests run against both backends (PO and database), each with their own Django server instance
- CI matrix tests across Python 3.12--3.14 and Django 4.2--6.0
- CI enforces 90%+ unit test coverage; e2e coverage is tracked separately
- Type checking with pyrefly and linting with ruff run on every push and PR
- Benchmarks verify runtime overhead stays within bounds: < 10% for normal users, < 100% for translators. Normal users (not authorized for live translation) only pay for a single contextvar lookup per
gettext()call: no markers, no injection, no response modification. In practice the overhead is ~1-2% in a pure translation-rendering scenario (no DB hits, network I/O, etc.), so in real applications the relative cost is even lower. Run withpytest tests/benchmarks/.
Performance characteristics
| Scenario | Overhead |
|---|---|
| Regular user (not authorized) | One contextvar lookup per gettext() call. No markers, no injection, no response modification. DB backend adds one cache read per request (ensure_current()). |
| Authorized user, edit mode off | ZWC encoding + string registry per gettext() call, asset injection on response |
| Authorized user, edit mode on | Same as above, plus client-side DOM walking |
| Database backend, per request | One cache read (ensure_current()), occasional full catalog re-injection |