Software localization

A Step-by-Step Guide to Go Internationalization

Learn how to make your Go applications ready for localization—with this step-by-step tutorial on Go Internationalization (i18n).
Software localization blog category featured image | Phrase

Go has become a “go-to” programming language (no pun intended) for authoring cloud services, dev ops, web dev, and more. The systems language has seen adoption from industry giants like Dropbox, Microsoft, Netflix, and Riot Games. So it’s puzzling that Go i18n (internationalization) and l10n (localization) are two of the least developed features in the language’s Standard Library. Fortunately for us, some good third-party Go libraries fill that gap.

And what Go’s standard library lacks in i18n features, it makes up for with functionality for handling character encodings, text transformations, and locale-specific text processing. Third-party libraries can take care of the rest, giving us gettext support and additional Unicode processing, so we have all our Go i18n and l10 needs covered.

This hands-on guide explores making Go applications locale-aware using gettext tools and the gotext package. We’ll start with the basics of i18n, work with translations, and delve into date and number formatting.

🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and in different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

Before we dive into the code, let’s briefly discuss the current state of Go i18n.

The state of Go i18n and l10n

In the latest version at the time of writing, v1.22, Go includes some built-in capabilities for i18n but lags behind other languages. We often need third-party libraries to complete our i18n solutions. One popular package for i18n in Go is Nick Snyder’s go-i18n, which provides tools for managing localized strings in Go applications.

🔗 Resource » Our guide, A Simple Way to Internationalize in Go with go-i18n, covers working with Synder’s library in depth.

In this tutorial, we’ll instead focus on the gotext i18n package by Leonel Quinteros, which provides gettext utilities for Go. gettext is a mature, widely used i18n package from FOSS (free and open source) legends, GNU. gotext provides the core functionality of gettext to Go developers.

🔗 Resource » Learn more about GNU gettext and its tools

With gotext Go devs have a comprehensive set of tools and conventions for handling multilingual text. gotext includes utilities for extracting translatable strings from source code, generating message catalogs for different languages, and runtime libraries for loading translated strings into applications.

Our demo app

The demo we’ll work with in this guide is a simple web app: a video game speedrunning leaderboard that supports multiple locales. We’re keeping the demo very light to focus on i18n here.

Here’s what Speedrun Leaderboard looks like:

Leaderboard index page
Leaderboard index page
Leaderboard add new Speedrun page
Leaderboard add new Speedrun page

Packages used

Below is a list of the packages we’ll use as we develop our app, along with their versions and descriptions:

Package Version Comment
go 1.22
https://github.com/leonelquinteros/gotext 1.5.2 Our main i18n package.
http://golang.org/x/text 0.14.0 Supplementary Unicode text processing libraries.

OK, coding time. Start by initializing a new Go project from the command line:

$ go mod init PhraseApp-Blog/go-internationalization
$ go mod tidy
Code language: Bash (bash)

Create the code for the backend server in main.go. The main function defines the Speedrun model, initiates the HTTP server handlers, and starts the application. It also populates the initial leaderboard with some sample data.

// main.go
package main

import (
  "html/template"
  "net/http"
  "time"
)

type Speedrun struct {
  PlayerName   string    `json:"player_name"`
  Game         string    `json:"game"`
  Category     string    `json:"category"`
  Time         string    `json:"time"`
  SubmittedAt  time.Time `json:"submitted_at"`
}

var speedruns []Speedrun

func main() {
  // Initialize sample data
  speedruns = []Speedrun{
    {PlayerName: "Alex", Game: "Super Mario 64", Category: "Any%",
     Time: "16:58", SubmittedAt: time.Now()},
    {PlayerName: "Theo", Game: "The Legend of Zelda: Ocarina of Time",
     Category: "Any%", Time: "1:20:41", SubmittedAt: time.Now()},
  }

  // Define routes
  http.HandleFunc("/", handleIndex)
  http.HandleFunc("/speedruns", handleSpeedruns)
  http.HandleFunc("/speedruns/add", handleSpeedrunForm)
  http.HandleFunc("/speedrun.html", handleSpeedrunForm)

  // Serve static files
  http.Handle("/static/", http.StripPrefix("/static/",
              http.FileServer(http.Dir("./static"))))

  // Start server
  fmt.Println("Server listening on port 8080...")
  http.ListenAndServe(":8080", nil)
}
Code language: Go (go)

The rest of the code provides the handlers that serve each endpoint. The root (/) and /speedruns/add endpoints are served as HTML templates. /speedruns responds with a JSON list.

// main.go
func handleIndex(w http.ResponseWriter, r *http.Request) {
  // Execute the index.html template
  tmpl, err := template.ParseFiles("static/index.html")
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }

  // Render the template with the speedruns data
  err = tmpl.Execute(w, speedruns)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
}

func handleSpeedruns(w http.ResponseWriter, r *http.Request) {
  // Send speedrun data as JSON response
  w.Header().Set("Content-Type", "application/json")
  json.NewEncoder(w).Encode(speedruns)
}

func handleSpeedrunForm(w http.ResponseWriter, r *http.Request) {
  if r.Method == http.MethodPost {
    // Parse request body to get new speedrun data
    var speedrun Speedrun
    err := json.NewDecoder(r.Body).Decode(&speedrun)
    if err != nil {
      http.Error(w, err.Error(), http.StatusBadRequest)
      return
    }

    // Add the current date as the submitted date
    speedrun.SubmittedAt = time.Now()

    // Add new speedrun to the global speedruns slice
    speedruns = append(speedruns, speedrun)

    // Send success response
    fmt.Fprintln(w, "Speedrun submitted successfully!")
  } else {
    http.ServeFile(w, r, "static/speedrun.html")
  }
}
Code language: Go (go)

Next, the code for the front-end UI is also a simple view that renders the current leaderboard. It populates the list of entries from the data that we passed on the template:

<!-- static/index.html -->

