The Complete Guide to Go Internationalization

From message files to goroutine-safe locale resolution: set up i18n in your Go app with go-i18n, then automate translations with AI.

1

Install go-i18n

go-i18n is the most popular internationalization library for Go. It uses CLDR plural rules, Go templates for variable interpolation, and supports JSON, TOML, and YAML message files. You also need golang.org/x/text for language tag matching.

go-i18n v2 requires Go 1.16+. The golang.org/x/text package provides BCP 47 language tag parsing and matching, which go-i18n uses internally for plural rule selection.
Terminal
go get -u github.com/nicksnyder/go-i18n/v2/i18n
go get -u golang.org/x/text/language
2

Create Message Files

Create one JSON file per language in a locales directory. Each message has an ID and one or more plural forms. Go-i18n uses Go template syntax (double curly braces with a dot prefix) for variable interpolation.

locales/en.json
{
  "HelloWorld": { "other": "Hello, World!" },
  "Greeting": { "other": "Hello, {{.Name}}!" },
  "ItemCount": {
    "one": "{{.Count}} item",
    "other": "{{.Count}} items"
  },
  "WelcomeBack": {
    "other": "Welcome back, {{.Name}}. You have {{.Count}} messages."
  }
}
Use descriptive message IDs like 'ItemCount' or 'WelcomeBack' rather than dot-separated paths. go-i18n uses flat IDs, not nested keys. Keep IDs PascalCase to match Go conventions.
3

Load the Bundle

The Bundle is go-i18n's central registry. Create one at startup, register your file format, and load all message files. The Bundle is goroutine-safe — create it once and share it across your application.

main.go
package main

import (
    "encoding/json"
    "fmt"
    "github.com/nicksnyder/go-i18n/v2/i18n"
    "golang.org/x/text/language"
)

func main() {
    // 1. Create a bundle with a default language
    bundle := i18n.NewBundle(language.English)
    // 2. Register the unmarshal function for your file format
    bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
    // 3. Load message files
    bundle.MustLoadMessageFile("locales/en.json")
    bundle.MustLoadMessageFile("locales/ja.json")
    bundle.MustLoadMessageFile("locales/de.json")
    // 4. Create a localizer and localize a message
    localizer := i18n.NewLocalizer(bundle, "ja")
    msg := localizer.MustLocalize(&i18n.LocalizeConfig{
        MessageID: "HelloWorld",
    })
    fmt.Println(msg) // "こんにちは、世界!"
}
If you see 'message not found', check three things: 1) The message file was loaded with LoadMessageFile or MustLoadMessageFile. 2) The file extension matches the registered unmarshal function. 3) The MessageID in LocalizeConfig matches the key in your JSON file exactly (case-sensitive).
4

Use the Localizer

Create a Localizer for each request with the user's preferred language. The Localizer resolves messages from the Bundle, handles template rendering with Go's text/template engine, and selects the correct plural form based on PluralCount.

Using the Localizer
// Simple message
msg := localizer.MustLocalize(&i18n.LocalizeConfig{
    MessageID: "HelloWorld",
})

// Message with template data
msg := localizer.MustLocalize(&i18n.LocalizeConfig{
    MessageID: "Greeting",
    TemplateData: map[string]interface{}{
        "Name": "Alice",
    },
})
// "Hello, Alice!" (en) or "こんにちは、Aliceさん!" (ja)

// Plural + template data
msg := localizer.MustLocalize(&i18n.LocalizeConfig{
    MessageID: "ItemCount",
    PluralCount: 5,
    TemplateData: map[string]interface{}{
        "Count": 5,
    },
})
// "5 items" (en) or "5個のアイテム" (ja)

// Combined: plurals + multiple variables
msg := localizer.MustLocalize(&i18n.LocalizeConfig{
    MessageID: "WelcomeBack",
    TemplateData: map[string]interface{}{
        "Name":  "Alice",
        "Count": 3,
    },
})
HTTP handler with locale detection
func handler(w http.ResponseWriter, r *http.Request) {
    // Accept-Language: ja,en;q=0.9,de;q=0.8
    accept := r.Header.Get("Accept-Language")

    // NewLocalizer accepts multiple languages — first match wins
    localizer := i18n.NewLocalizer(bundle, accept)

    msg := localizer.MustLocalize(&i18n.LocalizeConfig{
        MessageID: "HelloWorld",
    })

    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.Write([]byte(msg))
}
NewLocalizer accepts multiple language strings — it tries them in order. Pass the Accept-Language header directly: i18n.NewLocalizer(bundle, r.Header.Get("Accept-Language")). go-i18n parses the header and matches against available translations automatically.
5

