Skip to content
gohacks.dev
Basics

Error Strings and Localization in Go Applications Done Well

Localize Go errors without translating error strings: keep Error() for engineers, carry a stable code and arguments, and translate at the boundary.

7 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

Sooner or later someone asks for the application to speak French, or Japanese, or Brazilian Portuguese, and the first instinct is to translate the error messages. It is the wrong instinct. An error string is part of how engineers find problems: they search logs for it, grep the code for it, paste it into a ticket. A translated string breaks all of that, and it lives in the wrong place, deep in a package that has no idea what language the person at the other end reads.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The right split keeps two things apart: the error that explains the failure to engineers, and the message that explains it to a user. This article shows how to carry the second inside the first without confusing them. It continues user-facing and internal errors and uses the design from domain errors. It belongs to the error handling guide. Everything ran on Go 1.25.6.

Why not translate Error()

Three reasons settle it.

Engineers read it. A log line in the language of the user who triggered it is unreadable by the engineer on call who does not speak it. Searching for a message across all the logs of a service stops working when it comes out in twelve languages.

It is assembled in the wrong layer. An error is created where something fails, often in a package that knows nothing about the request. The language is a property of the request (an Accept-Language header, a user preference), which only the outermost layer has.

Messages need grammar. Plurals, gender, word order and number formats vary by language, so a sentence built by concatenating pieces in the code is wrong in languages that order words differently. Translators need a whole template, with placeholders, to produce a correct sentence.

The consequence is a rule: the error carries data, and a separate step turns the data into prose.

An error with a code and arguments

A localizable error holds a stable identifier and the values the message needs. The identifier is a contract: it does not change when someone rewords the English, and it is what the catalog is keyed by.

errs.goGo
package main

import "fmt"

// Coded is an error a user may need to read in their own language.
type Coded struct {
	Code string // stable key such as "quota.exceeded"
	Args []any  // values for the message template
	Msg  string // English text for logs and engineers
}

func (e *Coded) Error() string { return e.Msg }

// NewCoded builds the English message from the same template the catalog uses.
func NewCoded(code, english string, args ...any) *Coded {
	return &Coded{Code: code, Args: args, Msg: fmt.Sprintf(english, args...)}
}

Error() returns the English text and stays stable for logs. The Code and Args travel with the error and survive wrapping, because the translator finds them with errors.As.

The catalog and the translation step

A catalog maps a language and a code to a template. The edge of the program looks up the user's language and formats:

catalog.goGo
package main

import (
	"errors"
	"fmt"
)

var catalog = map[string]map[string]string{
	"en": {
		"quota.exceeded": "You have used %d of %d uploads this month.",
		"user.not_found": "We could not find that account.",
	},
	"fr": {
		"quota.exceeded": "Vous avez utilisé %d envois sur %d ce mois-ci.",
		"user.not_found": "Nous n'avons pas trouvé ce compte.",
	},
}

// Message returns text for err in lang, falling back to English, then to a
// generic sentence when the error carries no code at all.
func Message(err error, lang string) string {
	var ce *Coded
	if errors.As(err, &ce) {
		for _, l := range []string{lang, "en"} {
			if tmpl, ok := catalog[l][ce.Code]; ok {
				return fmt.Sprintf(tmpl, ce.Args...)
			}
		}
	}
	for _, l := range []string{lang, "en"} {
		if tmpl, ok := catalog[l]["generic"]; ok {
			return tmpl
		}
	}
	return "Something went wrong."
}

func init() {
	catalog["en"]["generic"] = "Something went wrong. Please try again."
	catalog["fr"]["generic"] = "Une erreur s'est produite. Veuillez réessayer."
}

The lookup tries the user's language, then English, then a generic sentence, so a missing translation degrades to English text and not to a blank or a raw key. The generic message also covers every error that has no code, which is all the unexpected ones, and that is the safe default for the same reason a 500 body is generic (the HTTP adapter does this).

Seeing it

main.goGo
package main

import (
	"errors"
	"fmt"
)

func main() {
	err := fmt.Errorf("upload avatar: %w", NewCoded("quota.exceeded",
		"upload quota exceeded: %d of %d", 10, 10))

	fmt.Println("log:", err)
	fmt.Println("en: ", Message(err, "en"))
	fmt.Println("fr: ", Message(err, "fr"))
	fmt.Println("ja: ", Message(err, "ja"))
	fmt.Println("raw:", Message(errors.New("pq: connection refused"), "fr"))
}
Text
log: upload avatar: upload quota exceeded: 10 of 10
en:  You have used 10 of 10 uploads this month.
fr:  Vous avez utilisé 10 envois sur 10 ce mois-ci.
ja:  You have used 10 of 10 uploads this month.
raw: Une erreur s'est produite. Veuillez réessayer.

