How to Translate Drupal .po Files with AI

Most people associate .po files with WordPress, but the gettext format predates WordPress by decades and powers interface translation across the entire open-source world. Drupal is one of its biggest users. If you run a multilingual Drupal site, every menu label, field description, error message, and module string flows through .po files exactly the way it does in WordPress, just with a different placeholder dialect and a different import workflow. And just like WordPress, the moment your site outgrows a handful of strings, hand-translating those files becomes the bottleneck.
This guide is about how to translate Drupal po files with AI without breaking the things that make Drupal Drupal. We will walk through Drupal's translation system, where the strings actually come from, the placeholder syntax that differs from WordPress and absolutely must be preserved, and the full export, translate, and re-import loop including the drush commands that make it repeatable.
How Drupal's Translation System Works
Drupal handles interface translation through the core locale module (sometimes called "Interface Translation" in modern Drupal). Once enabled, it manages a database table of source strings and their translations per language, and it can import and export those translations as standard gettext .po files.
The admin interface lives at /admin/config/regional/translate. From there you can search untranslated strings, edit them inline, and crucially, import a .po file to bulk-load translations or export the current state to a .po file for offline editing. That export/edit/import cycle is the workflow we are optimizing.
Unlike WordPress, where each plugin and theme ships its own .po and .mo files in a languages/ directory, Drupal centralizes interface strings in its database and treats .po as the interchange format. You export, work on the file, and import it back. The compiled .mo step that WordPress requires is handled internally.
Where the Strings Come From
Drupal strings originate from three places, and a complete translation has to cover all of them:
- Core ships thousands of translatable strings for the admin UI, forms, and system messages.
- Contrib modules (Views, Webform, Commerce, and the rest) each register their own strings through the
t()function. - Themes contribute template strings, region labels, and theme-setting descriptions.
When you export from /admin/config/regional/translate, you can scope the export to translated, untranslated, or all strings. For a fresh translation pass, exporting only untranslated strings gives you a focused .po file with exactly the work that remains.
A practical consequence of this three-source structure: your translation work is never truly "done." Every time you add a contrib module, enable a new feature, or update Drupal core, a fresh batch of untranslated msgid entries appears in the locale tables. This is why Drupal teams treat translation as a recurring pipeline rather than a one-time launch task. The same export, translate, re-import loop runs on every deployment that touches modules, and the faster that loop is, the less translation debt accumulates between releases.
It is also worth noting that Drupal can pull community-contributed translations automatically from localize.drupal.org for core and popular contrib modules. Those cover the common strings, but they rarely cover your custom modules, your theme, or the project-specific phrasing your site actually uses. The gap between the community translation and a fully localized site is exactly the work an AI pass closes quickly.
Drupal Placeholders Are Not WordPress Placeholders
Here is the single most important thing to understand before sending any Drupal .po file to an AI translator: Drupal does not use the printf-style %s and %1$s placeholders that dominate WordPress. Drupal's t() function uses three distinct placeholder prefixes, each with different escaping behavior:
@variable— the value is HTML-escaped, the safe default for user-supplied text.%variable— the value is escaped and wrapped in<em>emphasis markup.:placeholder— used for URL attributes, run through URL sanitization.
A typical Drupal source string looks like this in the exported .po file:
#: core/modules/node/node.module
msgid "@count comments"
msgid_plural "@count comments"
msgstr[0] ""
msgstr[1] ""
#: core/modules/user/user.module
msgid "Welcome @name, you last logged in on %date."
msgstr ""
If a translator changes @count to @cuenta, replaces @name with the translated word for "name," or inserts a space turning @count into @ count, the placeholder no longer matches what the t() call passes in. Drupal then prints the literal token @count to the page instead of the actual number. The translation looks done but the site is visibly broken.
This is the same class of bug that hits WordPress %s strings, and we cover the general principle in depth in how to translate PO files without breaking code variables. The Drupal twist is simply that there are three placeholder styles instead of one, and a translation tool has to recognize all of them.
Why Generic Translators Fail Here
Paste that node.module string into a consumer machine-translation box and you will frequently get @count mangled, the <em>-bound %date localized into a different token, or the plural forms collapsed into one. None of these tools were built with gettext semantics in mind. They translate text; they do not understand that @count is a contract with the application code.
A gettext-aware translation pipeline with Syntax Locking treats @variable, %variable, and :placeholder as immutable tokens. They are locked before translation and restored after, so the surrounding sentence gets translated while the placeholders pass through untouched. The same lock covers WordPress %s and %1$s, i18next {{name}}, and inline HTML, which is why a single tool can serve a Drupal, Symfony, or Laravel project as easily as a WordPress one. Drupal plural forms via msgid_plural are preserved as plural forms rather than flattened, which matters because Drupal languages can have anywhere from one to six plural variants.
There is a subtler failure mode worth calling out too. Drupal strings frequently embed inline markup and links inside the translatable text, like Read the <a href=":url">documentation</a> where :url is a placeholder and the <a> tags must survive. A translator that does not understand the structure may translate the href attribute, drop the closing tag, or localize the placeholder name. Context-Aware AI that reads the whole string as a unit, combined with token locking, keeps both the markup and the :url contract intact while translating only the human-readable "documentation" text in between.
The Export, Translate, Re-Import Loop
The repeatable workflow has three stages, and drush makes the export and import scriptable so you are not clicking through the admin UI every time.
Step 1: Export the PO File
You can export from the UI at /admin/config/regional/translate by choosing a language and an export scope, or do it from the command line. The locale module exposes export through Drupal's translation services, and most teams script it. A typical drush invocation to trigger a translation import (the inverse, which we use in step three) looks like this:
# Re-import a translated PO file for Spanish, overwriting customized strings
drush locale:import es /var/www/translations/es-untranslated.po \
--type=customized --override=all
# Rebuild caches so the new strings render immediately
drush cache:rebuild
For the export side, the admin export form produces the .po file; many teams wrap the locale export service in a small custom Drush command for full automation. Either way you end up with a standard gettext .po file containing your untranslated msgid entries.
Step 2: Translate the File
This is the stage where AI replaces hours of manual editing. Upload the exported .po file to a cloud translator, pick your target language, and let it process. Because Drupal .po files for a large site routinely run past 10MB once core, contrib, and themes are combined, Smart Batching matters here: the file is chunked, translated, and reassembled without you splitting it by hand or hitting size limits. The output is a complete .po file with every msgstr filled and every @count, %date, and :url placeholder exactly where it started.
Step 3: Re-Import to Drupal
Import the translated file back through /admin/config/regional/translate or via the drush locale:import command shown above, then rebuild the cache. Drupal merges the translations into its locale tables, and the strings appear across the site immediately. Run the loop again whenever you add a module or update core, since each brings new untranslated strings.
One import detail to get right: the --override flag controls whether incoming translations replace existing ones. Use --override=all when you want the AI-translated file to be authoritative, or a more conservative setting if you have hand-tuned certain strings in the Drupal UI that you do not want overwritten. For most automated pipelines, treating the .po file as the source of truth and overriding everything keeps the system predictable: the file in version control is what the site shows, full stop.
For multilingual sites with several target languages, the loop runs once per language. Export the untranslated set, translate it into German, import it; export again, translate into Spanish, import it; and so on. Because each language is an independent .po file, you can run them in parallel, and a cloud translator processing them concurrently turns what used to be a week of contractor time into an afternoon.
Drupal PO Is One Format Among Several
It is worth noting that gettext .po is not the only way Drupal handles translation. Configuration and content entities can also be exchanged as XLIFF, which is the standard the Translation Management Tool (TMGMT) module leans on for professional translation vendor workflows. If your Drupal localization runs through XLIFF rather than interface .po files, the placeholder-preservation principles are identical but the file structure differs, and we cover that path in translating XLIFF files for Drupal, Symfony, and Angular.
For the interface translation layer, though, .po remains the workhorse. The export, AI-translate, re-import loop turns a multi-day manual chore into a few minutes of processing, and as long as the tool you use genuinely understands gettext placeholders, your @variable contracts survive intact and your Drupal site stays unbroken in every language you ship.
Ready to translate your Drupal
.pofiles without breaking a single@variable? Try SimplePoTranslate free — no credit card required. The free tier handles standard gettext.poand.potfiles with full Syntax Locking, so your Drupal placeholders pass through exactly as the code expects.