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,
    },
}
SourceReadsConfigured by
LocaleFromAcceptLanguageThe Accept-Language header, quality values honouredNothing. It is the default
LocaleFromPathA path segment, as /pt/booksPathIndex, counting from zero
LocaleFromQueryA query parameterQuery, defaulting to locale
LocaleFromHeaderA named headerHeader, defaulting to X-Locale
LocaleFromCookieA cookieCookie, defaulting to locale
LocaleFromCustomWhatever you writeExtractor

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

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.