Iris ships internationalization as a regular middleware package,
github.com/kataras/iris/v14/middleware/i18n. It loads translation files in
INI, JSON, TOML or YAML format, matches the request language against your
registered locales using BCP 47 tags, and exposes the matched locale to your
handlers. This chapter covers only what the package actually provides: loading
translations, per-request language detection, message rendering with plurals
and variables, and the optional path/subdomain rewriting router.
Background
Internationalization (abbreviated i18n, for the eighteen letters between
the “i” and the “n”) is structuring an application so it can address each
user in their own language. The obstacle is rarely translation itself; it
is where the words live. When user-facing strings are hardcoded, written
literally inside handlers and templates, adding a second language means
hunting down every string in the codebase, and translators, who are
usually not programmers, would have to edit Go source to do their job.
Worse, a fixed string like "You have 3 new messages" glued together in
code embeds English grammar into program logic.
The standard cure is a message catalog: user-facing text moves out of
code and into plain data files, one set per language, where every message
has a key: a stable name like welcome or FreeDay. Code asks for the
key; the catalog answers in whichever language was matched for the current
request. That is the problem that disappears from your handlers: they no
longer contain any user-facing prose at all, and shipping a new language
becomes adding files rather than changing code.
Translation alone is not enough, which is why catalogs are more than key-to-string maps:
- Plurals. English has two forms (1 day, 5 days), but languages disagree wildly: Russian uses different forms for 1, 2–4 and 5+; Arabic distinguishes six cases. A catalog therefore stores several variants of a message and picks one based on a count at run time.
- Variables and word order. A sentence with a value in the middle (“You are 10 minutes late”) cannot be built by concatenating halves, because other languages put the value elsewhere. Messages carry placeholders instead, and the placeholder can move freely per language.
- Locale codes. Languages are identified by standardized language
tags (BCP 47, defined in RFC 5646) such as
el-GR: a language code (el, Greek) optionally followed by a region code (GR, Greece). The region part matters when regions diverge:en-USanden-GBspell, format dates and count currency differently. A locale is the matched combination the application serves.
The remaining question is how a server knows which language a given
request wants. Every browser volunteers an answer: the Accept-Language
request header carries the user’s ranked preferences, taken from their
operating system or browser settings (for example
el-GR, el;q=0.9, en;q=0.8, where q weights the ranking). That is a
good default and a poor final word, since users borrow machines and
override preferences, so real applications also honor explicit choices: a
?lang= URL parameter, a saved cookie, a language subdomain
(el.example.com) or a path prefix (/el-GR/account). The i18n
middleware you meet below checks exactly these sources, in a documented
order, and falls back to your default language when nothing matches.
Package Overview
The package has three cooperating pieces:
i18n.New()returns an*i18n.I18ncatalog. You load locale files into it once at startup withLoad,LoadFS,LoadKVorLoadAssets.i18n.NewHandler(locales, i18n.HandlerOptions{...})returns a regular middleware handler. Register it withUse. On every request it detects the language and stores the matched locale on the context.i18n.NewRouter(locales, i18n.RouterOptions{...})is optional. Register it withUseRouterto strip language prefixes such as/el-GR/...(or language subdomains such asel.example.com) before routing happens.
Handlers then read translations through the package-level functions
i18n.GetLocale(ctx) and i18n.GetMessage(ctx, key, args...).
Loading Translations
Load accepts a glob pattern and an optional list of language codes. The
language of each file is deduced from its path: a directory named after the
language (locales/el-GR/anything.yml) or a file named after it
(locales/el-GR.yml) both work.
package main
import "github.com/kataras/iris/v14/middleware/i18n"
func main() {
locales := i18n.New()
// First language is the default/fallback one.
err := locales.Load("./locales/*", "en-US", "el-GR")
if err != nil {
panic(err)
}
}Rules worth knowing:
- The first language in the list is the default.
SetDefault("el-GR")can reorder that after loading, but usually you just list the default first. - If you omit the language list entirely, the languages are discovered dynamically from the file paths.
- The file extension selects the parser:
.ini,.json,.toml/.tml, and.yaml/.yml(the default). Multiple files for the same language are merged into one message catalog. - Nested keys flatten with dots: a YAML map
user: {name: ...}is fetched as"user.name". INI sections behave the same way.
Embedded Files
Go can compile arbitrary files straight into the executable: the
//go:embed directive tells the compiler to bundle the named files, and
the resulting embed.FS value serves them through the standard fs.FS
read-only filesystem interface. For translations this means the deployed
binary is self-contained. There is no locales/ directory to ship,
forget or mismatch.
LoadFS works with any fs.FS, including embed.FS, so translations can be
compiled into the binary:
import "embed"
//go:embed locales/*
var localesFS embed.FS
func load(locales *i18n.I18n) error {
return locales.LoadFS(localesFS, "locales/*", "en-US", "el-GR")
}Maps
LoadKV loads translations from an in-memory i18n.LangMap
(map[string]map[string]any), which is convenient in tests:
langMap := i18n.LangMap{
"en-US": map[string]any{
"hello": "Hello",
},
"el-GR": map[string]any{
"hello": "Γειά",
},
}
err := locales.LoadKV(langMap, "en-US", "el-GR")LoadAssets(assetNames, asset, languages...) exists as well, for go-bindata
style generated assets.
Loader Options
The Loader field of *i18n.I18n is an i18n.LoaderConfig. Configure it
before calling any Load method:
locales := i18n.New()
locales.Loader.Strict = true // fail unless every language loads.
locales.Loader.Left = "{{" // template delimiters (defaults).
locales.Loader.Right = "}}"
locales.SetDefaultMessageFunc(
func(langMatched, key string, args ...any) string {
return "[missing: " + key + "]"
})Without a default message function, a lookup for an unknown key returns an empty string.
The Request Handler
i18n.NewHandler builds the middleware that performs language detection. All
detection sources are opt-in through i18n.HandlerOptions:
app.Use(i18n.NewHandler(locales, i18n.HandlerOptions{
// ?lang=el-GR
URLParameter: "lang",
// read the language from a cookie named "lang".
Cookie: "lang",
// treat a subdomain as a language: el.example.com.
Subdomain: true,
// optional custom source, checked before the ones above.
ExtractFunc: nil,
}))The handler checks the sources in a fixed order and stops at the first registered language that matches:
- A language force-set by server code (
i18n.SetLanguage, see below). ExtractFunc, if set.- The URL query parameter (
URLParameter). - The cookie (
Cookie). The handler only reads this cookie; writing it is your job or the router’s (seeRouterOptions.Cookie). - The subdomain, if
Subdomainis true. - The
Accept-Languagerequest header (always checked). - Fallback: the default locale, i.e. the first registered language.
Matching is lenient: for a registered el-GR, the inputs el-GR, el-gr
and plain el all resolve to the same locale.
One asymmetry in that list is worth knowing. Rungs 2 through 7 fall
through: a source that is unset, empty or unmatched simply hands over to
the next. Rung 1 does not. A language force-set with i18n.SetLanguage
that matches nothing registered leaves the request with no locale at
all, so i18n.GetLocale(ctx) returns nil and i18n.GetMessage returns
an empty string. It does not fall back to the default.
Translating in Handlers
i18n.GetLocale(ctx) returns the matched locale.Locale, an interface with
Language(), Index(), Tag() and GetMessage(key, args...).
i18n.GetMessage(ctx, key, args...) is a shortcut for
GetLocale(ctx).GetMessage(key, args...).
app.Get("/", func(ctx iris.Context) {
locale := i18n.GetLocale(ctx)
hi := locale.GetMessage("hi", "iris")
// Equivalent: hi := i18n.GetMessage(ctx, "hi", "iris")
ctx.Writef("[%s] %s", locale.Language(), hi)
})Plain messages are rendered with printf-style verbs, so a locale entry
hi = hello, %s combined with GetMessage("hi", "iris") produces
hello, iris. Indexed verbs such as %[1]d are supported too.
To override the detected language:
i18n.SetLocaleByLanguage(ctx, "el-GR")switches the locale for the rest of the current request. Use it inside route handlers, after the i18n handler has run.i18n.SetLanguage(ctx, "el-GR")stores a language code that the i18n handler honors above every other source. It only has an effect when called before the handler runs, e.g. in a middleware registered earlier.
Pluralization and Variables
Plural Forms
A message becomes plural-aware when its value is a map whose keys are plural
forms. The supported forms are zero, one, two, other and the
comparisons =N, <N, >N:
FreeDay:
one: "You have a day off"
other: "You have %[1]d free days"
"=3": "You have three days and %[2]d minutes off."The plural count is the first argument to GetMessage and it also feeds the
printf verbs:
i18n.GetMessage(ctx, "FreeDay", 1) // You have a day off
i18n.GetMessage(ctx, "FreeDay", 5) // You have 5 free days
i18n.GetMessage(ctx, "FreeDay", 3, 15) // ... three days and 15 minutes off.Calling a plural message without a count does not panic; the returned string is an error description, which makes the mistake visible during development.
Variables
A locale file can declare a Vars list. Each variable has its own plural
forms and is referenced inside messages as ${Name}. The count that selects
the variable’s form comes from the message arguments:
Vars:
- Minutes:
one: "minute"
other: "minutes"
YouLate: "You are %[1]d ${Minutes} late."i18n.GetMessage(ctx, "YouLate", 1) // You are 1 minute late.
i18n.GetMessage(ctx, "YouLate", 10) // You are 10 minutes late.Vars declared at the top level of a file are shared by every message of
that locale. A message can also carry its own local variables by using a map
value with Text and Vars keys:
HouseCount:
Text: "She has %[1]d ${Houses}"
Vars:
- Houses:
one: "house"
other: "houses"Template Messages
Go’s standard library includes text/template, a small templating
language: a string containing placeholders such as {{.name}} is parsed
once, then rendered against a data value that fills the placeholders in.
Compared to printf verbs, templates name their inputs, which reads better
when a message takes several values.
A message whose value contains the configured delimiters (default
{{ and }}) is parsed as a Go text/template. The first GetMessage
argument becomes the template data, usually a map:
welcome: "Welcome back, {{.name}}!"i18n.GetMessage(ctx, "welcome", iris.Map{"name": "Makis"})
// Welcome back, Makis!For plural template messages the reserved data key PluralCount selects the
plural form. Custom per-locale template functions can be registered through
the Funcs field of the loader configuration; every locale template also has
a built-in tr function that resolves other keys of the same locale.
Path and Subdomain Rewriting
i18n.NewRouter lets URLs carry the language, e.g. /el-GR/account or
el.example.com/account. It must run before routing, so register it with
UseRouter, and keep the regular handler registered with Use as well:
app.UseRouter(i18n.NewRouter(locales, i18n.RouterOptions{
// write the detected language to this response cookie.
Cookie: "lang",
// also detect and strip language subdomains.
Subdomain: true,
// optional hook, called with the detected language tag.
InjectFunc: nil,
}))
app.Use(i18n.NewHandler(locales, i18n.HandlerOptions{
URLParameter: "lang",
Cookie: "lang",
}))When the first path segment (or the subdomain, with Subdomain: true)
matches a registered language, the router rewrites the request in place: it
removes the language part, sets the request’s Accept-Language header to the
matched tag and, if Cookie is set, writes the language to a response cookie
shared across subdomains. Routing then continues with the clean path, so a
route defined as /account serves /el-GR/account too, and the i18n handler
picks the language up from the rewritten header. Lenient matching applies
here as well: /el/account and /el-gr/account both work.
Template Integration
Server-side views (see Chapter 6) integrate
through plain template functions. The pattern used by the package itself
registers a static tr function on the engine and passes per-request
closures as view data:
import (
"github.com/kataras/blocks"
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/i18n"
"github.com/kataras/iris/v14/middleware/view"
)
func registerViews(app *iris.Application, locales *i18n.I18n) {
templates := blocks.New("./views")
// Static form, any language: {{tr "el-GR" "hi" "iris"}}.
templates.AddFunc("tr",
func(lang, key string, args ...any) string {
if locale, ok := locales.GetLocale(lang); ok {
return locale.GetMessage(key, args...)
}
return "[INVALID LOCALE] " + lang
})
templates.Load()
app.UseRouter(view.Handler(templates))
app.Get("/", func(ctx iris.Context) {
view.Render(ctx, "index.html", "", iris.Map{
// Request-aware form: {{call .tr "hi" "iris"}}.
"tr": func(key string, args ...any) string {
return i18n.GetMessage(ctx, key, args...)
},
})
})
}<h1>{{call .tr "hi" "iris"}}</h1>
<p>{{tr "el-GR" "hi" "iris"}}</p>Complete Example
A runnable program with two locale files, plural messages, URL-parameter and header detection plus path prefixes:
myapp/
├── locales/
│ ├── en-US.yml
│ └── el-GR.yml
└── main.golocales/en-US.yml
hi: "hello, %s"
FreeDay:
one: "You have one day off."
other: "You have %d days off."locales/el-GR.yml
hi: "γεια, %s"
FreeDay:
one: "Έχεις μία ελεύθερη μέρα."
other: "Έχεις %d ελεύθερες μέρες."main.go
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/middleware/i18n"
)
func main() {
app := iris.New()
locales := i18n.New()
if err := locales.Load("./locales/*", "en-US", "el-GR"); err != nil {
panic(err)
}
app.UseRouter(i18n.NewRouter(locales, i18n.RouterOptions{
Cookie: "lang",
}))
app.Use(i18n.NewHandler(locales, i18n.HandlerOptions{
URLParameter: "lang",
Cookie: "lang",
}))
app.Get("/", func(ctx iris.Context) {
locale := i18n.GetLocale(ctx)
ctx.Writef("[%s] %s", locale.Language(),
locale.GetMessage("hi", "iris"))
})
app.Get("/days/{count:int}", func(ctx iris.Context) {
count := ctx.Params().GetInt("count")
ctx.WriteString(i18n.GetMessage(ctx, "FreeDay", count))
})
app.Listen(":8080")
}curl http://localhost:8080/
# [en-US] hello, iris
curl -H "Accept-Language: el" http://localhost:8080/
# [el-GR] γεια, iris
curl "http://localhost:8080/days/5?lang=el-GR"
# Έχεις 5 ελεύθερες μέρες.
curl http://localhost:8080/el-GR/days/1
# Έχεις μία ελεύθερη μέρα.Test i18n behavior the same way as any other handler chain, with
httptest.New(t, app) and requests that vary the lang query parameter or
the Accept-Language header; Chapter 16 covers the tooling.
Summary
i18n.New()creates the catalog;Load,LoadFS,LoadKVandLoadAssetsfill it from glob patterns, embedded filesystems, maps or generated assets. INI, JSON, TOML and YAML files are supported, and the language is deduced from the file path.i18n.NewHandler(registered withUse) detects the language per request in a fixed order: server-set value,ExtractFunc, URL parameter, cookie, subdomain,Accept-Languageheader, then the default locale.- Handlers translate with
i18n.GetMessage(ctx, key, args...)or throughi18n.GetLocale(ctx). Messages use printf verbs, Go templates, plural forms (zero,one,two,other,=N,<N,>N) and${Var}variables. i18n.NewRouter(registered withUseRouter) rewrites language path prefixes and subdomains before routing and can persist the choice in a cookie.- View engines integrate via ordinary template functions such as a
trhelper backed byGetLocale/GetMessage.
Further Reading
- RFC 5646: Tags for Identifying Languages:
the BCP 47 language-tag grammar behind codes like
el-GRanden-US. - MDN: Accept-Language:
how browsers express ranked language preferences, including
qweights. - MDN: Content negotiation: the wider HTTP mechanism that language selection is one instance of.
- The Go text/template package: the full syntax available inside template-style messages.
- The Go embed package:
//go:embedandembed.FS, used to compile locale files into the binary.