<!DOCTYPE html>
<html lang="en">
  <body>
    <div class="container">
      <h1>Speedrun Leaderboard</h1>
      <table>
        <thead>
          <tr>
            <th>Player Name</th>
            <th>Game</th>
            <th>Category</th>
            <th>Time</th>
            <th>Date</th>
          </tr>
        </thead>
        <tbody>
          {{range .}}
          <tr>
            <td>{{.PlayerName}}</td>
            <td>{{.Game}}</td>
            <td>{{.Category}}</td>
            <td>{{.Time}}</td>
            <td>{{.SubmittedAt.Format "2006-01-02"}}</td>
          </tr>
          {{end}}
        </tbody>
      </table>
    </div>
  </body>
</html>
Code language: HTML, XML (xml)

Save the above file as index.html in the static directory.

🗒️ Note » For brevity, we omit all style code in this tutorial, except styles that relate to localization. You can get all the code for this demo app from GitHub, including styles.

Next, let’s create the speedrun.html file for adding a new speedrun entry. This would render a form that users could fill out to add new Speedrun entries.

<!-- static/speedrun.html -->

<!DOCTYPE html>
<html lang="en">
  <body>
    <div class="container">
      <h2>Add New Speedrun</h2>
      <form id="speedrunForm">
        <label for="playerName">Player Name:</label>
        <input type="text" id="playerName" name="playerName" required /><br />

        <label for="game">Game:</label>
        <input type="text" id="game" name="game" required /><br />

        <label for="category">Category:</label>
        <input type="text" id="category" name="category" required /><br />

        <label for="time">Time:</label>
        <input type="text" id="time" name="time" required /><br />

        <button type="submit">Submit</button>
      </form>
    </div>
    <script>
      // ... code to handle form
    </script>
  </body>
</html>
Code language: HTML, XML (xml)

We should save this file as speedrun.html in the static directory.

With these changes, hitting the / route will render the leaderboard list (index.html), and accessing /speedruns/add will render the form to add a new speedrun entry (speedrun.html).

🔗 Resource » Get all the starter code from GitHub

How do I localize my app with gotext i18n?

Let’s take a high-level overview of how to localize your Go application using the gotext package:

1. Install gotext and gettext.
2. Set up locale directories and translation files for each supported locale.
3. Configure the gotext package and add supported locales to your application.
4. Translate strings: Translators provide translations for each string, stored in PO files.
5. Load translations for a specific language using the gotext.Get function.
6. Localize the dates, numbers, and plural forms.

Let’s cover these in detail.

How do I install gotext?

To install the gotext package for your Go application, you can use the go get command followed by the package’s repository URL:

$ go get github.com/leonelquinteros/gotext
Code language: Bash (bash)

🗒️ Heads up » Since gotext depends on gettext, please ensure that gettext is installed on your system using this guide before continuing. Note that on Linux and macOS environments, gettext should already be installed.

Setting up locale directories

gettext assumes a specific directory structure for managing translations. Organizing translations into per-locale files under a dedicated directory structure makes it easier to manage translations, collaborate with translators, and maintain a structured codebase.

Let’s go through the steps for setting up these locale directories:

First, let’s create a script to automate the creation of locale directories and default translation files. Save the following script as scripts/initialize_locales.sh:

# scripts/initialize_locales.sh

#!/bin/bash

initialize_locale_directories() {
    # Iterate over each provided language code
    for lang_code in "$@"; do
        # Define locale directory path
        locale_dir="locales/$lang_code/LC_MESSAGES"

        # Create the locale directory if it doesn't exist
        mkdir -p "$locale_dir"

        # Initialize the default.po file using msginit
        msginit --no-translator -o "$locale_dir/default.po" \
            -l "$lang_code" -i "locales/default.po"

        # Print status message
        echo "Initialized locale directory"
        echo "for $lang_code"
    done
}

# Check if any language codes are provided as arguments
if [ $# -eq 0 ]; then
    echo "Error: No language codes provided."
    echo "Please provide one or more language codes as arguments."
    exit 1
fi

initialize_locale_directories "$@"
Code language: Bash (bash)

Now run the script to create locale directories and default translation files. For example, to create directories for English-America (en_US), Greek-Greece (el_GR), and Arabic-Saudi-Arabia (ar_SA), we would run:

$ ./scripts/initialize_locales.sh en_US el_GR ar_SA
Code language: Bash (bash)

This will create the following tree structure:

root
└── locales
    ├── en_US
    │   └── LC_MESSAGES
    │       └── default.po
    ├── el_GR
    │   └── LC_MESSAGES
    │       └── default.po
    └── ar_SA
        └── LC_MESSAGES
            └── default.po
Code language: plaintext (plaintext)

Once the translation files are initialized, translators can populate them with the translated messages for each locale. Each PO file contains the translations for a specific locale, making it easier to manage and collaborate on translations.

🗒️ Note » You can create the above directory structure and files manually. The Bash script above scales well as you add locales, but you don’t have to use it to follow along here.

A note on locales

A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, US for the United States) is recommended for accurate date and number localization. So a complete locale might look like en_US for American English or zh_CN for Chinese as used in China.

🔗 Resource » Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

How do I configure the gotext package?

Next, we need to configure the gotext package. Since we have laid out our locale directories, we need a way to load the available locales and the default locale based on standard Unix/Linux environmental variables.

🗒️ Note » If you are running this example on non-UNIX/Linux systems, you need to make sure that one of the following environmental variables is set to the initial language type so that the app can pick up the correct locale: LANGUAGE, LC_ALL, LCMESSAGES or LANG.

Configure available locales on start-up

The following code handles supported locales in your application. We can define the supported language codes and map them to their respective locales using the gotext package. Here’s an example of how to do it:

// pkg/i18n/lang.go

package i18n

import (
  "github.com/leonelquinteros/gotext"
)

type LanguageCode string

// Supported locales
const (
  GR LanguageCode = "el_GR" // Greek
  EN LanguageCode = "en_US" // English
  AR LanguageCode = "ar_SA" // Arabic
)

// langMap stores Locale instances for each language code.
var langMap = make(map[LanguageCode]*gotext.Locale)

// String returns the string representation of a LanguageCode.
func (l LanguageCode) String() string {
  return string(l)
}

// T returns the translated string for the given key
// in the specified language.
func (l LanguageCode) T(s string) string {
  if lang, ok := langMap[l]; ok {
    return lang.Get(s)
  }

  // Return the original key if no translation is available
  return s
}
Code language: Go (go)

