SEO

Go (Golang) i18n: Internationalization Patterns and Libraries

Eray Gündoğmuş
Eray Gündoğmuş
·13 min read
Share
Go (Golang) i18n: Internationalization Patterns and Libraries

Go (Golang) i18n: Internationalization Patterns and Libraries

Go is an increasingly popular language for backend services, CLIs, and APIs that serve global users. While Go's standard library doesn't include an i18n framework as comprehensive as some other ecosystems, the combination of golang.org/x/text, go-i18n, and careful architecture enables robust multilingual Go applications.

This guide covers the complete Go i18n toolkit—from locale-aware formatting using x/text to translation management with go-i18n, plus patterns for structuring i18n in Go services and CLIs.

Go's i18n Ecosystem Overview

Go's i18n tooling is split across several packages:

PackagePurpose
golang.org/x/text/languageBCP 47 language tag parsing and matching
golang.org/x/text/messagePrintf-style formatted messages with i18n
golang.org/x/text/numberNumber formatting (plurals, currency)
golang.org/x/text/collateLocale-aware string sorting
golang.org/x/text/unicode/normUnicode normalization
github.com/nicksnyder/go-i18n/v2Translation file management, pluralization
github.com/BurntSushi/tomlTOML parsing (common translation format)

Language Tag Parsing with golang.org/x/text

The golang.org/x/text/language package implements BCP 47 language tags—the standard for locale identifiers used in HTTP Accept-Language headers, HTML lang attributes, and locale strings.

Parsing and Matching Language Tags

package main

import (
    "fmt"
    "golang.org/x/text/language"
)

func main() {
    // Parse a language tag
    tag, err := language.Parse("zh-Hant-TW")
    if err != nil {
        panic(err)
    }
    fmt.Println(tag) // zh-Hant-TW

    // Language matching: find the best match from supported languages
    supported := []language.Tag{
        language.English,
        language.French,
        language.SimplifiedChinese,
        language.TraditionalChinese,
    }
    
    matcher := language.NewMatcher(supported)
    
    // User requests zh-TW (Traditional Chinese, Taiwan)
    t, _, _ := matcher.Match(language.MustParse("zh-TW"))
    fmt.Println(t) // zh-Hant → matches Traditional Chinese
    
    // User requests es-MX (Mexican Spanish)
    t, _, _ = matcher.Match(language.MustParse("es-MX"))
    fmt.Println(t) // en → falls back to English (Spanish not supported)
}

Parsing Accept-Language Headers

import (
    "net/http"
    "golang.org/x/text/language"
)

var supported = []language.Tag{
    language.English,
    language.French,
    language.German,
    language.Japanese,
}

var matcher = language.NewMatcher(supported)

func getLocale(r *http.Request) language.Tag {
    // Parse Accept-Language header
    accept := r.Header.Get("Accept-Language")
    tags, _, err := language.ParseAcceptLanguage(accept)
    if err != nil || len(tags) == 0 {
        return language.English
    }
    
    tag, _, _ := matcher.Match(tags...)
    return tag
}

go-i18n: Translation Management

go-i18n is the most widely used Go library for translation management. It supports:

  • JSON, TOML, and YAML translation files
  • ICU message format for pluralization
  • Template interpolation
  • Multiple translation file loading

Installation and Setup

go get github.com/nicksnyder/go-i18n/v2@latest
go get github.com/BurntSushi/toml

Translation File Structure

# locales/en.toml
[welcome]
description = "Welcome message for new users"
one = "Welcome, {{.Name}}! You have {{.Count}} new notification."
other = "Welcome, {{.Name}}! You have {{.Count}} new notifications."

[item_count]
description = "Number of items in a list"
zero = "No items"
one = "{{.Count}} item"
other = "{{.Count}} items"
# locales/fr.toml
[welcome]
one = "Bienvenue, {{.Name}} ! Vous avez {{.Count}} nouvelle notification."
other = "Bienvenue, {{.Name}} ! Vous avez {{.Count}} nouvelles notifications."

[item_count]
zero = "Aucun élément"
one = "{{.Count}} élément"
other = "{{.Count}} éléments"

Initializing go-i18n

package i18n

import (
    "embed"
    "encoding/json"

    "github.com/BurntSushi/toml"
    "github.com/nicksnyder/go-i18n/v2/i18n"
    "golang.org/x/text/language"
)

//go:embed locales/*.toml
var localeFS embed.FS

var bundle *i18n.Bundle

func Init() error {
    bundle = i18n.NewBundle(language.English)
    bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)

    // Load all locale files
    entries, err := localeFS.ReadDir("locales")
    if err != nil {
        return err
    }

    for _, entry := range entries {
        if _, err := bundle.LoadMessageFileFS(localeFS, "locales/"+entry.Name()); err != nil {
            return fmt.Errorf("loading locale %s: %w", entry.Name(), err)
        }
    }

    return nil
}