The log line is English with the engineer's context. The user gets the sentence in their language, with the numbers filled in, or the English one when Japanese is not in the catalog yet. The last line is an error that was never classified: the user sees the generic sentence in French, and the driver text stays in the log.

Word order differs by language

Templates must be free to move the arguments around. Go's fmt supports explicit argument indexes, so a translator can reorder placeholders without touching the code that supplies them:

main.goGo
package main

import "fmt"

func main() {
	args := []any{"Ada", 3}
	fmt.Printf("%[1]s has %[2]d messages\n", args...)
	fmt.Printf("%[2]d messages are waiting for %[1]s\n", args...)
}
Text
Ada has 3 messages
3 messages are waiting for Ada

Both lines use the same arguments in different positions, which is what a language with a different sentence structure needs. If your catalog uses plain %s and %d, translators are forced into English order, so prefer indexed verbs in templates from the start. The placeholder-count test above should then count distinct indexes and not verbs; adapt it when you adopt the habit.

Test the catalog

Nothing stops a developer from adding a code and forgetting a language. A test that cross-checks the catalogs is the cheap defense:

catalog_test.goGo
package main

import "testing"

func TestEveryCodeIsTranslated(t *testing.T) {
	for lang, msgs := range catalog {
		for code := range catalog["en"] {
			if _, ok := msgs[code]; !ok {
				t.Errorf("language %q is missing code %q", lang, code)
			}
		}
	}
}

func TestPlaceholdersMatch(t *testing.T) {
	// The English template is the source of truth for the arguments.
	for code, en := range catalog["en"] {
		for lang, msgs := range catalog {
			if countVerbs(msgs[code]) != countVerbs(en) {
				t.Errorf("%s/%s: placeholder count differs from English", lang, code)
			}
		}
	}
}

func countVerbs(s string) int {
	n := 0
	for i := 0; i < len(s)-1; i++ {
		if s[i] == '%' && s[i+1] != '%' {
			n++
		}
	}
	return n
}
Text
=== RUN   TestEveryCodeIsTranslated
--- PASS: TestEveryCodeIsTranslated (0.00s)
=== RUN   TestPlaceholdersMatch
--- PASS: TestPlaceholdersMatch (0.00s)
PASS
ok  	example.test/lab	1.273s

The first test catches the forgotten translation. The second catches the subtler failure: a translator who dropped a placeholder, which would print %!d(MISSING) or silently omit a number. Run both in CI, and a catalog change cannot ship broken.

Real catalogs and plurals

The stdlib map above shows the shape and breaks down on plurals: "1 file" versus "2 files", and languages with more than two plural forms. For production use the golang.org/x/text packages, maintained by the Go team outside the standard library. golang.org/x/text/language parses and matches language tags (so Accept-Language: fr-CA,fr;q=0.9 resolves to the best supported language), and golang.org/x/text/message gives you a printer per language that formats numbers and dates per locale and supports plural selection. Message catalogs are usually extracted and managed with the gotext tool from the same module. The templates are still keyed by something stable, so the structure here holds: error carries code and arguments, edge translates.

What not to localize

Not everything a user sees needs a catalog entry. Validation messages that echo user input back ("age must be at least 18") are usually better built from a code, the field name and a limit, with the field name translated as a label by the front end. Technical details such as request IDs, status codes and error codes stay as they are, since they are what a support person asks for on the phone. Keep the set of coded user-facing errors small, for the same reason domain error kinds stay few: every code is a message times every language, and a hundred codes in ten languages is a thousand strings to keep correct. Anything that falls outside the set shows the generic sentence plus a reference ID, and engineers find the detail in the log.

Where translation belongs

Resolve the language once per request, early, and carry it in the context or on the request. Translate in exactly one place, the code that builds the response body, and call Message(err, lang) there. For an API, a better division is often to return the code and arguments in the body and let the client translate, which keeps the server free of copy and lets the front end own its strings. In both cases the internal error stays English and stays in the log.

For deeper reading, see the Go blog's Language and Locale Matching in Go and the golang.org/x/text/message package.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.