Table of Contents
Table of Contents
- Go (Golang) i18n: Internationalization Patterns and Libraries
- Go's i18n Ecosystem Overview
- Language Tag Parsing with golang.org/x/text
- Parsing and Matching Language Tags
- Parsing Accept-Language Headers
- go-i18n: Translation Management
- Installation and Setup
- Translation File Structure
- Initializing go-i18n
- Translating Messages
- Number and Currency Formatting
- Date and Time Formatting
- Structuring i18n in a Go Web Service
- Middleware-Based Locale Detection
- Error Messages as Translateable Content
- i18n for Go CLI Applications
- Extraction and Management Tooling
- Pluralization for Complex Languages
- Testing i18n in Go
- Take your app global with better-i18n
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:
| Package | Purpose |
|---|---|
golang.org/x/text/language | BCP 47 language tag parsing and matching |
golang.org/x/text/message | Printf-style formatted messages with i18n |
golang.org/x/text/number | Number formatting (plurals, currency) |
golang.org/x/text/collate | Locale-aware string sorting |
golang.org/x/text/unicode/norm | Unicode normalization |
github.com/nicksnyder/go-i18n/v2 | Translation file management, pluralization |
github.com/BurntSushi/toml | TOML 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.