What is pseudo-localization?

Pseudo-localization finds hard-coded strings and layouts that break under text expansion before you pay a translator. Here is how to generate and read it.

Pseudo-localization replaces your source text with a machine-generated variant that is still readable but obviously transformed. You then run the application in that fake locale and look at it.

Original:      Save changes
Pseudo:        [Šàávè çhàángéés ~~~~~]

Everything you can still read in plain unaccented English is a hard-coded string that was never extracted. Everything clipped, wrapped or overflowing is a layout that will break when real translations arrive. Both are found in an afternoon, before any money is spent on translation.

What the transformation does

Three separate tricks, each catching a different class of bug.

Accent every character. Save becomes Šàávè. Still legible to an English reader, immediately distinguishable from untransformed text. This is what makes missed extractions jump out — your eye skips the accented text and lands on the plain word.

It also exercises the encoding path end to end. If anything in your stack is not Unicode-clean — a database column, a template engine, a PDF generator, an email — it shows up here as mojibake rather than in production in Polish.

Pad the string. Šàávè ~~~~~~ simulates text expansion. German and Finnish routinely run 20–30% longer than English, so 30–40% padding is the usual setting. Anything that clips, truncates with an ellipsis, wraps into a broken layout or pushes a neighbour off screen is a real bug you have just found for free.

Short strings need proportionally more padding. A three-character label can triple; a paragraph rarely grows by more than a third.

Bracket the string. [Šàávè ~~~~~~] marks the boundaries. Missing brackets mean the string was truncated somewhere in the pipeline; adjacent brackets like ][ mean two strings were concatenated, which is an internationalization bug that will not survive translation into any language with different word order.

Some generators add a fourth trick: prefixing each string with its key, so you can see at a glance which translation key produced which piece of interface. Invaluable when you are trying to find where a string lives.

What it catches

Symptom Underlying bug
Plain unaccented text String never extracted into a language file
Text clipped or ellipsised Fixed-width container
Layout wraps or overflows No room for expansion
][ between words Concatenated sentence fragments
Missing closing bracket String truncated by a length limit
Mojibake, boxes, question marks Encoding problem in the pipeline
Placeholder rendered literally Interpolation broken by the transform
Text unchanged in one screen only That screen bypasses the i18n layer entirely

That last row is the one that surprises people. It is usually an admin panel, a PDF export, a transactional email or an error page — surfaces built at a different time, by a different person, that never went through the extraction pass.

What it does not catch

Being clear about the limits:

  • Nothing linguistic. It cannot tell you a translation is wrong, awkward or offensive.
  • Right-to-left layout problems. Standard pseudo-locales are still left-to-right. Test RTL with a real RTL locale, or use a dedicated bidirectional pseudo-locale that reverses text direction.
  • Genuinely different plural structures. It does not generate six Arabic plural forms.
  • Cultural issues — imagery, colour, iconography.

It is a mechanical check, and its value is precisely that it is mechanical: it runs the same way every time and needs no linguist.

Generating it

Most i18n libraries and toolchains have a pseudo-locale generator, and the transformation is simple enough to write yourself:

ruby
ACCENTS = {
  "a" => "àá", "e" => "éè", "i" => "ìí", "o" => "òó", "u" => "ùú",
  "c" => "çĉ", "n" => "ñń", "s" => "šś", "y" => "ýÿ"
}.freeze

def pseudo(text, padding: 0.4)
  # Leave placeholders alone — mangling them tests nothing and breaks rendering.
  parts = text.split(/(%\{[^}]+\}|\{\{[^}]+\}\}|%\d*\$?[sdf@]|\{\d+\})/)
  accented = parts.map.with_index { |part, i|
    i.odd? ? part : part.chars.map { |c| ACCENTS[c.downcase]&.chars&.sample || c }.join
  }.join
  "[#{accented} #{'~' * (text.length * padding).ceil}]"
end

pseudo("Save changes")        # => "[Šàvé çhàñgés ~~~~~]"
pseudo("Hello, %{name}!")     # => "[Héllò, %{name}! ~~~~~~]"

The important detail is the first line of the method: do not transform placeholders. Accenting %{name} into %{ñàmé} breaks interpolation and tests nothing useful — you already know placeholders must survive translation, and the placeholder cheat sheet covers the syntaxes to protect.

Be deterministic if you can. Randomly sampled accents mean the same string looks different on each run, which makes screenshot diffing useless.

Wiring it into a project

Treat the pseudo-locale as a real locale so it flows through the same machinery as everything else.

Give it a locale code. en-XA is a widely used convention (Android and Chrome use en-XA and ar-XB), and qps-ploc is the Windows equivalent. Both are in private-use or unassigned ranges, so neither collides with a real language.

In WebTranslateIt this is a custom language: take English as the base and append a suffix code and description, producing something like en_ploc named “English (pseudo)”. It then appears alongside your real target languages, with its own files generated automatically.

Generate it, do not translate it. The pseudo-locale should be produced from the current source file by a script, every time the source changes. If it drifts, it stops catching new strings — which is the main thing it exists to do.

Run it in CI. Generate the pseudo-locale, boot the app in it, screenshot the main flows, and diff against the previous run. Layout regressions from a new long string become a failing build rather than a discovery in week three of translation.

Make it easy to switch to. A query parameter or a developer menu item. If turning it on requires a rebuild, it will be used once.

Testing right-to-left separately

A standard pseudo-locale is still left-to-right, so it tells you nothing about whether your layout survives Arabic or Hebrew. That needs its own pass, and there are two ways to get it.

A bidirectional pseudo-locale wraps each string in Unicode right-to-left override characters, so the text renders reversed while remaining your own copy. Android ships ar-XB for exactly this. It exercises the layout without requiring a translation.

A real RTL locale with real text is the stronger test, because it also catches the mixed-direction cases — a English product name inside an Arabic sentence, a phone number, a code snippet — where the bidirectional algorithm produces results that surprise people.

What to look for either way: icons that should mirror (back arrows, progress indicators) and icons that should not (media playback, checkmarks), padding and margins that were written as left/right rather than logical start/end, and any layout built on absolute positioning.

Where it fits

The sequencing is the whole argument for it:

  1. Internationalize — extract strings, remove concatenation, fix hard-coded formats.
  2. Pseudo-localize — prove step 1 actually worked.
  3. Fix what it found.
  4. Send to translators.

Skipping step 2 does not avoid the work. It relocates it to after you have paid for translation, at which point every fix means re-translating, and the bug reports arrive from reviewers working in languages nobody on your team can read.

Half a day of pseudo-localization is the cheapest quality investment in the entire localization process, and it is the step teams most often skip.

Frequently asked questions

What is pseudo-localization?
Pseudo-localization replaces your source text with a machine-generated variant that is still readable but visibly transformed — accented characters, padding, and bracket markers. Running the app in that fake locale reveals hard-coded strings and layouts that break under longer text, before any real translation exists.
When should I run pseudo-localization?
After internationalizing and before sending anything to translators. That ordering is the point: it catches the class of bug that would otherwise come back as reports in languages nobody on the team reads, at a stage where fixing it is cheap.
How much padding should I add?
Around 30 to 40% for text originating in English is the usual choice, since German and Finnish commonly run 20 to 30% longer. Short strings need proportionally more, because a three-character label can easily triple in length.
Does pseudo-localization replace testing with real translations?
No. It catches mechanical problems — missed extractions, truncation, overflow, encoding. It cannot tell you whether a translation is correct, natural or appropriate. It reduces the number of bugs real translators and reviewers have to spend their attention on.

Keep reading

Translate your app without the spreadsheet round-trip

WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.