Handle Plural Rules

go-i18n implements CLDR plural rules for all languages. English has 2 forms (one, other). Arabic has 6 (zero, one, two, few, many, other). Japanese has 1 (other). Define all required forms in your message files — go-i18n selects the correct one based on PluralCount.

Plural forms by language
// English: 2 forms (one, other)
{
  "ItemCount": {
    "one": "{{.Count}} item",
    "other": "{{.Count}} items"
  }
}

// Arabic: 6 forms (zero, one, two, few, many, other)
{
  "ItemCount": {
    "zero": "لا عناصر",
    "one": "عنصر واحد",
    "two": "عنصران",
    "few": "{{.Count}} عناصر",
    "many": "{{.Count}} عنصرًا",
    "other": "{{.Count}} عنصر"
  }
}

// Japanese: 1 form (other)
{
  "ItemCount": {
    "other": "{{.Count}}個のアイテム"
  }
}
Using plural rules
// go-i18n selects the correct plural form based on PluralCount
localizer := i18n.NewLocalizer(bundle, "ar")

msg := localizer.MustLocalize(&i18n.LocalizeConfig{
    MessageID:   "ItemCount",
    PluralCount: 3,
    TemplateData: map[string]interface{}{
        "Count": 3,
    },
})
// Arabic "few" form: "3 عناصر"

msg = localizer.MustLocalize(&i18n.LocalizeConfig{
    MessageID:   "ItemCount",
    PluralCount: 1,
    TemplateData: map[string]interface{}{
        "Count": 1,
    },
})
// Arabic "one" form: "عنصر واحد"
PluralCount and TemplateData are separate. PluralCount selects the plural form, TemplateData provides values for template rendering. If you need the count in the message text, pass it in both: PluralCount: n and TemplateData: map[string]interface{}{"Count": n}.
6

Locale Detection

In web applications, detect the user's preferred language from multiple sources: query parameters, cookies, the Accept-Language header, or URL path segments. Use golang.org/x/text/language.Matcher for BCP 47-compliant language negotiation.

Locale detection middleware
// detectLocale resolves the user's preferred language.
// Priority: query param > cookie > Accept-Language header > default
func detectLocale(r *http.Request, matcher language.Matcher) string {
    // 1. Explicit query parameter: ?lang=ja
    if lang := r.URL.Query().Get("lang"); lang != "" {
        tag, _, _ := matcher.Match(language.Make(lang))
        return tag.String()
    }
    // 2. Cookie from previous selection
    if cookie, err := r.Cookie("lang"); err == nil {
        tag, _, _ := matcher.Match(language.Make(cookie.Value))
        return tag.String()
    }
    // 3. Accept-Language header
    accept := r.Header.Get("Accept-Language")
    if accept != "" {
        tags, _, _ := language.ParseAcceptLanguage(accept)
        if len(tags) > 0 {
            tag, _, _ := matcher.Match(tags...)
            return tag.String()
        }
    }
    return "en" // 4. Default
}

// Usage:
matcher := language.NewMatcher([]language.Tag{
    language.English, language.Japanese,
    language.German, language.Spanish,
})

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    locale := detectLocale(r, matcher)
    localizer := i18n.NewLocalizer(bundle, locale)
    // ... use localizer
})
language.NewMatcher returns the best match from your supported languages, not the raw user preference. If a user requests 'pt-BR' and you only support 'pt', the matcher correctly returns 'pt'. Without a matcher, you'd need manual fallback logic for every regional variant.
7

Fix Per-Key Fallback with go-locale-chain

go-i18n has a known limitation: once a locale matches (has any translations loaded), missing keys don't fall through to the next locale in the chain. A pt-BR user with a partially translated pt-BR file gets empty strings instead of falling back to pt-PT or pt. go-locale-chain fixes this with per-key fallback resolution across 75 built-in locale chains.

Terminal
go get github.com/i18n-agent/go-locale-chain
go-locale-chain with go-i18n
package main

import (
    "encoding/json"
    "fmt"

    "github.com/nicksnyder/go-i18n/v2/i18n"
    localechain "github.com/i18n-agent/go-locale-chain"
    "golang.org/x/text/language"
)

