Published on Wednesday, 2025-01-22 at 13:00 UTC

Translating Ecto Changeset Errors in Phoenix

Translating changeset errors is actually easy and you don’t have to mix web and context modules.

I recently received a great pull request for Keila, my Open Source email newsletter tool, that added a French translation. Even though I had previously translated Keila to German, it seems like I missed quite a few strings that should have been translated. One of the aspects I had previously neglected to translate were Ecto changeset errors from custom validations.

So in this how-to I’ll share what I learned when fixing this and how you can translate Ecto changeset errors in your own Phoenix applications.

How Not to Translate Changeset Errors

Every new Phoenix app comes with some macros based on the the Gettext library. Theese macros make translating an application quite easy.

If you want to translate a string in a Phoenix view, simply wrap it with the gettext() macro like this: <%= gettext("Translate me!") %> and it’s now translatable!
If you want to support pluralization, you can use ngettext("an apple", "%{count} apples", n) instead.

Thus, it might seem like a no-brainer to just import MyAppWeb.Gettext in your schema module and use gettext/2 or ngettext/4 directly in a custom validation like so:

# Don't do this!

defmodule MyApp.MySchema do
  import MyAppWeb.Gettext
  # ...
  
  def changeset(data, params) do
    data
    |> cast(params, [:password])
    |> validate_change(:password, fn :password, value ->
      if value != "mellon" do
        [password: gettext("this is not the right password")]
      else
      []
    end)
  end
end

This, however, is not a good idea for two reasons:

  1. It mixes the web concerns (MyAppWeb) and the context concerns (MyApp)
  2. This approach translates the error directly at the source. This means if you're doing introspection or logging when something goes wrong unexpectedly, you'll have to deal with errors in a language you might not be familiar with.

So what could be a better solution?

How Translations Normally Work in Phoenix

The nice thing about the translation system that ships with Phoenix is that it can automatically extract all strings from your web application into gettext-compatible translation files.

Once you run mix gettext.extract, your gettext-wrapped strings are added to a .pot file in priv/gettext. By default, all strings from your views go into default.pot. An entry in this file look like this:

msgid "Translate me!"
msgstr ""

This .pot file is a template file and simply lists the strings available for translation. That's why msgstr is always empty here.

Next you can run mix gettext.merge to create the actual .po translation files for each language you’ve configured. They can then be found in priv/gettext/#{locale}/LC_MESSAGES/. Here we can adjust the msgstr that goes with every msgid and add a translation:

msgid "Translate me!"
msgstr "Übersetze mich!"

If you don’t want all strings to be in the same translation file, you can specify a different domain with dgettext/3 and dngettext/5.
So the string from <%= dgettext("errors", "This is an error") %> is going to be extracted into errors.pot.

Speaking of errors.pot - you might have noticed that Phoenix already ships with this file. And it contains msgstrs for all built-in Ecto validations!

## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""

So how does this work and how do we use it for our own validations?

Let’s do it the Phoenix way!

Recent versions of Phoenix initially create a handful of useful components in lib/myapp_web/components/core_components.ex. These include helpers like input/1 that render a form field with validation errors. The module also exports a function called translate_errors which looks like this:

def translate_error({msg, opts}) do
  if count = opts[:count] do
    Gettext.dngettext(MyAppWeb.Gettext, "errors", msg, msg, count, opts)
  else
    Gettext.dgettext(MyAppWeb.Gettext, "errors", msg, opts)
  end
end

In applications initialized with older versions of Phoenix, this same function can be found atlib/myapp_web/views/helpers/error_helpers.ex.

Here is a simplified version of how translate_error is used by the input component or how you might use it in your own custom component to show changeset errors alongside an input field:

<div>
  <input value={@field.value} id={id}>
  <%= for error <- @field.errors do %>
    <p class="bg-red-300"><%= translate_error(error) %></p>
  <% end %>
</div>

For every error entry in the Phoenix.HTML.FormField struct (which contains the changeset errors), translate_error/1 is called. Beacause it then calls gettext dynamically, the possible error strings are not known at compile-time. This means, the strings cannot be extracted by mix gettext.extract. That’s why Phoenix ships with a default errors.pot template!

Now that we understand this, we know that we have to add the msgstr entry to errors.pot manually:

msgid "this is not the right password
msgstr ""

Now you can run mix gettext.merge and add translations in the errors.po files. Your custom validation error messages are translated, the Phoenix way!

Translating more complex errors

Changeset errors can either be a plain string or a tuple with a string and a keyword list (see the specs of validate_change/3 here). That’s also how we can get pluralized validation messages. Let’s take this validation for example:

def changeset(data, params) do
  data
  |> cast(params, [:password])
  |> validate_change(:password, fn :password, value ->
    random = :rand.uniform(100)
    [password: {"%{count} random error(s)", count: random}]
  end)
end

To translate this error, you need to add this to errors.pot:

msgid "%{count} random error(s)"
msgid_plural "%{count} random error(s)"
msgstr[0] ""
msgstr[1] ""

Note that the same string has to be used for msgid and msgid_plural. That’s because Ecto changeset errors only consist of a single string with no native plural support. But the way translate_error is implemented, when the :count keyword is present, ngettext is used with the value of :count as the number that determines whether a plural form needs to be used or not (n).

The other keywords are also passed to gettext which means they can be used as interpolation values.

Keep in mind that you can also add English "translation" strings in en/LC_MESSAGES/errors.po like this even if the original language of your application is already English:

msgid "%{count} random error(s)"
msgid_plural "%{count} random error(s)"
msgstr[0] "one random error"
msgstr[1] "%{count} random errors"

Final Thoughts

As always in Phoenix, there isn’t really any magic to this. translate_error is just a regular function. It’s initially created by the Phoenix app generator, and you can use it if you like or create your own implementation.

TL;DR: Manually add error msgstrs to errors.pot and use the translate_error function.