The langMap specifies a mapping of all supported locales that the application supports. It is used to match a string input type to a gotext.Locale type. We use this mapping in the next step of the code: configuring gotext based on the user’s preferred language and setting up the locales accordingly.

// pkg/i18n/i18n.go

package i18n

import (
  "fmt"
  "os"
  "path"
  "strings"

  "github.com/leonelquinteros/gotext"
)

var (
  defaultDomain = "default"
)

func Init() error {
  localePath, err := getLocalePath()
  if err != nil {
    return err
  }
  languageCode := getLanguageCode()
  fullLocale := NewLanguageFromString(languageCode).String()

  gotext.Configure(localePath, fullLocale, defaultDomain)
  setupLocales(localePath)

  fmt.Println("languageCode:", fullLocale)

  return nil
}

// Returns the language code from environment
// variables LANGUAGE, LC_ALL, or LC_MESSAGES,
// in that order of priority.
// It returns an empty string if none of the
// variables are set.
func getLanguageCode() string {
  // Check LANGUAGE environment variable
  if lc := os.Getenv("LANGUAGE"); lc != "" {
    return lc
  }
  // Check LC_ALL environment variable
  if lc := os.Getenv("LC_ALL"); lc != "" {
    return lc
  }
  // Check LC_MESSAGES environment variable
  if lc := os.Getenv("LC_MESSAGES"); lc != "" {
    return lc
  }
  // No language code found in environment variables
  return os.Getenv("LANG")
}

func setupLocales(localePath string) {
  // Get a list of all directories in the locale path
  localeDirs, err := os.ReadDir(localePath)
  if err != nil {
    return err
  }

  // Iterate over each directory and add it
  // as a supported language
  for _, dir := range localeDirs {
    if dir.IsDir() {
      langCode := LanguageCode(dir.Name())
      lang := gotext.NewLocale(localePath, langCode.String())
      lang.AddDomain(defaultDomain)
      langMap[langCode] = lang
    }
  }

  return nil
}

func getSupportedLanguages() []LanguageCode {
  var languages []LanguageCode

  for lang := range langMap {
    languages = append(languages, lang)
  }

  return languages
}

func NewLanguageFromString(code string) LanguageCode {
  code = strings.ToLower(code)

  if strings.Contains(code, "en") {
    return EN
  } else if strings.Contains(code, "el") {
    return GR
  }
  return AR
}

func T(s string) string {
  return gotext.Get(s)
}

func GetCurrentLanguage() LanguageCode {
  return NewLang(getLanguageCode())
}
Code language: Go (go)

🗒️ Note » The getLocalePath and getPwdDirPath functions are defined in the pkg/i18n/helpers.go inside the GitHub project repo.

The above code defines some useful helpers to detect the initial locale from the environment, register the list of available locales, and get the current locale.

We can now call the Init function in your main.go file to initialize the localization setup. Here’s how we can do it:

// main.go
package main

import (
+   "PhraseApp-Blog/go-internationalization/pkg/i18n"
+   "log"
   // ...
)

func main() {
+   // Initialize i18n package
+   if err := i18n.Init(); err != nil {
+     log.Fatalf("failed to initialize i18n: %v", err)
+   }
   // ...
}

Code language: Diff (diff)

To start the project with different environmental variables controlling the locale, you can set the desired language using the LANGUAGE, LC_ALL, LCMESSAGES, or LANG environment variables before running the application. Here’s an example:

# Set the desired language environment variable
$ export LANGUAGE=en_US
$ go run main.go
# => languageCode: en_US.UTF-8

$ export LANGUAGE=el
$ go run main.go
# => languageCode: el_GR
Code language: Bash (bash)

The advantage of this approach is that your application can preload all locale resources upon startup and seamlessly switch to the default locale. This method can be particularly useful when localizing CLI applications where you want to switch to a suitable locale on startup automatically.

Now that we’ve configured the gotext package, we can translate our app. Let’s switch our focus to extracting and managing the strings to be translated, and integrating these translations into our application.

How do I work with translation messages?

Let’s illustrate how to use translation messages in our existing code. Suppose we have a button label in our application that says “Submit” and we want that string to be translated into different languages. Instead of hardcoding the label in our HTML or Go code, we can use our new i18n.T() function to fetch the translated label dynamically.

Here’s an example of using the i18n.T() function to translate the “Submit” button label in our HTML template. Remember that the i18n.T() function internally calls the gotext.Get function that translates text from the default domain:

<button>{{ i18n.T("Submit") }}</button>
Code language: Go (go)

In this example, i18n.T("Submit") retrieves the translated version of the “Submit” button label based on the current locale settings.

We can also translate strings using the i18n.T() function in our Go code. For instance, if we have an error message that says “An error occurred”, we can use the i18n.T() function to translate it like so:

errorMessage := i18n.T("An error occurred")
Code language: Go (go)

Let’s replace all our hardcoded strings with i18n.T() calls.

// main.go

-    fmt.Println("Server listening on port 8080...")
+    fmt.Println(i18n.T("Server listening on port 8080..."))

...
// Send success response
-    fmt.Fprintln(w, "Speedrun submitted successfully!")
+    fmt.Fprintln(w, i18n.T("Speedrun submitted successfully!"))
Code language: Diff (diff)

Now go ahead and provide a translation for those strings in Greek:

# locales/el_GR/LC_MESSAGES/default.po

msgid "Server listening on port 8080..."
msgstr "Ακρόαση Διακομιστή στη θύρα 8080..."

msgid "Speedrun submitted successfully!"
msgstr "Το Speedrun υποβλήθηκε με επιτυχία!"
Code language: plaintext (plaintext)

If you restart the server with the Greek locale configured, you will see the translated message printed in the console:

$ export LANGUAGE=el
$ go run main.go
# => languageCode: el_GR
# => Ακρόαση Διακομιστή στη θύρα 8080...
Code language: Bash (bash)

Now let’s see how to interpolate runtime variables into translation messages.

How do I inject runtime values into translation messages?

We can use placeholders in our translated strings to interpolate dynamic values into translation messages. gotext supports this functionality through the gotext.Get function.