// NewLocalizer creates a localizer for the given language tags
func NewLocalizer(langs ...string) *i18n.Localizer {
    return i18n.NewLocalizer(bundle, langs...)
}

Translating Messages

package handlers

import (
    "net/http"
    "myapp/i18n"
)

type WelcomeData struct {
    Name  string
    Count int
}

func WelcomeHandler(w http.ResponseWriter, r *http.Request) {
    // Get localizer for request language
    accept := r.Header.Get("Accept-Language")
    localizer := i18n.NewLocalizer(accept, "en")

    // Simple message with plural handling
    itemCount := 3
    msg, err := localizer.Localize(&i18n.LocalizeConfig{
        MessageID: "item_count",
        PluralCount: itemCount,
        TemplateData: map[string]interface{}{
            "Count": itemCount,
        },
    })
    if err != nil {
        msg = "Unknown number of items" // fallback
    }

    // Message with name and plural
    welcome, err := localizer.Localize(&i18n.LocalizeConfig{
        MessageID:   "welcome",
        PluralCount: itemCount,
        TemplateData: WelcomeData{
            Name:  "Alice",
            Count: itemCount,
        },
    })

    fmt.Fprintf(w, "%s\n%s\n", welcome, msg)
}

Number and Currency Formatting

The golang.org/x/text/message package provides locale-aware number formatting:

package main

import (
    "fmt"
    "golang.org/x/text/language"
    "golang.org/x/text/message"
    "golang.org/x/text/number"
)

func main() {
    // Create locale-specific printers
    enPrinter := message.NewPrinter(language.English)
    dePrinter := message.NewPrinter(language.German)
    jaPrinter := message.NewPrinter(language.Japanese)

    amount := 1234567.89

    // Locale-aware number formatting
    enPrinter.Printf("%v\n", number.Decimal(amount))
    // 1,234,567.89
    
    dePrinter.Printf("%v\n", number.Decimal(amount))
    // 1.234.567,89
    
    jaPrinter.Printf("%v\n", number.Decimal(amount))
    // 1,234,567.89

    // Percentage formatting
    enPrinter.Printf("%.2%\n", number.Percent(0.8527))
    // 85.27%
    
    dePrinter.Printf("%.2%\n", number.Percent(0.8527))
    // 85,27 %
}

Date and Time Formatting

Go's time package handles basic date formatting, but for locale-aware date formatting you need x/text or a third-party library:

import (
    "time"
    "golang.org/x/text/language"
    "golang.org/x/text/language/display"
)

func formatDateLocale(t time.Time, locale language.Tag) string {
    // Go's built-in time formatting uses fixed layout strings
    // For locale-specific formatting, use strftime-equivalent libraries
    // or generate patterns from CLDR data
    
    switch locale {
    case language.English:
        return t.Format("January 2, 2006")
    case language.German:
        return t.Format("2. January 2006") // German date format
    case language.Japanese:
        return t.Format("2006年01月02日")
    default:
        return t.Format(time.RFC3339)
    }
}

For production-quality locale-aware date formatting, use a library like github.com/goodsign/monday for day/month names.

Structuring i18n in a Go Web Service

Middleware-Based Locale Detection

package middleware

import (
    "context"
    "net/http"
    "golang.org/x/text/language"
    "myapp/i18n"
)

type contextKey string
const localizerKey contextKey = "localizer"

var supported = []language.Tag{
    language.English,
    language.French,
    language.German,
    language.Japanese,
}
var matcher = language.NewMatcher(supported)

func LocaleMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Check URL query parameter first (e.g., ?lang=fr)
        lang := r.URL.Query().Get("lang")
        
        // Then check cookie
        if lang == "" {
            if cookie, err := r.Cookie("lang"); err == nil {
                lang = cookie.Value
            }
        }
        
        // Fall back to Accept-Language header
        if lang == "" {
            lang = r.Header.Get("Accept-Language")
        }

        // Create localizer and attach to context
        localizer := i18n.NewLocalizer(lang, "en")
        ctx := context.WithValue(r.Context(), localizerKey, localizer)
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// GetLocalizer retrieves the localizer from the context
func GetLocalizer(ctx context.Context) *i18n.Localizer {
    l, ok := ctx.Value(localizerKey).(*i18n.Localizer)
    if !ok {
        return i18n.NewLocalizer("en")
    }
    return l
}

Error Messages as Translateable Content

package errors

import (
    "github.com/nicksnyder/go-i18n/v2/i18n"
)

// AppError is an error with a translatable message
type AppError struct {
    MessageID   string
    TemplateData interface{}
    Err         error
}

func (e *AppError) Error() string {
    return e.MessageID // Internal identifier
}