func main() {
    // 1. Configure fallback chains (call once at startup)
    localechain.Configure()

    // 2. Set up go-i18n bundle as usual
    bundle := i18n.NewBundle(language.English)
    bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
    bundle.MustLoadMessageFile("locales/en.json")
    bundle.MustLoadMessageFile("locales/pt.json")
    bundle.MustLoadMessageFile("locales/pt-PT.json")
    bundle.MustLoadMessageFile("locales/pt-BR.json")

    // 3. Resolve with per-key fallback
    result, _ := localechain.ResolveWithLoader("pt-BR", func(locale string) (map[string]string, error) {
        localizer := i18n.NewLocalizer(bundle, locale)
        messages := make(map[string]string)
        for _, id := range []string{"hello", "goodbye", "thanks"} {
            msg, err := localizer.Localize(&i18n.LocalizeConfig{MessageID: id})
            if err == nil {
                messages[id] = msg
            }
        }
        return messages, nil
    })

    fmt.Println(result["hello"])   // "Olá (BR)"   — from pt-BR
    fmt.Println(result["goodbye"]) // "Adeus (PT)" — fallback to pt-PT
    fmt.Println(result["thanks"])  // "Obrigado"   — fallback to pt
}
go-locale-chain is an open-source Go package with zero external dependencies. It complements go-i18n — use go-i18n for message loading, pluralization, and template rendering, and go-locale-chain for proper fallback chain resolution.
Standalone usage (no go-i18n)
// Standalone: zero external dependencies, works with any format
localechain.Configure()

result, _ := localechain.ResolveWithLoader("es-MX", func(locale string) (map[string]string, error) {
    data, err := os.ReadFile(fmt.Sprintf("locales/%s.json", locale))
    if err != nil {
        return nil, err // Locale file doesn't exist — skip
    }
    var msgs map[string]string
    json.Unmarshal(data, &msgs)
    return msgs, nil
})
// es-MX -> es-419 -> es: each key resolved from most specific locale
Use ConfigureWithOverrides() to customize specific chains while keeping the defaults. For example, simplify pt-BR to fall back only to pt, or add a chain for a locale not in the defaults like sv-FI -> sv.
8

Automate Translations

With your i18n setup complete, translate your locale files using AI. Generate translations for all target languages from your English source file — directly from your IDE or in your CI/CD pipeline.

Terminal
# In your IDE, ask your AI assistant:
> Translate locales/en.json to German, Japanese, and Spanish

✓ locales/de.json created (1.2s)
✓ locales/ja.json created (1.5s)
✓ locales/es.json created (1.1s)

# Or use the CLI in CI/CD:
npx i18n-agent translate locales/en.json --lang de,ja,es
Translate incrementally — when you add new message IDs to your source file, translate just the new keys rather than regenerating all files. This preserves any human-reviewed translations.

Automate Translation Quality

Catch missing keys and broken placeholders before they ship with i18n-validate. Test your UI with pseudo-translations using i18n-pseudo before real translations arrive.

Common Pitfalls

PluralCount and TemplateData Mismatch

PluralCount selects the plural form but does not inject the value into the template. You must also pass the count in TemplateData for it to appear in the rendered message. Without TemplateData, {{.Count}} renders as <no value>.

Missing RegisterUnmarshalFunc

LoadMessageFile silently returns no messages if you forget to call bundle.RegisterUnmarshalFunc() for the file format. Always register json.Unmarshal (or toml/yaml) before loading files.

Per-Key Fallback Does Not Work

go-i18n's NewLocalizer accepts multiple languages, but once a locale matches a single key, missing keys return empty strings instead of falling back. Use go-locale-chain to fix this with proper per-key cascade.

Template Syntax: {{.Var}} Not {{Var}}

go-i18n uses Go's text/template syntax. Variables must be prefixed with a dot: {{.Name}}, not {{Name}}. The dot refers to the TemplateData map. Missing the dot causes a template execution error.

Recommended File Structure

Project Structure
my-go-app/
├── locales/
│   ├── en.json           # Source language (English)
│   ├── de.json           # German translations
│   ├── ja.json           # Japanese translations
│   ├── es.json           # Spanish translations
│   ├── pt.json           # Portuguese (base)
│   ├── pt-PT.json        # Portuguese (Portugal)
│   └── pt-BR.json        # Portuguese (Brazil)
├── i18n/
│   ├── bundle.go         # Bundle initialization
│   ├── detect.go         # Locale detection logic
│   └── middleware.go      # HTTP middleware for locale
├── main.go
├── go.mod
└── go.sum

Try i18n Agent Now

Drop your translation file here

JSON, YAML, PO, XML, CSV, Markdown, Properties

or click to browse

Target languages

No signup requiredInstant estimate

Go i18n FAQ