Internationalization Prep is the phase of software development where a system is instrumented, structured, and validated to support multiple languages and locales, ensuring that text, dates, numbers, and layouts adapt correctly across regions. It matters most when a product is ready to ship to users beyond its native language, typically during the final stages of a sprint or before a major release. This is not just translation; it is the foundational work that enables translations to be efficient, accurate, and maintainable.
Extracting Translatable Text
The process begins with identifying all user-facing strings in code, templates, and configuration. The goal is to isolate text from the logic so it can be extracted into a format that translation teams can work with. This requires marking strings in code and generating a consistent, machine-readable catalog.
In a Go application using golang.org/x/text/message, translatable strings are extracted via msgfmt:
import "golang.org/x/text/message"
func greetUser(name string) string {
p := message.NewPrinter(language.English)
return p.Sprintf("Hello, %s!", name)
}
To extract all messages, run:
msgfmt -o locale/en/messages.po -l en -s "main.go" "ui/templates/*.html"
This generates messages.po, a standard gettext .po file, which includes:
msgid "Hello, %s!"
msgstr ""
The msgfmt tool reads source files, identifies calls to Sprintf, Printf, and T, and outputs a .po file with all strings, their contexts, and source locations.
The same process applies to a Python app using gettext:
from gettext import gettext as _
def welcome_user(name):
return _("Welcome, {name}!").format(name=name)
Extract with:
xgettext --from-code=UTF-8 -o locale/messages.pot --add-location=number --keyword=_ --keyword=N_ --keyword=NP_ --keyword=U_ src/*.py
The .pot (template) file becomes the source of truth for all language-specific .po files.
Key Pitfalls
- Missing context: Without
msgctxt, similar strings like "Save" and "Save as" become indistinguishable. Usemsgctxtin.pofiles:
msgctxt "Button label"
msgid "Save"
msgstr "Speichern"
- Incorrect domain: When multiple catalogs exist (e.g.,
app.po,admin.po), the wrong domain is passed togettext. Ensure the domain is explicitly set in code:
p := message.NewPrinter(language.German, message.Domain("admin"))
- Missing plural forms: The
ngettextfunction is often used incorrectly. The plural form must be specified in the.pofile:
msgid "1 item"
msgid_plural "%d items"
msgstr[0] "1 Artikel"
msgstr[1] "%d Artikel"
Failure to define both singular and plural forms causes the system to fall back to the singular form regardless of count.
Structuring Locale Data
Once extracted, strings must be organized per locale. Each locale has a unique identifier like fr, de-CH, ar-SA, and may require custom formatting rules.
A typical locale/ directory structure:
locale/
├── en/
│ └── LC_MESSAGES/
│ └── messages.mo
├── fr/
│ └── LC_MESSAGES/
│ └── messages.mo
├── de-CH/
│ └── LC_MESSAGES/
│ └── messages.mo
└── fr-CA/
└── LC_MESSAGES/
└── messages.mo
The .mo (compiled) files are generated from .po files using msgfmt:
msgfmt -o locale/fr/LC_MESSAGES/messages.mo locale/fr/messages.po
The binary .mo format is used by gettext, golang.org/x/text, and python gettext. It includes a header with metadata and a lookup table for efficient string retrieval.
Configuration for Runtime Locale Resolution
Application configuration must define how to resolve the current locale. This is typically driven by the request, user preferences, or system defaults.
In a Node.js app using i18next, the configuration might look like:
{
"i18n": {
"locales": ["en", "fr", "de-CH"],
"defaultLocale": "en",
"fallbackLocale": "en",
"transloaders": [
"fs"
],
"transloadersConfig": {
"fs": {
"path": "/app/locale/{{lng}}/{{ns}}.json"
}
}
}
}
This config instructs the system to look for JSON files at:
/app/locale/en/app.json
/app/locale/fr/app.json
/app/locale/de-CH/app.json
Each JSON file contains key-value pairs:
{
"welcome": "Welcome, {name}!",
"settings": "Settings"
}
The runtime resolves the locale from:
- HTTP
Accept-Languageheader - Query parameter
?lang=fr - Cookie
lang=de-CH - User profile setting
When no explicit locale is found, it falls back through the hierarchy until it finds a matching .json file.
Failure Mode: Missing Locale Files
A common silent failure occurs when a locale file exists in the filesystem but is not loaded. This happens when the path in transloadersConfig is misconfigured.
For example, if the config uses:
"path": "/app/locale/{{lng}}/{{ns}}.json"
But the actual file is at /app/locale/de-CH/app.json, the system fails silently because de-CH is not found in the filesystem. The error appears only when the log level is increased:
Error: Cannot find locale file for de-CH
The fix is to ensure the directory structure matches the expected pattern, and to verify file existence with a startup script:
#!/bin/bash
for lang in en fr de-CH; do
if [[ ! -f "/app/locale/$lang/app.json" ]]; then
echo "Missing locale file: /app/locale/$lang/app.json"
exit 1
fi
done
Validating Translation Coverage
After extraction and locale setup, the system must verify that every translatable string is present in every locale. The process involves comparing the master .pot file against all .po files and reporting missing or unreviewed strings.
Run msgmerge to update each .po file with new strings:
msgmerge -U locale/fr/messages.po locale/messages.pot
This updates the French translation file with any new strings from the .pot template, preserving existing translations and marking new ones as fuzzy.
To validate coverage, use msgcat to extract all messages and compare against a list of expected keys:
msgcat -o - locale/en/messages.po | sort > expected.txt
msgcat -o - locale/fr/messages.po | sort > actual_fr.txt
diff expected.txt actual_fr.txt
A missing string in fr appears in the output:
--- expected.txt
+++ actual_fr.txt
@@ -1,3 +1,3 @@
Hello, %s!
Welcome, {name}!
Settings
+Login
The Login string is missing in the French translation.
Handling Fuzzy Translations
When a .po file is updated with msgmerge, the new strings are marked fuzzy. This indicates that the translator should review the translation, but it’s not yet final.
In a CI pipeline, a failure occurs when a fuzzy translation is present:
- name: Check for fuzzy translations
run: |
git ls-files "*.po" | xargs msgcat -o - | grep -q "fuzzy"
if [ $? -ne 0 ]; then
echo "Fuzzy translations found in .po files"
exit 1
fi
This ensures that no new string is left unreviewed before release.
Silent Failure: Wrong Locale in Request
A subtle but common failure is when the locale is set in the request but not correctly mapped to the file system. For example, a request with Accept-Language: de-CH expects de-CH but the app looks for de-ch (lowercase) or de_CH (underscore).
The problem is not in the code, but in the file system or configuration. The solution is to normalize the locale name during resolution:
func normalizeLocale(l string) string {
l = strings.ToLower(l)
l = strings.ReplaceAll(l, "_", "-")
return l
}
This maps de_CH → de-ch, de-CH → de-ch, DE-ch → de-ch, ensuring consistency.
Preparing for Localization
With strings extracted and validated, the next step is to prepare the application to handle locale-specific formatting and layout.
Date and Number Formatting
Each locale has its own rules for displaying dates and numbers. The application must use a standardized formatting library that supports these rules.
In a Ruby app using I18n, the configuration includes:
I18n.default_locale = :en
I18n.available_locales = [:en, :fr, :de, :de_CH]
I18n.load_path = Dir.glob("config/locales/*.yml")
The config/locales/fr.yml file defines:
fr:
date:
formats:
long: "%A, %d %B %Y"
short: "%d %b"
time:
formats:
long: "%A, %d %B %Y à %Hh%M"
number:
format:
precision: 2
separator: ","
delimiter: " "
significant: true
When rendering a date in French:
I18n.l(Date.new(2023, 4, 15), format: :long)
The same code, when run in German (de-CH), produces:
I18n.l(Date.new(2023, 4, 15), format: :long)
# => "Samstag, 15. April 2023"
Layout and Styling
Text length varies significantly between languages. A string like "Settings" is 9 characters in English but 14 in German ("Einstellungen"). The layout must adapt.
In a React app using react-i18next, use i18next to load translations and css for responsive design:
import { useTranslation } from 'react-i18next';
function SettingsPage() {
const { t } = useTranslation();
return (
<div className="settings-page">
<h1>{t('settings')}</h1>
<p>{t('settings.description')}</p>
<button>{t('save')}</button>
</div>
);
}
The CSS includes responsive rules:
.settings-page {
padding: 1rem;
font-size: 1rem;
}
.settings-page h1 {
font-size: 1.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-page button {
padding: 0.5rem 1rem;
min-width: 100px;
}
Without white-space: nowrap and text-overflow: ellipsis, a long German label like "Einstellungen" spills into multiple lines or overflows the button.
Failure Mode: Incomplete Translation Keys
When a new feature adds a new text in code but the corresponding key is missing in the .po or .json files, the system returns the key as the default value.
For example, in a Go app:
p := message.NewPrinter(language.German)
fmt.Println(p.Sprintf("new_feature.welcome_message"))
If new_feature.welcome_message is not in de/messages.po, the output is:
new_feature.welcome_message
The user sees the raw key, not the translated text. This happens because the translator forgot to extract the new key.
The fix is to add a build step that checks for missing keys:
#!/bin/bash
for lang in en fr de; do
expected=$(grep -o 'msgid.*' locale/$lang/messages.po | sort)
actual=$(find src -name "*.go" -exec grep -o 'Sprintf(".*"' {} \; | sort)
diff <(echo "$expected") <(echo "$actual") || {
echo "Missing translation keys"
exit 1
}
This ensures that every string used in code has a corresponding entry in the translation catalog.
Summary
Internationalization Prep is the disciplined work of making a system ready for multilingual use. It begins with extracting translatable strings, validating them across locales, and configuring the runtime to resolve the correct locale. It extends to handling formatting, layout, and translation coverage. The process reveals subtle failures: missing files, incorrect locale names, unreviewed fuzzy translations, and untranslated keys. The success of the system depends not on the existence of these components, but on their consistency, completeness, and validation.
This page was rewritten on 14 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
Join the Newsletter
Get the latest updates and deep insights shipped directly to your inbox.