Internationalization in Go Web Apps With Plain Tools
Internationalize a Go web app with embedded JSON catalogs and golang.org/x/text: language negotiation, plural forms, number formats and missing translations.

A web application is internationalized when adding a language is a matter of adding a file, and localized when that file exists for a particular language. The first is an engineering job, and it is the one this article covers. It is also where teams lose time, because the first version, with English sentences hard-coded in templates and fmt.Sprintf calls, spreads through the code in a way that is expensive to undo. Doing the plumbing early costs a few hundred lines; doing it after the product has a thousand strings costs weeks.
Tested with Go 1.25.6 and golang.org/x/text v0.32.0, macOS on Apple Silicon (arm64)
Go's tools here are modest and good. The standard library gives you embed, encoding/json and html/template. The Go team's golang.org/x/text module adds the hard parts: parsing and matching language tags, plural rules, and locale-aware number formatting. Together they are enough for a serious application without a framework. This article builds the layer end to end and runs it, from the Accept-Language header to a formatted price. It belongs to the web cluster that ends in a production HTTP server, builds on html/template and template functions, and loads its catalogs with embedded files. Everything ran on Go 1.25.6 with golang.org/x/text v0.32.0.
What internationalization covers
Translation is the visible part. The invisible parts cause more bugs:
- Language selection: which language does this visitor get, and can they change it?
- Plurals: "1 item" and "2 items" is the English rule, and most languages have a different one.
- Numbers and currencies: the thousands separator and the decimal mark swap places between languages, and the grouping character is sometimes a no-break space.
- Dates and times: formats, calendars, and above all time zones.
- Text direction and sorting: right-to-left scripts, and alphabetical order that differs by language (where does
äsort?). - Content, not just UI: error messages, emails, validation text, PDF invoices, push notifications.
The design below handles the first four directly and leaves the rest as notes, because those need the same discipline and different tools.
The catalogs
One JSON file per language, embedded in the binary, with the key as identifier and the message as value. A message is either a string or, for words that change with quantity, an object keyed by plural category.
{
"greeting": "Welcome back, {{name}}",
"items": { "one": "{{n}} item in your cart", "other": "{{n}} items in your cart" },
"total": "Total"
}{
"greeting": "Willkommen zurück, {{name}}",
"items": { "one": "{{n}} Artikel im Warenkorb", "other": "{{n}} Artikel im Warenkorb" },
"total": "Summe"
}{
"greeting": "С возвращением, {{name}}",
"items": { "one": "{{n}} товар в корзине", "few": "{{n}} товара в корзине", "many": "{{n}} товаров в корзине", "other": "{{n}} товара в корзине" },
"total": "Итого"
}{
"greeting": "Bon retour, {{name}}",
"items": { "one": "{{n}} article dans le panier", "other": "{{n}} articles dans le panier" }
}Three conventions are worth settling now. Keys name the meaning (greeting, items), not the English text, so editing the English copy never breaks the lookup in other languages. Placeholders are named ({{name}}, {{n}}), never positional, because word order changes between languages and a translator must be able to move the placeholder. And the French file deliberately lacks the total key, to test the fallback.
The bundle
package main
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"strings"
"golang.org/x/text/feature/plural"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
//go:embed locales/*.json
var localeFS embed.FS
// message is either a plain string or a map of plural forms.
type msg struct {
Text string
Forms map[string]string
}
func (m *msg) UnmarshalJSON(b []byte) error {
if json.Unmarshal(b, &m.Text) == nil {
return nil
}
return json.Unmarshal(b, &m.Forms)
}
type Bundle struct {
def language.Tag
catalogs map[language.Tag]map[string]msg
matcher language.Matcher
supp []language.Tag
}
func LoadBundle(fsys fs.FS, def language.Tag) (*Bundle, error) {
b := &Bundle{def: def, catalogs: map[language.Tag]map[string]msg{}}
files, err := fs.Glob(fsys, "locales/*.json")
if err != nil {
return nil, err
}
b.supp = append(b.supp, def) // the default must be first: the matcher falls back to the first tag
for _, f := range files {
raw, err := fs.ReadFile(fsys, f)
if err != nil {
return nil, err
}
tag := language.MustParse(strings.TrimSuffix(strings.TrimPrefix(f, "locales/"), ".json"))
var cat map[string]msg
if err := json.Unmarshal(raw, &cat); err != nil {
return nil, fmt.Errorf("%s: %w", f, err)
}
b.catalogs[tag] = cat
if tag != def {
b.supp = append(b.supp, tag)
}
}
b.matcher = language.NewMatcher(b.supp)
return b, nil
}
// Negotiate picks the best supported language: an explicit choice first (URL or cookie), then Accept-Language.
func (b *Bundle) Negotiate(explicit, acceptLanguage string) language.Tag {
var prefs []language.Tag
if explicit != "" {
if t, err := language.Parse(explicit); err == nil {
prefs = append(prefs, t)
}
}
if tags, _, err := language.ParseAcceptLanguage(acceptLanguage); err == nil {
prefs = append(prefs, tags...)
}
if len(prefs) == 0 {
return b.def
}
_, i, conf := b.matcher.Match(prefs...)
if conf == language.No {
return b.def
}
return b.supp[i] // the matcher can return a regional variant; we hand back the catalog's own tag
}
// T looks a key up for a language, falling back to the default language, then to the key itself.
func (b *Bundle) T(tag language.Tag, key string, args map[string]any) string {
m, ok := b.catalogs[tag][key]
if !ok {
m, ok = b.catalogs[b.def][key]
}
if !ok {
return "!" + key + "!"
}
text := m.Text
if m.Forms != nil {
n, _ := args["n"].(int)
form := plural.Cardinal.MatchPlural(tag, n, 0, 0, 0, 0)
names := map[plural.Form]string{plural.Zero: "zero", plural.One: "one", plural.Two: "two", plural.Few: "few", plural.Many: "many", plural.Other: "other"}
text = m.Forms[names[form]]
if text == "" {
text = m.Forms["other"]
}
}
p := message.NewPrinter(tag)
for k, v := range args {
text = strings.ReplaceAll(text, "{{"+k+"}}", p.Sprint(v)) // numbers are formatted for the language
}
return text
}
func (b *Bundle) Money(tag language.Tag, amount float64, symbol string) string {
return message.NewPrinter(tag).Sprintf("%.2f %s", amount, symbol)
}Most of the file is about three decisions.
Language selection. Negotiate collects preferences in order of authority: an explicit choice first (from the URL prefix or a cookie the visitor set by clicking a language link), then the parsed Accept-Language header. language.NewMatcher takes the list of languages you support, with the default first, because the matcher falls back to its first entry, and returns the best match with a confidence. A regional request such as de-AT matches the de catalog; fr-CH matches fr. When confidence is No, the default wins.
Plural forms. plural.Cardinal.MatchPlural implements the Unicode CLDR plural rules for the language and returns the category for a number. The message then picks the matching form, falling back to other, which every language defines. The calling code never compares to 1.
Fallback. T looks in the visitor's catalog, then the default catalog, then returns the key between exclamation marks. The first fallback keeps a half-translated language usable. The visible marker for a key found nowhere makes the gap obvious in a screenshot instead of silently rendering an empty string.
Running it
package main
import (
"fmt"
"golang.org/x/text/language"
)
func main() {
b, err := LoadBundle(localeFS, language.English)
if err != nil {
panic(err)
}
fmt.Println("supported:", b.supp)
fmt.Println("\nnegotiation:")
for _, h := range []string{
"de-AT,de;q=0.9,en;q=0.5",
"fr-CH, fr;q=0.9, en;q=0.8",
"pt-BR,pt;q=0.9",
"zh-CN,zh;q=0.9,en;q=0.2",
"ru-RU,ru;q=0.9",
"*",
"",
"not a header ;;;",
} {
fmt.Printf(" %-34q -> %v\n", h, b.Negotiate("", h))
}
fmt.Printf(" explicit choice de beats the header -> %v\n", b.Negotiate("de", "fr,en;q=0.8"))
fmt.Println("\nplurals:")
for _, lang := range []language.Tag{language.English, language.German, language.French, language.Russian} {
fmt.Printf(" %-3v", lang)
for _, n := range []int{0, 1, 2, 5, 21, 1000} {
fmt.Printf(" | %s", b.T(lang, "items", map[string]any{"n": n}))
}
fmt.Println()
}
fmt.Println("\nfallbacks and numbers:")
fmt.Println(" fr has no 'total': ", b.T(language.French, "total", nil), "(falls back to English)")
fmt.Println(" nobody has 'missing': ", b.T(language.German, "missing", nil))
for _, lang := range []language.Tag{language.English, language.German, language.French, language.Russian} {
fmt.Printf(" %-3v 1234567.891 -> %q\n", lang, b.Money(lang, 1234567.891, "EUR"))
}
fmt.Println(" greeting:", b.T(language.German, "greeting", map[string]any{"name": "Mara"}))
}supported: [en de fr ru]
negotiation:
"de-AT,de;q=0.9,en;q=0.5" -> de
"fr-CH, fr;q=0.9, en;q=0.8" -> fr
"pt-BR,pt;q=0.9" -> en
"zh-CN,zh;q=0.9,en;q=0.2" -> en
"ru-RU,ru;q=0.9" -> ru
"*" -> en
"" -> en
"not a header ;;;" -> en
explicit choice de beats the header -> de
plurals:
en | 0 items in your cart | 1 item in your cart | 2 items in your cart | 5 items in your cart | 21 items in your cart | 1,000 items in your cart
de | 0 Artikel im Warenkorb | 1 Artikel im Warenkorb | 2 Artikel im Warenkorb | 5 Artikel im Warenkorb | 21 Artikel im Warenkorb | 1.000 Artikel im Warenkorb
fr | 0 article dans le panier | 1 article dans le panier | 2 articles dans le panier | 5 articles dans le panier | 21 articles dans le panier | 1 000 articles dans le panier
ru | 0 товаров в корзине | 1 товар в корзине | 2 товара в корзине | 5 товаров в корзине | 21 товар в корзине | 1 000 товаров в корзине
fallbacks and numbers:
fr has no 'total': Total (falls back to English)
nobody has 'missing': !missing!
en 1234567.891 -> "1,234,567.89 EUR"
de 1234567.891 -> "1.234.567,89 EUR"
fr 1234567.891 -> "1\u00a0234\u00a0567,89 EUR"
ru 1234567.891 -> "1\u00a0234\u00a0567,89 EUR"
greeting: Willkommen zurück, MaraThe negotiation block shows the matcher at work. de-AT,de;q=0.9,en;q=0.5 becomes German, and fr-CH becomes French. pt-BR and zh-CN, for which no catalog exists, fall back to English, because no supported language is a close enough match. Russian is matched exactly. The wildcard *, an empty header and a malformed one all end at the default, and an explicit choice of German beats a header that prefers French. This ordering is the one visitors expect: the choice they made is more authoritative than the guess made from their browser.
The plural block is the reason not to write if n == 1. English and German have two forms, though German uses the same text for both here. French treats 0 as singular ("0 article dans le panier"), which an n == 1 test gets wrong. Russian has four: 1 and 21 take the singular form, 2 uses the few form (товара), 5 uses many (товаров), and the rule keys on the last digits, so 21 behaves like 1 and 1000 like 5. Arabic has six categories and some languages have one. Because the plural logic lives in the library and the forms live in the translator's file, none of this is code you write.
The number block shows message.Printer. The same value, 1234567.891 with two decimals, prints as 1,234,567.89 in English, 1.234.567,89 in German, and with no-break spaces (character 160, shown escaped in the output) as the grouping character in French and Russian. The no-break space matters: a plain space would let the number wrap across lines in a narrow column. Note that the printer formats the number only. The currency symbol position and the currency name are separate matters; golang.org/x/text/currency handles them if you need real currency formatting beyond a suffix.
Choosing the language in a web request
The handler side is short once the bundle exists. Decide the URL strategy first, because it is hard to change. The most dependable is a language prefix in the path (/de/preise, /en/pricing): each language has its own URLs, search engines index them separately, links can be shared and are unambiguous, and caching works without special rules. A cookie-only or header-only approach serves different content at the same URL, which breaks caching and confuses crawlers. If you must serve different languages from one URL (a logged-in application where URLs carry no content meaning), add Vary: Accept-Language to the response so caches keep the versions apart, and set a cookie when the visitor makes a choice.
For public content, add hreflang alternates in the page head linking every language version to the others, and an x-default entry for the fallback, so search engines show the right version to each searcher. Do not redirect crawlers based on Accept-Language; they send none or send English, and a redirect hides your other languages from the index. Redirect real visitors from the bare root (/) to their negotiated language once, and then let the URL be the truth.
In templates, register t as a template function bound to the request's language (see template functions) so templates call {{t "greeting" (dict "name" .User.Name)}}, and set <html lang="{{.Lang}}"> and, for right-to-left languages, dir="rtl". The lang attribute is not decoration: screen readers pick the voice from it, browsers pick hyphenation and quotation marks, and translation tools use it to decide whether to offer a translation.
Right to left, sorting and the rest
Right-to-left languages (Arabic, Hebrew, Persian, Urdu) need more than translated text. Set dir="rtl" on the root element and write your CSS with logical properties (margin-inline-start, padding-inline-end, text-align: start) instead of left and right, so the layout mirrors by itself. Icons that imply direction, such as arrows and back buttons, need to flip; numbers and embedded Latin words stay left to right inside the paragraph, which the browser's bidirectional algorithm handles when the markup declares the language correctly.
Sorting is the other quiet one. A plain Go string comparison orders by byte value, which puts Zebra before Äpfel and apple after Zebra. A list shown to a German or Swedish reader should be sorted by that language's rules, which golang.org/x/text/collate implements with collate.New(language.German).SortStrings(list). The same applies to case-insensitive comparisons, where strings.EqualFold is right for simple folding and wrong for languages with special casing rules (Turkish dotted and dotless i).
Everything the server sends to people is in scope, not just pages. Email subjects and bodies, validation messages returned by an API, push notifications, PDF invoices and exported spreadsheet headers all need the visitor's language. Pass the language tag along with the job, not the request: a background worker has no Accept-Language header, so store the recipient's preferred language on their profile and read it when the job runs. The usual bug is an email that goes out in the language of whoever triggered it.
Dates and time zones
x/text does not format dates. Go's time.Format takes a layout, not a locale, so 2 January 2006 is English by construction. The pragmatic options are: store a layout per language in the catalog ("date": "2. January 2006" with month names also translated), use numeric ISO formats where the audience is technical, or use a library built on CLDR data for full locale formatting. Whatever you choose, store times in UTC and convert at the edge to the visitor's time zone, which is a separate preference from their language: a German speaker in Tokyo wants German text and Tokyo time. Keep the zone in the user profile or detect it in the browser, never infer it from the language.
Who owns the words
The technical layer is the easy half. The process around it decides whether translations stay good. Give translators the catalog files in a format and tool they can use, with context for each string: a short description of where it appears and what the placeholders hold, since "Total" alone could be a noun on an invoice or a heading in a report. Review new strings before they reach translators, because a rewording after translation multiplies the cost by the number of languages. Treat the default-language catalog as the source of truth, and let the test above report which languages are behind, so the question "is German up to date" has an answer in CI instead of in someone's memory.
Testing translations
The bugs that matter in translation are missing keys, mismatched placeholders and missing plural forms, and they are found by comparing files, not by running the app. The test below loads the real catalogs and checks them against the default.
package main
import (
"regexp"
"sort"
"testing"
"golang.org/x/text/language"
)
var placeholder = regexp.MustCompile(`\{\{(\w+)\}\}`)
func placeholders(m msg) []string {
texts := []string{m.Text}
for _, f := range m.Forms {
texts = append(texts, f)
}
seen := map[string]bool{}
for _, t := range texts {
for _, g := range placeholder.FindAllStringSubmatch(t, -1) {
seen[g[1]] = true
}
}
var out []string
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}
func TestEveryLocaleCoversTheDefaultKeys(t *testing.T) {
b, err := LoadBundle(localeFS, language.English)
if err != nil {
t.Fatal(err)
}
for tag, cat := range b.catalogs {
for key := range b.catalogs[language.English] {
if _, ok := cat[key]; !ok {
t.Logf("%v is missing %q (it falls back to English)", tag, key) // a warning: gaps are allowed, but visible
}
}
}
}
func TestPlaceholdersMatchTheDefault(t *testing.T) {
b, _ := LoadBundle(localeFS, language.English)
for tag, cat := range b.catalogs {
for key, m := range cat {
def, ok := b.catalogs[language.English][key]
if !ok {
t.Errorf("%v has %q, which the default language does not", tag, key)
continue
}
got, want := placeholders(m), placeholders(def)
if len(got) != len(want) {
t.Errorf("%v %q uses placeholders %v, the default uses %v", tag, key, got, want)
}
}
}
}
func TestEveryPluralMessageHasAnOtherForm(t *testing.T) {
b, _ := LoadBundle(localeFS, language.English)
for tag, cat := range b.catalogs {
for key, m := range cat {
if m.Forms != nil && m.Forms["other"] == "" {
t.Errorf("%v %q has no \"other\" form, which every language needs as a fallback", tag, key)
}
}
}
}=== RUN TestEveryLocaleCoversTheDefaultKeys
i18n_test.go:40: fr is missing "total" (it falls back to English)
--- PASS: TestEveryLocaleCoversTheDefaultKeys (0.00s)
=== RUN TestPlaceholdersMatchTheDefault
--- PASS: TestPlaceholdersMatchTheDefault (0.00s)
=== RUN TestEveryPluralMessageHasAnOtherForm
--- PASS: TestEveryPluralMessageHasAnOtherForm (0.00s)
PASS
ok example.test/lab 1.245sThe first test logs rather than fails on a missing key, because a language that is 90 percent translated is a valid state during rollout and the fallback keeps it working. Decide your policy: strict for languages you advertise as complete, lenient for ones in progress. The second catches the translator who drops {{name}} or invents {{nom}}. The third enforces the one plural form every language must have. Add a pseudo-locale for testing the interface itself: a generated language where each string is accented and 30 percent longer ([Ẃéĺćóḿé ḃáćḱ]) shows hard-coded strings that were missed and layouts that break when German is longer than English, before any real translation exists.
What this does not cover, and what to use
The bundle above is about 80 lines and covers lookup, fallback, plurals and number formatting. Larger projects add tooling around it: extracting strings from code, handing translators a format they know (XLIFF, PO or a translation management service), and checking completeness in CI. The golang.org/x/text module has its own extraction toolchain (gotext) that generates catalogs from message.Printer calls, and nicksnyder/go-i18n is a widely used library with message files, plural categories and a command-line extractor. I have not run either for this article; both implement the same ideas (keys, named placeholders, CLDR plurals) around a bundle like this one, and moving to one later is easier because your templates already call t with a key and a map of arguments.
Keep one habit regardless of the tooling: never build a sentence by concatenating translated fragments. "Hello" plus a name plus "you have" plus a count does not translate, because word order, case and agreement change. The whole sentence is the unit, with placeholders inside it. The tag parsing and matching behavior is documented in the language package, the plural rules in feature/plural, and the data behind them comes from the Unicode CLDR plural rules.