We include placeholders in our translation messages using formats such as %s for strings, %d for integers, %f for floats, etc. Then, when calling gotext.Get, we can provide the necessary values as arguments to replace these placeholders.

We need to update our T function to accept dynamic values. We can utilize Go’s variadic parameters to do this. Here’s the modified T function:

// pkg/i18n/i18n.go

- func T(s string) string {
-    return gotext.Get(s)
- }
+ func T(s string, args ...interface{}) string {
+   return gotext.Get(s, args...)
+ }
Code language: Diff (diff)

After updating the message catalog, you might need to update the translated string as well:

# locales/el_GR/LC_MESSAGES/default.po
msgid "Server listening on port %d..."
msgstr "Ακρόαση Διακομιστή στη θύρα %d..."
Code language: plaintext (plaintext)

Let’s replace all occurrences of strings that will benefit from this feature:

-    fmt.Println(i18n.T("Server listening on port 8080..."))
+    fmt.Println(i18n.T("Server listening on port %d...", 8080))
Code language: Diff (diff)

OK, we’ve been focusing on translating the backend message strings. In the upcoming section, we’ll delve into localizing our HTML templates, ensuring that the front-end part of our application is also being rendered with the right translated content.

How do I localize Go templates?

Localizing Go templates involves translating the static text within the templates and adjusting the layout or structure based on language preferences.

Include the translation function in router handlers

To make T() available in our templates, we need to inject this function into our route handlers. Let’s create a handlers file to do this.

// pkg/handlers/handlers.go
package handlers

import (
  "PhraseApp-Blog/go-internationalization/pkg/i18n"
  "PhraseApp-Blog/go-internationalization/pkg/model"
  "html/template"
  "net/http"
)

func HandleTemplate(w http.ResponseWriter, r *http.Request,
                    tmplName string,
                    data interface{}) {
  // Parse the template
  tmpl, err := template.New(tmplName).Funcs(template.FuncMap{
    "T": i18n.T,
  }).ParseFiles("static/" + tmplName)

  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }

  // Execute the template with the translation function and data
  err = tmpl.Execute(w, data)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
}

func HandleIndex(w http.ResponseWriter,
                 r *http.Request, data []model.Speedrun) {
  HandleTemplate(w, r, "index.html", map[string]interface{}{
    "Title":      "Speedrun Leaderboard",
    "Header":     "Speedrun Leaderboard",
    "PlayerName": "Player Name",
    "Game":       "Game",
    "Category":   "Category",
    "Time":       "Time",
    "Date":       "Submitted At",
    "Data":       data,
  })
}

func HandleSpeedrun(w http.ResponseWriter, r *http.Request) {
  HandleTemplate(w, r, "speedrun.html", map[string]interface{}{
    "Header":     "Add New Speedrun",
    "Title":      "Add New Speedrun",
    "PlayerName": "Player Name",
    "Game":       "Game",
    "Category":   "Category",
    "Submit":     "Submit",
    "Time":       "Time",
  })
}
Code language: Go (go)

And update the main.go to use new handler functions:

  // main.go
  package main

  func handleIndex(w http.ResponseWriter, r *http.Request) {
-   http.ServeFile(w, r, "static/index.html")
+   handlers.HandleIndex(w, r, speedruns)
  }

  func handleSpeedrunForm(w http.ResponseWriter, r *http.Request) {
-   http.ServeFile(w, r, "static/speedrun.html")
+   handlers.HandleSpeedrun(w, r)
  }
Code language: Diff (diff)

Updating templates

First, let’s update the index.html template, localizing our hardcoded strings using the new T() function.

 <!-- static/index.html -->
 <!-- ... -->
 <head>
-   <title>Speedrun Leaderboard</title>
+   <title>{{T .Title}}</title>
 </head>

 <body>
   <div class="container">
     <h1>{{T .Header}}</h1>
     <table>
        <thead>
            <tr>
-               <th>Player Name</th>
-               <th>Game</th>
-               <th>Category</th>
-               <th>Time</th>
-               <th>Date</th>
+               <th>{{T "PlayerName"}}</th>
+               <th>{{T "Game"}}</th>
+               <th>{{T "Category"}}</th>
+               <th>{{T "Time"}}</th>
+               <th>{{T "Date"}}</th>
            </tr>
        </thead>
        <tbody>
            <!-- ... -->
        </tbody>
     </table>
   </div>
 </body>
 </html>
Code language: Diff (diff)

🗒️ Note » We do the same for the Add Speedrun form. The updated code is located in static/speedrun.html inside the GitHub repo.

Now go back and provide translations for all the remaining template strings. Here is the list that I provided for the Greek translations:

# locales/el_GR/LC_MESSAGES/default.po

msgid "Category"
msgstr "Κατηγορία"

msgid "Time"
msgstr "Χρόνος"

msgid "Submit"
msgstr "Υποβολλή"

msgid "Game"
msgstr "Παιχνίδι"

msgid "Submitted At"
msgstr "Υποβλήθηκε:"

msgid "Add New Speedrun"
msgstr "Προσθήκη νέου Speedrun"
Code language: plaintext (plaintext)

Restart the server while having the Greek Locale enabled:

$ export LANGUAGE=el
$ go run main.go
Code language: Bash (bash)

You should be able to see the translated page

Index page translated in Greek
Index page translated in Greek
The Add New Speedrun page translated into Greek
The Add New Speedrun page translated into Greek

Now let’s look at how to render RTL languages like Arabic or Hebrew.

How do I handle text direction (LTR/RTL)?

When developing multilingual web applications, we often neglect locales written from right to left (RTL), such as Arabic, Hebrew, and Persian. Fortunately, HTML provides the dir attribute to control text directionality.

While golang.org/x/text package provides utilities for text processing involving Unicode, unfortunately, it does not offer anything related to RTL detection. The same problem exists in gotext as well.

This means that we have to define those helper functions manually in our code.

Since this package is not installed by default, we need to import it:

$ go get golang.org/x/text
Code language: Bash (bash)

First, define the LanguageDirectionMap in your i18n package:

// pkg/i18n/i18n.go
package i18n