func (e *AppError) Localize(localizer *i18n.Localizer) string {
    msg, err := localizer.Localize(&i18n.LocalizeConfig{
        MessageID:    e.MessageID,
        TemplateData: e.TemplateData,
    })
    if err != nil {
        return e.MessageID // Fallback to key
    }
    return msg
}

// Usage
var ErrNotFound = &AppError{MessageID: "error.not_found"}
var ErrUnauthorized = &AppError{MessageID: "error.unauthorized"}

i18n for Go CLI Applications

CLI applications have distinct i18n requirements: they must detect the system locale, format output appropriately, and handle terminal encoding.

package main

import (
    "os"
    "golang.org/x/text/language"
    "myapp/i18n"
)

func detectSystemLocale() string {
    // Check LANG environment variable (Unix/Linux/macOS)
    if lang := os.Getenv("LANG"); lang != "" {
        // LANG is typically "en_US.UTF-8" format
        // Extract language tag
        parts := strings.Split(lang, ".")
        if len(parts) > 0 {
            // Convert "en_US" to "en-US" (BCP 47)
            return strings.ReplaceAll(parts[0], "_", "-")
        }
    }
    
    // Check LC_ALL, LC_MESSAGES
    for _, env := range []string{"LC_ALL", "LC_MESSAGES"} {
        if lang := os.Getenv(env); lang != "" {
            return strings.ReplaceAll(strings.Split(lang, ".")[0], "_", "-")
        }
    }
    
    return "en" // Default to English
}

func main() {
    locale := detectSystemLocale()
    localizer := i18n.NewLocalizer(locale, "en")
    
    // Use localizer for all output
    fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{
        MessageID: "cli.welcome",
    }))
}

Extraction and Management Tooling

go-i18n provides a goi18n command-line tool for extracting translatable strings:

# Install the CLI tool
go install github.com/nicksnyder/go-i18n/v2/goi18n@latest

# Extract strings from Go source (creates active.en.toml)
goi18n extract -format toml -outdir locales ./...

# Merge new strings into existing translations
# (creates translate.fr.toml with only new/changed strings)
goi18n merge -format toml -outdir locales locales/active.en.toml locales/active.fr.toml

# After translation, merge translate.fr.toml back into active.fr.toml
goi18n merge -format toml -outdir locales locales/active.fr.toml locales/translate.fr.toml

This workflow integrates well with CI/CD localization automation for continuous translation updates.

Pluralization for Complex Languages

Go-i18n handles pluralization via CLDR rules automatically for all supported languages:

# locales/ru.toml (Russian - 4 plural forms)
[item_count]
zero = "Нет элементов"
one = "{{.Count}} элемент"     # 1, 21, 31...
few = "{{.Count}} элемента"    # 2-4, 22-24...
many = "{{.Count}} элементов"  # 5-20, 25-30...
other = "{{.Count}} элемента"  # fractions, other
# locales/ar.toml (Arabic - 6 plural forms)
[item_count]
zero = "لا عناصر"
one = "{{.Count}} عنصر"
two = "{{.Count}} عنصران"
few = "{{.Count}} عناصر"
many = "{{.Count}} عنصرًا"
other = "{{.Count}} عنصر"

For deeper coverage of pluralization rules, see pluralization rules across languages.

Testing i18n in Go

package i18n_test

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

func TestPluralForms(t *testing.T) {
    bundle := setupTestBundle(t)
    
    tests := []struct {
        lang     string
        count    int
        expected string
    }{
        {"en", 0, "No items"},
        {"en", 1, "1 item"},
        {"en", 2, "2 items"},
        {"ru", 1, "1 элемент"},
        {"ru", 2, "2 элемента"},
        {"ru", 5, "5 элементов"},
        {"ru", 11, "11 элементов"}, // Tricky: 11 uses "many", not "one"
    }
    
    for _, tt := range tests {
        t.Run(fmt.Sprintf("%s_%d", tt.lang, tt.count), func(t *testing.T) {
            localizer := i18n.NewLocalizer(bundle, tt.lang)
            got, err := localizer.Localize(&i18n.LocalizeConfig{
                MessageID:   "item_count",
                PluralCount: tt.count,
                TemplateData: map[string]interface{}{"Count": tt.count},
            })
            if err != nil {
                t.Fatalf("localize error: %v", err)
            }
            if got != tt.expected {
                t.Errorf("got %q, want %q", got, tt.expected)
            }
        })
    }
}

For comprehensive testing strategies, see i18n testing tools, strategies, and automation.


Take your app global with better-i18n

better-i18n combines AI-powered translations, git-native workflows, and global CDN delivery into one developer-first platform. Stop managing spreadsheets and start shipping in every language.

Get started free → · Explore features · Read the docs