How to Use the golang.org/x/text Package for i18n

Use golang.org/x/text/language and message packages to detect locales and format localized strings in Go applications.

The locale leak in production

You ship a feature. Users in Brazil see dates like 12/31/2023 when they expect 31/12/2023. Numbers show 1,000.50 instead of 1.000,50. The strings are translated, but the data feels foreign. The app works, but it feels broken.

This is the gap between translation and localization. Translation swaps words. Localization adapts structure, numbers, dates, and plural rules to a specific region. Go's standard library handles strings and basic formatting, but it leaves the heavy lifting of locale-aware rules to golang.org/x/text. The package provides the data and algorithms to format values correctly for any locale, using the CLDR (Common Locale Data Repository) standard.

What x/text actually does

The x/ prefix means "experimental" in Go naming, but golang.org/x/text is stable, widely used, and maintained by the Go team. It fills the gap where the standard library stops. The standard library keeps the core small; x/text carries the weight of internationalization data, which is large.

The package splits work into modules. language handles locale tags and matching. message handles formatting strings, numbers, and plurals. currency handles money. time handles dates.

Think of language as the dictionary of locales and message as the engine that applies rules from that dictionary. You parse a tag to identify a locale, then use a printer to format values according to that locale's rules.

Anatomy of a language tag

Locales are identified by BCP 47 tags. These tags encode language, script, region, and variants. es is Spanish. es-MX is Mexican Spanish. zh-Hant-TW is Traditional Chinese in Taiwan. The tag structure is precise. The region can change number formatting, date order, and even plural rules.

language.Parse returns a tag and an error. language.MustParse panics if the tag is invalid. Use MustParse for constants hardcoded in your source. Never use MustParse on user input from a header or query param. The compiler won't catch a bad runtime value.

Convention aside: MustParse is safe for configuration strings you control. If a tag comes from the outside world, use Parse and handle the error. A malformed tag can crash your handler if you rely on MustParse.

Minimal example: A printer for Spanish

Here's the simplest setup. Parse a tag, create a printer, format a number. The printer applies locale rules to the output.

package main

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

func main() {
	// MustParse panics if the tag is invalid. Use Parse for user input.
	tag := language.MustParse("es")
	// Printer holds the locale state. It's safe to reuse across goroutines.
	p := message.NewPrinter(tag)
	// Formats 1234.56 as "1.234,56" for Spanish.
	// Spanish uses period for thousands and comma for decimals.
	p.Printf("Total: %v\n", 1234.56)
}

The output is Total: 1.234,56. The printer knows the grouping separator and decimal separator for Spanish. message.NewPrinter creates a struct that remembers the tag. Printf uses the printer's rules for verbs like %v, %d, and %f. Strings pass through unchanged. You need explicit message registration for translated text.

Printers hold state. Cache them or create per request.

Plural rules are not English

English has two plural forms: one and other. "1 file", "2 files". French has two, but the boundary is different. Arabic has six. Russian has three. Pluralization depends on the locale, not just the count.

x/text uses CLDR data to determine plural categories. You define messages with keys for each category. The printer picks the right form based on the number and the locale's rules.

Here's how to register plural messages. The syntax uses CLDR categories and conditions.

package main

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

func main() {
	tag := language.MustParse("ru")
	p := message.NewPrinter(tag)
	// Define plural forms. Keys are CLDR plural categories.
	// Russian needs "one", "few", and "many".
	// The condition syntax is {category, condition} text.
	p.Set(language.Russian, "files", "{one, 1 Ρ„Π°ΠΉΠ»}|{few, 2-4 Ρ„Π°ΠΉΠ»Π°}|{many, 5+ Ρ„Π°ΠΉΠ»ΠΎΠ²}")
	
	// The printer selects the form based on the count and locale rules.
	p.Printf("You have %d %s\n", 1, "files")  // 1 Ρ„Π°ΠΉΠ»
	p.Printf("You have %d %s\n", 2, "files")  // 2 Ρ„Π°ΠΉΠ»Π°
	p.Printf("You have %d %s\n", 5, "files")  // 5 Ρ„Π°ΠΉΠ»ΠΎΠ²
}

The Set method registers a message for a tag. The string uses a pipe-separated list of forms. Each form has a category and an optional condition. The printer evaluates the condition against the argument. If no condition matches, it falls back to the default category.

Plural rules are data, not logic. Let CLDR decide.

Matching user preferences