import (
  "encoding/json"
  "fmt"
  "net/http"
  "os"
  "strings"
  "time"

  "github.com/leonelquinteros/gotext"
+  "golang.org/x/text/language"
)

+ // LanguageDirectionMap maps language codes to their
+ // typical text directions
+ var LanguageDirectionMap = map[LanguageCode]string{
+   "el_GR": "ltr", // Greek
+   "en_US": "ltr", // English
+   "ar_SA": "rtl", // Arabic
+ }

// ...
Code language: Diff (diff)

Update your handleIndex and handleSpeedrun function to accept Dir as a template variable:

// pkg/handlers/handlers.go

// ...

func HandleIndex(w http.ResponseWriter,
                 r *http.Request, data []model.Speedrun) {
  HandleTemplate(w, r, "index.html", map[string]interface{}{
    ...
+    "Dir": i18n.LanguageDirectionMap[i18n.GetCurrentLanguage()],
  })
}

func HandleSpeedrun(w http.ResponseWriter, r *http.Request) {
  HandleTemplate(w, r, "speedrun.html", map[string]interface{}{
    ...
+    "Dir": i18n.LanguageDirectionMap[i18n.GetCurrentLanguage()],
  })
}
Code language: Diff (diff)

In your HTML template files, add the dir attribute to the tag and set it to the .Dir template variable you just created. Here’s index.html as an example:

 <!-- static/index.html -->

  <!DOCTYPE html>
- <html>
+ <html dir="{{.Dir}}">

 <!-- ... -->
Code language: Diff (diff)

🗒️ Note » Remember to do this in speedrun.html as well.

Now go back and provide translations for all the Arabic strings:

# locales/ar_SA/LC_MESSAGES/default.po

msgid "Speedrun Leaderboard"
msgstr "لوحة نتائج السرعة"

msgid "Player Name"
msgstr "اسم اللاعب"

msgid "Category"
msgstr "الفئة"

msgid "Time"
msgstr "الزمن"

msgid "Submit"
msgstr "تقديم"

msgid "Game"
msgstr "اللعبة"

msgid "Submitted At"
msgstr "تم التقديم في:"

msgid "Add New Speedrun"
msgstr "إضافة سباق سريع جديد"
Code language: plaintext (plaintext)

Restart the server, and you will be able to see the Arabic presented in the right text direction.

$ export LANGUAGE=ar
$ go run main.go
Code language: Bash (bash)

The following screenshots showcase the page before and after adding the RTL direction.

Index page translated in Arabic without RTL direction applied
Index page translated in Arabic without RTL direction applied
Index page translated in Arabic with RTL support
Index page translated in Arabic with RTL support

🔗 Resource » Setting the HTML dir attribute is a great first step in handling language direction. There’s a bit more to RTL handling, and you can read all about it in our CSS Localization guide.

How do I localize dates and times?

Handling dates and times is a crucial aspect of localization, since different regions format dates differently. To handle datetime localization in our app, we’ll use the time and golang.org/x/text/language packages to create new formatting functions.

Note that libraries like gotext do not offer built-in support for localized date formatting, so we have to provide the formats manually. Create a date_formats.json file in the project root folder to hold these formats:

// date_formats.json
{
  "en_US": "01/02/2006 03:04:05 PM",
  "el_GR": "02/01/2006 15:04:05",
  "ar_SA": "02/01/2006 15:04:05"
}
Code language: JSON / JSON with Comments (json)

We’ll be able to add and update date formats for different locales by adding them to date_formats.json without modifying any code. Let’s write those formatting functions next.

Create a function in the i18n package that formats a date according to the user’s language preference:

// pkg/i18n/i18n.go

// ...
// Add this code after the SetCurrentLocale function declaration.

// FormatLocalizedDate formats the given time.Time object
// according to the specified language locale.
func FormatLocalizedDate(t time.Time, lang string) string {
    dateFormats, err := readDateFormatsFromFile("date_formats.json")
    if err != nil {
        // Fallback to default format if unable to read formats
        return t.Format("02/01/2006 15:04:05")
    }

    format, ok := dateFormats[lang]
    if !ok {
        // If the language is not recognized, use a default format
        return t.Format("02/01/2006 15:04:05")
    }

    location, err := time.LoadLocation(lang)
    if err != nil {
        // Log or handle error
        // Fallback to default location if an error occurs
        location = time.UTC
    }

    // Format the time using the specified format and location
    return t.In(location).Format(format)
}

func readDateFormatsFromFile(filename string)
     (map[string]string, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }

    var dateFormats map[string]string
    if err := json.Unmarshal(data, &dateFormats); err != nil {
        return nil, err
    }

    return dateFormats, nil
}
Code language: Go (go)

This function, FormatLocalizedDate accepts a time unit and a language tag and provides a suitable formatted representation based on the formats we defined earlier. It uses a helper function to read the contents of date_formats.json to apply the correct formats.

Provide a template function that will call the above FormatLocalizedDate so we can use it in the HTML templates. The language.Make function is part of the golang.org/x/text/language package which provides support for language tags and related functionality.

// pkg/handlers/handlers.go

