The comma that broke your app
You ship a feature to display prices. In New York, the user sees $1,234.56. In Berlin, the user sees 1.234,56 €. In Tokyo, the formatting might differ again. You hardcode the comma logic, realize it breaks for Arabic numerals, and suddenly your "simple" print statement is a minefield of cultural edge cases. Go's standard library gives you fmt.Printf, which works fine for logs and debugging. It does not know about locales. When you need to format strings, numbers, and dates for humans in different regions, you reach for message.Printer from the golang.org/x/text project.
What message.Printer actually does
The message.Printer type lives in golang.org/x/text/message. This package is part of the x family, which means it is maintained by the Go team but lives outside the standard library. You import it explicitly. The printer acts like a formatter that remembers a locale. You create a printer for a specific language tag, and then you use it to format values. The printer applies rules for that locale: digit grouping, decimal separators, plural forms, and date layouts. It wraps the familiar Printf interface so you don't have to learn a new syntax. You still use verbs like %d and %s, but the printer intercepts the output and transforms it according to the locale.
The printer relies on the language package to define tags. A tag like language.English or language.German maps to Unicode CLDR data. This data contains the rules for formatting. The printer stores a reference to these rules. When you format a value, the printer looks up the rule and applies it. The code stays the same. The printer handles the variation.
Minimal example
Here is the simplest way to create a printer and format a number.
package main
import (
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func main() {
// Create a printer for US English.
// The printer holds the locale state and formatting rules.
p := message.NewPrinter(language.English)
// Format a large number.
// The printer inserts commas for thousands separators in English.
p.Printf("Price: %d\n", 1234567)
// Create a printer for German.
// German uses dots for thousands and commas for decimals.
de := message.NewPrinter(language.German)
de.Printf("Preis: %d\n", 1234567)
}
How the printer works at runtime
When you call message.NewPrinter(language.English), the package looks up the data for that language tag. The language package defines tags that map to CLDR rules. The printer stores a reference to these rules. When you call p.Printf, the function parses the format string. It finds the verbs. For %d, it delegates to the number formatter inside the printer. The number formatter checks the locale rules. It sees that English uses a comma for grouping and a period for decimals. It constructs the string character by character. The result is printed to standard output. If you switch the printer to language.German, the same %d verb produces a string with dots for grouping. The code stays the same. The printer handles the variation.
The printer is safe for concurrent use. Multiple goroutines can call Printf on the same printer without data races. The underlying data is read-only after creation. You can share a printer across requests if the locale is fixed. If you need different locales, create separate printers.
Pluralization rules
Numbers are only half the battle. Pluralization is where localization gets tricky. English has two forms: "1 item" and "2 items". French has more complex rules. Arabic has six forms. The message package lets you register plural functions. You define a function that takes a count and returns the correct string. The printer calls this function when it sees a plural verb.
You must register a plural function for every language you support. The printer does not guess plural rules. If you register for English and switch to German, the German printer will not have the plural function. You must register for each language.
package main
import (
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func main() {
// Create a printer for English.
p := message.NewPrinter(language.English)
// Register a plural function for the key "items".
// The function receives the count and returns the localized string.
p.SetPluralFunc(language.English, "items", func(count int) string {
// English plural rule: singular for 1, plural otherwise.
if count == 1 {
return "item"
}
return "items"
})
// Use the %p verb with the plural key.
// The printer calls the registered function with the count.
p.Printf("You have %d %p.\n", 1, "items")
p.Printf("You have %d %p.\n", 5, "items")
}
Register plurals for every language you support. The printer does not inherit rules.
Dates and currency
The printer can also format dates and currency. Use the %T verb for time and %C for currency. The printer applies locale-specific layouts. For currency, you pass a currency.Code and an amount. The printer formats the amount and appends the currency symbol in the correct position.
package main
import (
"time"
"golang.org/x/text/currency"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func main() {
// Create a printer for French.
p := message.NewPrinter(language.French)
// Format a time value.
// The printer uses the locale's preferred date and time layout.
p.Printf("Date: %T\n", time.Now())
// Format a currency value.
// The printer formats the amount and places the symbol correctly.
p.Printf("Cost: %C\n", currency.USD, 1234.56)
}
The printer formats values. It does not translate words.
Realistic example: HTTP handler
Real applications rarely hardcode the locale. You usually determine the language from a request header, a user profile, or a configuration flag. Here is a function that formats a receipt for a user based on their preferred language. The handler parses the Accept-Language header, creates a printer, and formats the response.
package main
import (
"net/http"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
// HandleReceipt serves a localized receipt.
// It extracts the locale from the request and formats the output.
func HandleReceipt(w http.ResponseWriter, r *http.Request) {
// Extract the Accept-Language header.
// This header contains the user's preferred languages.
acceptLang := r.Header.Get("Accept-Language")
// Parse the header into a language tag.
// The parser handles complex headers with quality values.
tag, err := language.Parse(acceptLang)
if err != nil {
// Use a default tag if parsing fails.
// This ensures the handler always has a valid locale.
tag = language.English
}
// Create a printer for the user's locale.
p := message.NewPrinter(tag)
// Format the response.
// The printer applies locale-specific number formatting.
w.Header().Set("Content-Type", "text/plain")
p.Fprintf(w, "Total amount: %d\n", 1234567)
}
Parse headers defensively. Fall back to a default tag.
Pitfalls and compiler errors
If you try to use message without importing the package, the compiler rejects the code with undefined: message. If you pass a language.Tag where a string is expected, you get a type mismatch error. The compiler complains with cannot use tag (variable of struct type language.Tag) as string value in argument if you mix types.
The printer does not translate text. It only formats values. If you need to translate "Hello" to "Hola", the printer cannot help. You need a translation library. The printer is for numbers, dates, and plurals.
Creating a printer is cheap, but if you do it in a tight loop, you might want to cache. The printer caches formatting rules. Creating a printer per request is usually fine. Caching by language tag can save allocation in high-throughput services.
Plural functions must be registered per language. If you forget to register for a language, the printer will not find the function. The printer will not panic. It will use a fallback or skip the pluralization. This can lead to incorrect output. Register plurals for every language you support.
The message package is not thread-safe for registration. You can call SetPluralFunc concurrently, but the documentation recommends registering all plurals before using the printer. Register plurals at startup. Use the printer concurrently.
Decision matrix
Use fmt.Printf when you are writing logs, debugging output, or internal messages that never reach a user.
Use message.Printer when you need to format numbers, dates, or plurals according to locale rules, but the text itself is static or handled elsewhere.
Use a full internationalization library like github.com/nicksnyder/go-i18n when you need to translate entire strings and messages, not just format values.
Use language.Parse when you receive a locale string from a user and need to convert it to a language.Tag for the printer.
Use message.NewPrinter with a cached map when you create printers in a hot loop and want to reduce allocation.
Use the printer's Sprintf method when you need a formatted string for return values, not output to a writer.