Internationalization
A request's locale is resolved once, before the handler runs, and every string in the response is written in it. That includes the strings Muzak produces: the wording of each validation rule, what the request binder says about a value it could not read, and the sentence behind each HTTP status.
//go:embed locales
var locales embed.FS
func LocaleOptions() muzak.I18nOptions {
return muzak.I18nOptions{Store: i18n.MustLoad(locales, "locales")}
}
A handler translates through its context, which already knows the locale.
func Greeting(ctx *muzak.Context, in schemas.GreetingParams) (schemas.GreetingOut, error) {
return schemas.GreetingOut{
Locale: ctx.Locale(),
Greeting: ctx.T("greeting.hello", "name", in.Name),
Items: ctx.T("greeting.items", "count", in.Items),
Today: ctx.L(time.Now(), "as", "date", "format", "long"),
}, nil
}
$ curl -H 'Accept-Language: es' 'localhost:8080/greeting?name=Ada&items=1'
{"locale":"es","greeting":"Hola, Ada","items":"Tienes un artículo",
"today":"24 de agosto de 2026"}
A locale is passed, never set
Go has no per-goroutine storage, and a package-level locale shared by every goroutine would leak one request's language into another: a Spanish request setting it, and an English one two microseconds later reading it. On a server handling more than one request at a time, which is every server, a current locale is not a thing that can exist.
So the locale is passed. Muzak resolves it once per request, carries it on the request's
context, and ctx.T reads it from there. Code with no request in hand names the locale
itself.
i18n.T("es", "greeting.hello", "name", "Ada")
Anywhere there is a context.Context but no Muzak one, such as a repository or a goroutine
started from a handler, the same value is one call away.
locale, ok := muzak.LocaleFromContext(ctx)
Where the locale comes from
The choice is usually written by hand in a filter that runs per request. Here it is declared, so it is visible in the application's options rather than buried in code. Sources are tried in order, and the first that yields a locale the application actually answers in wins.
muzak.I18nOptions{
Store: i18n.MustLoad(locales, "locales"),
Sources: []muzak.LocaleSource{
muzak.LocaleFromQuery,
muzak.LocaleFromAcceptLanguage,
},
}
| Source | Reads | Configured by |
|---|---|---|
LocaleFromAcceptLanguage | The Accept-Language header, quality values honoured | Nothing. It is the default |
LocaleFromPath | A path segment, as /pt/books | PathIndex, counting from zero |
LocaleFromQuery | A query parameter | Query, defaulting to locale |
LocaleFromHeader | A named header | Header, defaulting to X-Locale |
LocaleFromCookie | A cookie | Cookie, defaulting to locale |
LocaleFromCustom | Whatever you write | Extractor |
LocaleFromAcceptLanguage is the default because every browser sends the header and nothing
has to be added to a URL for it to work. The client's quality values order the candidates, a
range matches by language as well as in full, so pt-BR is served pt when that is what
you have, and en;q=0 refuses English outright even when English is all there is.
A locale in a cookie is invisible in the URL, so two people opening the same link see different pages. Prefer a source that travels with the address unless you have a reason not to.
Nothing a request says is trusted
A value arriving from a client is matched against the locales the application declared, and
discarded if it matches none. What comes out of resolution is always one of your own
strings, never one of theirs, which is the single rule that keeps ?locale=../../etc/passwd
out of a filesystem path and a locale carrying a line break out of a response header.
Locale files
YAML or JSON, with the locale at the top level. Files are merged key by key rather than file by file, so a locale may be spread across as many files as suits it.
es:
errors:
messages:
blank: "es obligatorio"
too_short:
one: "debe tener al menos 1 carácter"
other: "debe tener al menos %{count} caracteres"
Muzak has no third-party dependencies, so the YAML is read by a parser written for this and nothing else. It reads the subset locale files are written in, which is deliberately the subset the published locale corpora use, so a file taken from one loads here unchanged, formats and plural forms included. Anything outside that subset is refused with the line and column it was found at, rather than guessed at.
Files are embedded rather than read from disk by default. A service that shipped without its
locale directory would start cleanly and then answer every request in the wrong language,
which is the kind of failure that reaches production. Point Dir at a directory instead
when translations should be editable without a rebuild.
What you have to translate, and what you do not
A locale file names the strings your service adds and the rules you want worded differently. Everything else falls through to the locale Muzak ships, which covers every string the framework produces. A translation grows as a service is translated rather than having to be complete before it is useful.
$ curl -H 'Accept-Language: es' localhost:8080/items -d '{"name": "", "cost": 10, "price": 5}'
{"error":{
"code":"validation_error",
"message":"La solicitud no pudo ser validada.",
"status":422,
"details":[
{"field":"name","location":"body","issue":"cada artículo necesita un nombre"},
{"field":"price","location":"body","issue":"el precio no puede ser inferior al coste"}
]}}
The code is not translated, and never will be. Clients branch on it, so a locale file that reworded one would break every client reading it.
Four scopes, narrowest first
A failure is looked up under four keys, in order, so one rule on one field of one model can be phrased without restating any other.
errors.models.<model>.attributes.<field>.<rule>
errors.models.<model>.<rule>
errors.attributes.<field>.<rule>
errors.messages.<rule>
es:
errors:
messages:
blank: "es obligatorio"
models:
create_item:
attributes:
name:
blank: "cada artículo necesita un nombre"
The model is the input type's name in snake case. A framework that distinguishes a persisted record from a plain model needs a fifth level above these; Muzak has one kind of model, so it has four.
Naming a translation yourself
Message fixes the wording in one language on purpose. MessageKey names a translation
instead, and the rule's own values still fill it in.
func (in *CreateItem) Validate(v *muzak.Validation) {
v.String(&in.Password).MinLen(12).MessageKey("errors.password.too_short")
v.When(in.Price < in.Cost).
RejectKey(&in.Price, "errors.item.price_below_cost")
}
An error you raise yourself works the same way, keeping the message already written as the fallback.
return muzak.Forbidden("only editors may do that").
WithMessageKey("errors.access.denied")
Pluralization
A count both prints and chooses which wording prints it.
en:
greeting:
items:
zero: "You have no items"
one: "You have one item"
other: "You have %{count} items"
ctx.T("greeting.items", "count", 0) // You have no items
ctx.T("greeting.items", "count", 1) // You have one item
ctx.T("greeting.items", "count", 5) // You have 5 items
English has two forms and an optional zero. Russian has four, Arabic six, and Japanese one. Muzak carries the CLDR arithmetic for around ninety languages, so a locale file supplies only the words.
ru:
files:
one: "%{count} файл"
few: "%{count} файла"
many: "%{count} файлов"
A rule that is genuinely new is conventionally written as a function inside the locale data, which a YAML file in Go cannot hold. A locale file here names a rule instead, and a rule nobody has written yet is supplied as a function.
ru:
i18n:
plural:
rule: slavic
Interpolation
%{name} takes a value as it comes; %<name>d takes a format directive.
en:
product_price: "$%{price}"
progress: "%<done>03d of %<total>03d"
ctx.T("product_price", "price", 10)
ctx.T("progress", "done", 7, "total", 250)
Arguments are alternating names and values, the way slog reads them, because a translation
takes an open set of values under names the translator chose and Go has no keyword
arguments. A handful of names mean something to the lookup rather than to the sentence:
count, scope, default and locale. A translation that names scope or default is an
error rather than a silent substitution.
A value the translation expects and the call did not supply is also an error. A message missing a number is worse than no message, because it looks correct.
Dates and numbers
ctx.L writes a value the way the locale writes it.
ctx.L(time.Now()) // Mon, 24 Aug 2026 09:05:03 +0000
ctx.L(time.Now(), "as", "date", "format", "long")
ctx.L(1234.5, "precision", 2) // 1,234.50 in en, 1.234,50 in es
es:
date:
formats:
long: "%d de %B de %Y"
month_names: [~, enero, febrero, marzo, abril, mayo, junio, julio, agosto,
septiembre, octubre, noviembre, diciembre]
number:
format:
separator: ","
delimiter: "."
Patterns are strftime rather than Go layouts, and that is not a matter of taste. Go formats
a time by example, and the reference layout hard-codes January, so a Go layout can only
ever produce an English month name. A strftime pattern names the field, which leaves Muzak
free to fill it from date.month_names in whatever locale was asked for. It is also what the
published locale corpora are written in.
A pattern beginning go: is handed to Go's own formatter, for the formats meant to be read
by a machine.
en:
date:
formats:
iso: "go:2006-01-02T15:04:05Z07:00"
The number helpers from the same scope are on the store: NumberWithDelimiter,
NumberWithPrecision, NumberToCurrency, NumberToPercentage, NumberToHumanSize,
DistanceOfTimeInWords and ToSentence.
Caching
A response reports the locale it was written in, and says so in Vary when the header took
part in choosing it.
Content-Language: es
Vary: Accept-Language, Accept-Encoding
Without the Vary, a cache in front of the service will serve one language to a client that
asked for another. It is added by default and DisableVary turns it off, for a service whose
cache key is already settled by something else.
Leaving it out
Leave AppOptions.I18n unset and none of this exists. No middleware is installed, no locale
is resolved, and every message reads exactly as it does without this feature.
It goes further than configuration. The framework reaches a translation engine only through
an interface, so a binary that never mentions muzak.dev/framework/i18n links neither the
engine, nor the YAML parser, nor the table of plural rules. A service that answers in one
language does not carry the machinery for ninety.
type Translator interface {
Translate(locale, key string, args ...any) string
Localize(locale string, value any, args ...any) string
Exists(locale, key string) bool
AvailableLocales() []string
DefaultLocale() string
}
Anything satisfying it can be the store, which is how translations come from a database or a service rather than from a file.
Running this at scale
Nothing here holds per-process state that a request can change, which is what makes it safe on a platform that runs many requests at once on an instance it may destroy a second later.
A locale is resolved from the request, put on the request's context, and copied onto the
pooled Context when that is taken from the pool. It is cleared when the Context goes
back. Nothing writes to the store after start-up: the translations, the plural rules and
the fallback chains are all settled while the application is being built, and only read
afterwards. The tests run 64 goroutines through five locales under the race detector, half
of them naming no locale at all, and require every answer to match its own request.
Cold start
On this machine, building an application with five locale files costs about 83 microseconds more than building the same application without translations, and about 69 kilobytes. The rest of a cold start is the framework and the runtime.
BenchmarkColdStartWithoutI18n 1.007 ms 6.90 MB
BenchmarkColdStart 1.090 ms 6.97 MB
BenchmarkStoreOnly 0.048 ms 0.07 MB
Locale files are parsed once, when the store is built, and the locale the framework ships is parsed once per process and shared by every store after that. A warm request that translates a message with nothing to interpolate allocates nothing at all.
Embed the locale files
Use FS rather than Dir anywhere the filesystem is not yours. A container built from
scratch has no locale directory, and an instance that starts without one will serve every
request in the default language rather than failing, which is the kind of fault that is
noticed a week later by a customer.
//go:embed locales
var locales embed.FS
Time zones need a database
Timezone() resolves a name against the zone database, and a Go binary does not carry one
unless it is asked to. On a scratch or distroless image there is no /usr/share/zoneinfo
either, so the rule refuses every value including the correct ones, and nothing says why.
Either carry the database, at a cost of about 450 kilobytes:
import _ "time/tzdata"
or run on an image that has one. Check at start-up either way, so that a missing database stops the process rather than every request:
if !validate.TimezoneDataAvailable() {
log.Fatal("no time zone database: import _ \"time/tzdata\" or use an image that has one")
}
Caches in front of the service
A response says which language it is in and, when the header decided it, that the header decided it.
Content-Language: es
Vary: Accept-Language, Accept-Encoding
Without the Vary, a shared cache will hand one language to a client that asked for
another. It is written by default; DisableVary turns it off for a service whose cache key
is already settled some other way.
One thing deliberately left out
errors.format, and the full messages built from it, have no counterpart here.
The idea is to join an attribute name to a message to make one sentence, as "Email is
required". Muzak reports field, location and issue as separate members of the
envelope and never concatenates them, so there is nothing for a format to join. A client
that wants the sentence builds it from the three, in whatever order its own language puts
them, which is the part a fixed format gets wrong outside English.
Everything else is here: scopes, default chains including keys that name other keys, bulk lookup, fallbacks, swappable and chained backends, and the exception handlers.
Where to go next
- Validation for the rules whose messages these are.
- Error handling for the envelope they arrive in.
- Middleware for where the locale is resolved in the chain.