func HandleTemplate(w http.ResponseWriter, r *http.Request,
                    tmplName string, data interface{}) {
  // Parse the template
  tmpl, err := template.New(tmplName).Funcs(template.FuncMap{
    "T": i18n.T,
+    "FormatLocalizedDate": func(submittedAt time.Time,
+                               currentLanguage i18n.LanguageCode) string {
+      return i18n.FormatLocalizedDate(submittedAt, currentLanguage.String())
+    },
  }).ParseFiles("static/" + tmplName)

// ...
Code language: Diff (diff)

Replace the SubmittedAt field in the index template with the new FormatLocalizedDate field:

 <!-- index.html -->

 <!-- ... -->

{{range .Data}}
    <tr>
      <td>{{.PlayerName}}</td>
      <td>{{.Game}}</td>
      <td>{{.Category}}</td>
      <td>{{.Time}}</td>
-     <td>{{.SubmittedAt.Format "2006-01-02"}}</td>
+     <td>{{FormatLocalizedDate .SubmittedAt $.CurrentLanguage}}</td>
    </tr>
{{end}}

 <!-- ... -->
Code language: Diff (diff)

Now try to test this out with the different locales and you will see the dates printed in the correct locale format:

SubmittedAt formatted in Greek
SubmittedAt formatted in Greek
SubmittedAt formatted in US English
SubmittedAt formatted in US English

🔗 Resource » We cover datetime localization in detail in our Guide to Date and Time Localization.

How do I localize numbers?

Similar to dates, we can provide a function that formats a given number with the appropriate grouping separators according to the user’s preferred language. The number 1000000 for example, can be formatted as 1,000,000 or as 10,00,000 depending on the country of reference (note the comma locations).

The following function formats a number using the provided language tag. It uses the message.NewPrinter(lang) function from the golang.org/x/text package.

// pkg/i18n/i18n.go

package i18n

import (
  "encoding/json"
  "fmt"
  "net/http"
  "os"
  "strings"
  "time"

  "github.com/leonelquinteros/gotext"
+  "golang.org/x/text/language"
  "golang.org/x/text/message"
)

+ // FormatNumber formats the given number according
+ // to the specified language locale.
+ func FormatNumber(number int64, lang language.Tag) string {
+   printer := message.NewPrinter(lang)
+   // Format the number with grouping separators
+   // according to the user's preferred language
+   return printer.Sprintf("%d", number)
+ }
Code language: Diff (diff)

This function takes two parameters: the language tag string from the language package, representing the user’s preferred language, and the number to be formatted. It creates an instance of a message.Printer that can format the number according to the specified language, including grouping separators as per the user’s locale. The printer.Sprintf method will format the number according to the specified locale and return it as a string that we can print.

We can include this helper in the list of template functions as well:

// pkg/handlers/handlers.go

import (
...
+  "golang.org/x/text/language"
)

func HandleTemplate(w http.ResponseWriter, r *http.Request,
                    tmplName string, data interface{}) {
  // Parse the template
  tmpl, err := template.New(tmplName).Funcs(template.FuncMap{
    "T": i18n.T,
    "FormatLocalizedDate": func(submittedAt time.Time,
                                currentLanguage i18n.LanguageCode) string {
      return i18n.FormatLocalizedDate(submittedAt, currentLanguage.String())
    },
+    "FormatNumber": func(number int64,
+                        currentLanguage i18n.LanguageCode) string {
+      return i18n.FormatNumber(number, language.Make(currentLanguage.String()))
    },
  }).ParseFiles("static/" + tmplName)
Code language: Diff (diff)

Here’s how you can use this helper function in your templates:

<!-- Example usage of formatNumber helper function -->
<p>{{T "Localized number"}}: {{FormatNumber 12342341123 $.CurrentLanguage}}</p>
Code language: HTML, XML (xml)

We can see two different examples below (notice how Greek and US English use different grouping separators):

Number formatted in Greek
Number formatted in Greek
Number formatted in US English
Number formatted in US English

🔗 Resource »  Our Concise Guide to Number Localization covers grouping separators in detail, and touches on other important aspects of number localization.

How do I work with plurals?

If you come from an English background, or similar, you might assume that plural forms equate to “1 tree” vs “5 trees”. However, different languages have very different plural rules — some languages, like Arabic, can have six plural forms!

🔗 Resource » The canonical source for language’s plural forms is the CLDR Language Plural Rules listing.

In our app, we can use gettext’s ability to define multiple plural forms for the same translation to manage our localized plurals. We use the Plural-Forms directive to define the rules for selecting the appropriate plural based on a count variable.

Let’s see all this in action by implementing plurals in our demo app.

Add the Plural-Forms directive to PO files

Greek and English have two plural forms, one and other. We often want to cater to the zero (0) form as well so we cover that in our Plural-Forms logic.

# locales/en_US/LC_MESSAGES/default.po

+ "Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;

# ...
Code language: Diff (diff)
# locales/el_GR/LC_MESSAGES/default.po

+ "Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;

# ...
Code language: Diff (diff)

Arabic has six plural forms that depend on somewhat intricate language rules:

# locales/ar_SA/LC_MESSAGES/default.po

+ "Plural-Forms: nplurals=6; \
+     plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 \
+     : n%100>=11 ? 4 : 5;\n"

# ...
Code language: Diff (diff)

🔗 Resource » Learn more about the Plural-Forms syntax and more on the official gettext plural forms page.

Define plural translations in PO files

In your PO files, define plural translations for messages that have plural forms. Each plural message should include a singular form and one or more plural forms. For example:

# locales/en_US/LC_MESSAGES/default.po

"Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;

# ...

+ msgid "EntryAdded"
+ msgid_plural "EntriesAdded"
+ msgstr[0] "%d new Entry Added today"
+ msgstr[1] "%d new Entries today"
+ msgstr[2] "No new Entries today"
Code language: Diff (diff)
# locales/el_GR/LC_MESSAGES/default.po

"Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;

# ...

+ msgid "EntryAdded"
+ msgid_plural "EntriesAdded"
+ msgstr[0] "%d νέα καταχώριση προστέθηκε σήμερα"
+ msgstr[1] "%d νέες καταχωρήσεις σήμερα"
+ msgstr[2] "Καμία καταχωρήση σήμερα"
Code language: Diff (diff)
# locales/ar_SA/LC_MESSAGES/default.po

"Plural-Forms: nplurals=6; \
     plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 \
     : n%100>=11 ? 4 : 5;\n"

# ...

+ msgid "EntryAdded"
+ msgid_plural "EntriesAdded"
+ msgstr[0] "لم تتم إضافة أي إدخالات جديدة اليوم"
+ msgstr[1] "تمت إضافة إدخال %d جديد اليوم"
+ msgstr[2] "تمت إضافة مدخلين %d جديدين اليوم"
+ msgstr[3] "تمت إضافة %d إدخالاً جديدًا اليوم"
+ msgstr[4] "تمت إضافة مدخلين %d جديدين اليوم"
+ msgstr[5] "تمت إضافة %d إدخالات جديدة اليوم"
Code language: Diff (diff)

Create a new helper function for plural translations

Define a new helper function in your i18n package to simplify the retrieval of plural translations. This function should call gotext.GetN with the appropriate parameters. For example:

// pkg/i18n/i18n.go

// ...

+ func TN(singular, plural string, count int, args ...interface{}) string {
+    return gotext.GetN(singular, plural, count, count)
+ }

Code language: Diff (diff)

This TN function takes the singular and plural forms of the message in the default locale, the count of items, and any additional arguments for interpolation.

If the message has a plural form defined (based on the Plural-Forms header), the GetN evaluates the plural expression using the count value. Based on the index returned by the expression, GetN selects the corresponding msgstr[n] entry from the translation file.

For example, if count = 0 and locale is el_GR, the Plural-Forms formula we defined will evaluate to the number 2 so that the msgstr[2] entry will be used to format the string.

If count = 1 then the formula will evaluate to 0 so the msgstr[0] will be used instead.

Use the helper function in templates

Whenever you have a plural in your template, replace the call to T with a call to TN. Pass the singular and plural forms of the message in the default locale (English in our case). Also pass the integer count, which will be used to resolve the plural form of the message in the active locale at runtime.

<p>{{TN "EntryAdded" "EntriesAdded" 5}}</p>
Code language: HTML, XML (xml)

Now re-run the app testing with the different locales. We can see two different examples below:

Plural messages formatted in Greek
Plural messages formatted in Greek
Plural messages formatted in US English
Plural messages formatted in US English
Plural messages formatted in Arabic
Plural messages formatted in Arabic

How do I detect the user’s locale?

To detect the user’s locale in your Go web application, you can create a helper function in the i18n package called DetectPreferredLocale. This will parse the request query for a language parameter or an Accept-Language header and will switch to the appropriate supported locale. In case no locale was provided it will default to en_US.

// pkg/i18n/i18n.go

// Add this code after the GetCurrentLanguage
// function declaration

func DetectPreferredLocale(r *http.Request) string {
  // Check if lang parameter is provided in the URL
  langParam := LanguageCode(r.URL.Query().Get("lang"))
  if langParam != "" {
    // Check if the provided lang parameter is supported
    for _, supportedLang := range GetSupportedLanguages() {
      if langParam == supportedLang {
        return langParam.String()
      }
    }
  }

  // Get Accept-Language header value
  acceptLanguage := r.Header.Get("Accept-Language")

  // Parse Accept-Language header
  prefs, _, err := language.ParseAcceptLanguage(acceptLanguage)
  if err != nil {
    // Default to English if parsing fails
    return "en_US"
  }

  // Convert supported language codes to language.Tags
  var supportedTags []language.Tag
  for _, code := range GetSupportedLanguages() {
    tag := language.Make(code.String())
    supportedTags = append(supportedTags, tag)
  }

  // Find the best match between supported languages
  // and client preferences
  match := language.NewMatcher(supportedTags)
  _, index, _ := match.Match(prefs...)

  // Get the best match language
  locale := GetSupportedLanguages()[index]

  return locale.String()
}

// SetCurrentLocale sets the current locale based
// on the language code
func SetCurrentLocale(lang string) {
  // If the language parameter is provided,
  // set the current locale
  if lang != "" {
    // Get the preferred locale based on
    // the language code
    locale := (lang)

    // Set the current locale
    gotext.SetLanguage(locale)
  }
}
Code language: Go (go)

Here we showcase the use of language.NewMatcher from the golang/x/text package, which has logic to detect a locale based on a list of matching supported tags. It works as a Regex Matcher for locales and it can be used in cases when you want to perform content negotiation with the Accept-Language header as well.

Next, modify the part of your application responsible for detecting the user’s language preference. You can achieve this by parsing the language preference from the request URL or headers and setting it in the current locale:

// main.go
+ func detectLanguageMiddleware(next http.HandlerFunc) http.HandlerFunc {
+   return func(w http.ResponseWriter, r *http.Request) {
+     // Get the preferred locale based on the request's Accept-Language header
+     lang := i18n.DetectPreferredLocale(r)
+     i18n.SetCurrentLocale(lang)
+     next.ServeHTTP(w, r)
+   }
+ }
Code language: Diff (diff)

Here’s an example of how you can update the main method to include language detection middleware. It wraps each handler with the detectLanguageMiddleware which will call this

// main.go
func main() {
  ...
  // Middleware to detect user's language preference
+   http.Handle("/", detectLanguageMiddleware(http.DefaultServeMux))
}
Code language: Diff (diff)

Now that we have the backend code ready we need to update the UI to include the language switcher component.

How do I build a language switcher?

Let’s update our static pages, enhancing their UX by providing a selector that allows language switching. This could be a drop-down menu or buttons that trigger a request to change the language. We’re going with the dropdown option here.

Include this code into both the index.html and speedrun.html templates:

<!-- index.html, speedrun.html -->
<label for="selectLanguage">{{T "Select Language"}}</label>
<select id="language-selector">
  {{range .SupportedLanguages}}
  <option value="{{.}}" {{if eq . $.CurrentLanguage}}selected{{end}}>
    {{.}}
  </option>
  {{end}}
</select>
<script>
  const languageSelector = document.getElementById("language-selector");
  languageSelector.addEventListener("change", () => {
    const selectedLanguage = languageSelector.value;
    const currentUrl = window.location.href;
    const newUrl = updateQueryStringParameter(
      currentUrl,
      "lang",
      selectedLanguage,
    );
    window.location.href = newUrl;
  });
  function updateQueryStringParameter(uri, key, value) {
    const re = new RegExp("([?&])" + key + "=.*?(&|$)", "i");
    const separator = uri.indexOf("?") !== -1 ? "&" : "?";
    if (uri.match(re)) {
      return uri.replace(re, "$1" + key + "=" + value + "$2");
    } else {
      return uri + separator + key + "=" + value;
    }
  }
</script>
Code language: HTML, XML (xml)

This code snippet updates the UI to include a language selection dropdown. When a user selects a language, it dynamically constructs a new URL with the selected language parameter and reloads the page with the updated URL. This allows users to change the language of the application.

With that in place, we can select a new locale from the UI, triggering a re-render of the page with the selected translations:

Switching locale from the UI
Switching locale from the UI

As an improvement, we can render localized versions of the locale tags like en-US when selecting their language. Let’s add human-friendly names to our locales:

 <!-- index.html, speedrun.html -->
 <div id="languageDropdown">
    <label for="selectLanguage">{{T "Select Language"}}</label>
    <select id="language-selector">
       {{range .SupportedLanguages}}
-      <option value="{{.}}" {{if eq . $.CurrentLanguage}}selected{{end}}>
-         {{ . }}</option>
+      <option value="{{.}}" {{if eq . $.CurrentLanguage}}selected{{end}}>
+        {{ T .String }}</option>
            {{end}}
        </select>
    </div>
Code language: Diff (diff)

And provide the translations for the language codes:

# locales/el_GR/LC_MESSAGES/default.po
msgid "el_GR"
msgstr "Ελληνικά"

msgid "en_US"
msgstr "Αγγλικά"

msgid "ar_SA"
msgstr "Αραβικά"

# locales/ar_SA/LC_MESSAGES/default.po
msgid "el_GR"
msgstr "اليونانية"

msgid "en_US"
msgstr "الإنجليزية"

msgid "ar_SA"
msgstr "العربية"
Code language: plaintext (plaintext)

Now the language switcher now looks more readable and will render the correct translated version for each locale tag:

Language switcher with human-friendly options
Language switcher with human-friendly options

How do I generate translations from my app?

Now that we have our localization infrastructure set up, let’s explore how we can automate the extraction of translation strings from our application code and initialize our translation files.

Our current tooling’s support for message extraction is currently in beta. Let’s use another tool for extraction.

Additional packages used

For our extraction workflow, we will use the following go package:

Package Version Comment
i18n4go 0.6 CLI tool that provides string extraction from source to either JSON or PO file format

We install the i18n4go CLI tool by using the go install command:

$ go install github.com/maximilien/i18n4go/i18n4go
Code language: Bash (bash)

This tool offers various options on how to extract strings from go sources. In our example, we use the following configuration:

$ i18n4go -c extract-strings --po -d . -r -o "$workdir" --ignore-regexp "$ignored" -output-match-package
Code language: Bash (bash)

🔗 Resource » Read about message extraction in the official readme.

We have provided a script that utilizes the i18n4go command to extract and merge translation messages. It is located in the scripts/extract-strings.sh path of the GitHub repo which also contains relevant comments about the specific arguments used with this command.

So if we run our extraction command now, each locale PO file will contain all the detected translation strings from the source code.

🗒️ Note » When a translation is missing from the active locale, the i18n4go uses the msgid as the value of the translation.

🗒️ Note » The mentioned script only handles one single domain for now. But with a bit of configuration, you can make it work for multiple language domains.

❯ ./scripts/extract-strings.sh --extract
i18n4go: inspecting dir ., recursive: true

...

Total files parsed: 3
Total extracted strings: 19
Total time: 17.140555ms
Code language: Bash (bash)

If you already had some translation string changes, it will keep them as well since it uses the msgcat tool to properly merge two translation catalogs while preserving conflicts. The following diff showcases the added messages when we run the script after we included more translation strings:

# locales/el_GR/LC_MESSAGES

# filename: main.go, offset: 1936, line: 78, column: 37
msgid "Another text on a different domain"
msgstr "Το Μυνημά μου σε διαφορετικό τομέα γλώσσας"

+ # filename: main.go, offset: 1900, line: 78, column: 25
+ msgid "My text on 'domain-name' domain 2"
+ msgstr "My text on 'domain-name' domain 2"
Code language: Diff (diff)

How do I integrate my app with Phrase Strings?

To take our localization to the next level, we can utilize Phrase Strings. Phrase Strings streamlines the localization process, allowing translators to efficiently translate our application’s content and keep message strings in sync between developer machines.

Creating the Phrase Strings project

To integrate Phrase Strings into your app, you need to configure a new Phrase Strings project:

1. Create a Phrase account (you can start for free).
2. Login, open Phrase Strings, and click the New Project button near the top of the screen to create a project.

Adding a new project
Adding a new project

3. Configure the project to use the.PO (Portable Object) translation file format
4. Add starting languages. In our case, we can add en-US first as the default locale, then add ar-SA and el-GR

Adding Supported Locales
Adding Supported Locales

5. Generate an access token from your profile page. Click the user avatar near the top-right of the screen and to go Settings → Profile → Access tokens → Generate Token. Make a copy of the generated token and keep it somewhere safe.

Token generation window

Setting up the Phrase Strings CLI

Now let’s set up the Phrase Strings CLI so we can automate the transfer of our translation files to and from our translators.

🔗 Resource » Installation instructions for the Phrase Strings CLI depend on your platform (Windows, macOS, Linux). Just follow the CLI installation docs and you should be good to go.

With the CLI installed, let’s use it to connect our Go project to Phrase Strings. From your project root, let’s run the following command from the command line.

$ phrase init
Code language: Bash (bash)

We’ll then be given some prompts:

  1. Select project — Select the Phrase Strings project we created above.
  2. Select the format to use for language files you download from Phrase Strings — Hit Enter to select the project’s default.
  3. Enter the path to the language file you want to upload to Phrase — Enter  ./locales/enUS/LCMESSAGES/default.po, since that’s our source translation file.
  4. Enter the path to which to download language files from Phrase — Enter ./locales/<localename>/LCMESSAGES/default.po. ( is a placeholder here, and it allows us to download all the translation files for our project: en-USar-SA, etc.).
  5. Do you want to upload your locales now for the first time? — Hit Enter to accept and upload.

At this point, our en-US.json file will get uploaded to Phrase, where our translators can use the powerful Phrase web admin to translate our app in different locales:

Language translation view
Language translation view

Once our translators complete their work, we execute the following command to incorporate all their translations into our project:

$ phrase pull
Code language: Bash (bash)

This command ensures that our translation files are updated with the latest translations. To verify the updated translations, we can proceed to run our app as usual. This setup also provides translators with an optimal environment for their work.

Conclusion

In this tutorial, we started exploring the capabilities of the Golang gotext package. We developed a small Speedrun leaderboard application that loads gettext translations from the filesystem and starts a web server that serves translations, showcasing how all the components work together. I hope you enjoyed this learning exploration. Please stay tuned for more detailed articles delving into various aspects of localization and internationalization.