Chapter 15

Internationalization (i18n)

The i18n middleware. INI, JSON, TOML and YAML catalogs matched with BCP 47 tags, pluralization, template integration, and path or subdomain rewriting.

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-US and en-GB spell, 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.I18n catalog. You load locale files into it once at startup with Load, LoadFS, LoadKV or LoadAssets.
  • i18n.NewHandler(locales, i18n.HandlerOptions{...}) returns a regular middleware handler. Register it with Use. On every request it detects the language and stores the matched locale on the context.
  • i18n.NewRouter(locales, i18n.RouterOptions{...}) is optional. Register it with UseRouter to strip language prefixes such as /el-GR/... (or language subdomains such as el.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.

Go
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:

Go
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:

Go
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:

Go
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:

Go
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:

  1. A language force-set by server code (i18n.SetLanguage, see below).
  2. ExtractFunc, if set.
  3. The URL query parameter (URLParameter).
  4. The cookie (Cookie). The handler only reads this cookie; writing it is your job or the router’s (see RouterOptions.Cookie).
  5. The subdomain, if Subdomain is true.
  6. The Accept-Language request header (always checked).
  7. Fallback: the default locale, i.e. the first registered language.
The i18n language-resolution ladder The i18n handler tries seven sources in order and the first match wins; only a language set on the context short-circuits, and it yields no locale at all when nothing registered matches it. Matched locale on the context i18n.GetLocale(ctx) i18n.GetMessage(ctx, key, args...) No locale on the context GetLocale(ctx) is nil, and GetMessage(ctx, ...) returns "" 1. i18n.SetLanguage(ctx, code) ctx.Values() key iris.i18n.language 2. HandlerOptions.ExtractFunc only when set 3. URL query, ?lang= only when URLParameter is not empty 4. Cookie read only: the handler never writes it 5. Subdomain only when Subdomain is true 6. Accept-Language header always checked 7. Default locale the first registered language set, nothing matches FIRST MATCH WINS i18n.NewRouter (UseRouter) strips /el-GR from the path, then sets Accept-Language to the matched tag, so it feeds rung 6, losing to ?lang= and the cookie Only rung 1 short-circuits; every other rung falls through to the next. Every rung matches leniently: for a registered el-GR, the inputs el-GR, el-gr and el all resolve to it.
The i18n handler tries seven sources in order and the first match wins; only a language set on the context short-circuits, and it yields no locale at all when nothing registered matches it.

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...).

Go
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:

YAML
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:

Go
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:

YAML
Vars:
  - Minutes:
      one: "minute"
      other: "minutes"

YouLate: "You are %[1]d ${Minutes} late."
Go
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:

YAML
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:

YAML
welcome: "Welcome back, {{.name}}!"
Go
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:

Go
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:

Go
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...)
            },
        })
    })
}
HTML
<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.go

locales/en-US.yml

YAML
hi: "hello, %s"
FreeDay:
  one: "You have one day off."
  other: "You have %d days off."

locales/el-GR.yml

YAML
hi: "γεια, %s"
FreeDay:
  one: "Έχεις μία ελεύθερη μέρα."
  other: "Έχεις %d ελεύθερες μέρες."

main.go

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")
}
Shell
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, LoadKV and LoadAssets fill 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 with Use) detects the language per request in a fixed order: server-set value, ExtractFunc, URL parameter, cookie, subdomain, Accept-Language header, then the default locale.
  • Handlers translate with i18n.GetMessage(ctx, key, args...) or through i18n.GetLocale(ctx). Messages use printf verbs, Go templates, plural forms (zero, one, two, other, =N, <N, >N) and ${Var} variables.
  • i18n.NewRouter (registered with UseRouter) 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 tr helper backed by GetLocale/GetMessage.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.