Users specify preferences via HTTP headers or settings. You need to find the best match among your supported locales. language.Match handles this using distance metrics. It considers language, script, region, and quality values.

Here's how to match a user's Accept-Language header against a list of supported tags.

package main

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

func main() {
	// Supported locales for the application.
	supported := []language.Tag{
		language.English,
		language.German,
		language.MustParse("zh-Hant"),
	}
	// User sends "zh-Hans-CN,en-US;q=0.8".
	// ParseAcceptLanguage handles the header format with quality values.
	userTags, _, err := language.ParseAcceptLanguage("zh-Hans-CN,en-US;q=0.8")
	if err != nil {
		// Compiler rejects this with undefined: language if import is missing.
		// Runtime panic if you ignore the error and use a nil slice.
		panic(err)
	}
	// Match returns the best supported tag and a confidence level.
	// It falls back gracefully based on linguistic distance.
	best, _, _ := language.Match(supported, userTags...)
	fmt.Println(best) // Prints "zh-Hant" because it's the closest match to zh-Hans.
}

ParseAcceptLanguage parses the header string into a slice of tags with quality values. Match compares the user tags against the supported list. It returns the best match and a confidence. zh-Hans-CN matches zh-Hant better than en because the language base matches, even though the script differs.

Convention aside: Check the confidence level. If the match is low, you might want to fall back to a default locale instead of serving a poor match. language.Match returns a language.Confidence value. Use it to decide if the result is acceptable.

Matching is the first step. Always validate the result.

Realistic example: Invoice service

In production, you format invoices for different regions. Numbers, currency, and plurals must align. currency.Amount links a value to a currency code. The printer uses the amount to pick the symbol and rules.

Here's a service that formats invoices. The printer is passed to avoid recreating state per call.

package main

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

// Invoice holds line items and a total.
type Invoice struct {
	Items []Item
	Total float64
}

// Item represents a single product.
type Item struct {
	Name  string
	Count int
	Price float64
}

// FormatInvoice returns a localized string representation.
// Printer is passed to avoid recreating state per call.
func FormatInvoice(inv Invoice, p *message.Printer) string {
	var result string
	for _, item := range inv.Items {
		// Format price with currency symbol and locale grouping.
		// currency.Amount links the value to a currency code.
		amt := currency.Amount{Value: item.Price, Currency: currency.USD}
		result += fmt.Sprintf("- %s: %v\n", item.Name, p.Sprint(amt))
	}
	// Total uses the same printer for consistency.
	totalAmt := currency.Amount{Value: inv.Total, Currency: currency.USD}
	result += fmt.Sprintf("Total: %v\n", p.Sprint(totalAmt))
	return result
}

func main() {
	inv := Invoice{
		Items: []Item{
			{Name: "Widget", Count: 1, Price: 19.99},
			{Name: "Gadget", Count: 3, Price: 5.50},
		},
		Total: 36.49,
	}
	// German locale uses comma for decimals and period for thousands.
	p := message.NewPrinter(language.MustParse("de"))
	fmt.Println(FormatInvoice(inv, p))
}

The output shows prices formatted for German. currency.Amount is essential. You can't just print a float. The printer needs the currency code to pick the symbol and rules. Sprint returns a string. The printer is reused for all items.

Currency amounts carry the code. Never pass raw floats for money.

Pitfalls and errors

MustParse panics on invalid tags. If you pass MustParse a string like en-US-foo, it panics with panic: language: invalid tag. Use Parse for dynamic input.

message.Printer is safe for concurrent reads. Set modifies state. If you share a printer and call Set concurrently, you have a race condition. Create printers per request or cache immutable printers. Don't share a global printer if you call Set dynamically.

The compiler rejects missing imports with undefined: language. If you forget to import golang.org/x/text/language, the build fails. The error is clear. Fix the import.

Forgetting to capture a loop variable in a goroutine is a common Go bug, but x/text doesn't introduce new concurrency traps. The printer is just a struct. Treat it like any other shared state.

Goroutines are cheap. Printers are not magic. Cache or recreate as needed.

Decision matrix

Use x/text/message when you need locale-aware number, date, or plural formatting. Use fmt when you are writing logs, internal tools, or debugging output where locale doesn't matter. Use a full i18n framework when you need template-based translation, context-aware strings, or plural forms with variables embedded in text. Use x/text/language for matching user preferences against supported locales.

Trust the CLDR data. Don't reinvent plural rules.

Where to go next