Published on Sep 01, 2025 in sphinx i18n

Sphinx Translation Tutorial: Localize Your Docs Like a Pro#

Localizing documentation, manual, or help is a challenging task. But it’s also an area where Sphinx documentation generator really shines. Sphinx gives you strong tooling for the entire lifecycle from initial translation to keeping translation(s) up to date with constantly changing original. In this tutorial, I will guide you through the full translation workflow using a minimal yet realistic Sphinx documentation example.

If you are in a hurry or want to see the result, check out my sphinx-doc-i18n-example GitHub repo.

Goals#

In this tutorial, I will demonstrate how to

  1. Create a minimal Sphinx project

  2. Build the docs languages set.

  3. Extract strings and translate strings with gettext.

  4. Keep translation and original in sync

  5. Automate the process with Nox.

At the end of the tutorial, you will have

  • Minimal but real-world Sphinx documentation.

  • Primary language is English (a language of .rst documents)

  • Translations to Czech and Ukrainian.

  • Project using modern Python tooling like uv and nox.

Create a Sphinx project#

The bare minimum Sphinx project needs only two files: index.rst and conf.py. Moreover, the latter one, conf.py, can be completely blank because all options have reasonable defaults.

  1. Create a new folder for the Sphinx documentation. E.g., sphinx-translation-example.

  2. Create a new subfolder source. It will contain Sphinx source files, such as .rst documents, images, and conf.py. Folder is sometimes called indir.

  3. Within source/ create

    • The blank conf.py file. E.g., with the touch conf.py command in Linux-like environments)

    • The index.rst with this content:

    Welcome
    =======
    
    Hello Sphinx!
    
    .. note:: This is a note.
    

Build docs languages set#

Let’s build the documentation for English, Czech, and Ukrainian. Translations will be identical to the English original at this moment.

Sphinx requires Python to be installed; however, no prior knowledge of Python is necessary to use it. The recommended way to install the Sphinx is the uv. The uv can even install Python if you don’t already have it.

  1. Test building docs. Open and execute in the terminal the following command.

    • with Sphinx already installed by other means:

      sphinx-build -b html source build
      

      Read more about sphinx-build, if you are interested.

    • install and run Sphinx using uvx:

      uvx --from sphinx sphinx-build -b html source build
      

      It says to the uv to temporarily install the Sphinx package (in the latest version) and run the sphinx-build command from it. The command takes sources from source/ and outputs HTML to build/. Read more about uvx, if you are interested.

      Important

      From now on, I will omit the uvx --from sphinx part.

  2. The previous sphinx-build invocation sets English because if not specified on the commandline or in conf.py, Sphinx assumes English (en).

  3. Build languages set with -D language=<code> argument to sphinx-build. It overrides language from conf.py, if any. Even if English is the default, it’s better to be explicit.

    To build English (en), Czech (cs), and Ukrainian (uk) docs in respective build/<code>/ subfolders run:

    $ sphinx-build -b html -D language=en source build/en
    $ sphinx-build -b html -D language=cs source build/cs
    $ sphinx-build -b html -D language=uk source build/uk
    

    Hint

    Always consult the language codes in Sphinx docs. Despite, e.g., uk for Ukrainian works, the official code is uk_UA.

  4. Open build/<code>/index.html in the browser to check everything looks okay. The pages are almost the same. They all contain original English strings, but Sphinx translates some parts of the website out of the box.

    For example, admonitions like “Note”, labels like “Navigation”, or the search “Go” button are automatically translated into the target language.

Gettext in Sphinx overview#

Sphinx supports localization using Gettext, the industry standard for software translation. There are many articles about this Gettext, including on our blog:

However, in essence, to know about gettext localization in Sphinx:

  • Gettext calls strings to be translated as messages, groups them into domains, and uses files with .pot, .po, and .mo extensions to get localization works.

  • Textual .pot contains message templates (therefore “T” in .pot). When the gettext extract tool searches for strings, it initially stores them into .pot files.

  • In Sphinx docs, domains are documents. E.g., all strings found in intro.rst will be in the intro domain and intro.pot file. (Footnote: The text domain of individual documents depends on gettext_compact.)

  • There is always one POT per domain.

  • Textual .po file is for translated messages. They exist for non-primary languages only. In Sphinx, the files are stored in the locales/ folder by default. E.g., for Czech and “intro” domain in locales/cs/LC_MESSAGES/intro.po.

  • PO files are identical to POT, but have empty translations.

  • Binary .mo files are compiled .po files. The gettext-powered software, such as Sphinx, doesn’t look up messages in .po files, but instead reads an optimized binary version with the .mo extension. For each .po, a corresponding .mo exists. Sphinx compiles MO from PO automatically as needed at sphinx-build time.

  • Only PO files are versioned. Ignore POT because they are generated from source documents, and MO because they are compiled from PO files.

Extract strings#

Extracking is searching documents for a string to be translated. For extracting strings in Sphinx documents, such as index.rst, we first need to run the Sphinx gettext builder. So far, we have been building in HTML. Gettext is another built-in Sphinx builder that produces .pot files instead of .html, .css, and .js.

  1. Extract strings to build/gettext/:

    $ sphinx-build -b gettext source build/gettext
    
  2. Examine build/gettext/index.pot. The POT/PO is a simple text format with couples of msgid and msgstr. The former is the original string, and the latter is the translation (which is always empty in POT).

    The first pair of blank msgid and msgstr is a header with metadata.

    # SOME DESCRIPTIVE TITLE.
    # Copyright (C) 
    # This file is distributed under the same license as the Project name not set package.
    # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
    #
    #, fuzzy
    msgid ""
    msgstr ""
    "Project-Id-Version: Project name not set \n"
    "Report-Msgid-Bugs-To: \n"
    "POT-Creation-Date: 2025-08-28 22:53+0200\n"
    "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    "Language-Team: LANGUAGE <LL@li.org>\n"
    "MIME-Version: 1.0\n"
    "Content-Type: text/plain; charset=UTF-8\n"
    "Content-Transfer-Encoding: 8bit\n"
    

    After the header, the actual strings follow. Our little index.rst has only three strings.

    #: ../../source/index.rst:2
    msgid "Welcome"
    msgstr ""
    
    #: ../../source/index.rst:4
    msgid "Hello Sphinx!"
    msgstr ""
    
    #: ../../source/index.rst:6
    msgid "This is a note."
    msgstr ""
    
    

    The #: ../../source/index.rst:4 means the path to the document and the line number where the original string is located.

Translate strings#

It’s time to translate! From the POT templates, I will generate a PO for each target language. Translations in POT are always blank. That’s the only difference with PO. A translator’s job is to enter the translation into msgstr.

  1. Create locales/<code>/LC_MESSAGES/ folders for each localized (non-primary) language. For our case, you will end up with

    • locales/cs/LC_MESSAGES/

    • locales/uk/LC_MESSAGES/

    (locales/ is the default value of Sphinx conf.py locale_dirs. Using a different name is not recommended.)

  2. Copy all .pot files from build/gettext/ to locales/<code>/LC_MESSAGES/ as .po. You will end up with:

    locales/cs/LC_MESSAGES/index.po

    locales/uk/LC_MESSAGES/index.po

  3. Translate msgid into msgstr.

    (For brevity, I am omitting the headers.) The Czech PO:

    #: ../../source/index.rst:2
    msgid "Welcome"
    msgstr "Vítejte"
    
    #: ../../source/index.rst:4
    msgid "Hello Sphinx!"
    msgstr "Ahoj Sphinxi!"
    
    #: ../../source/index.rst:6
    msgid "This is a note."
    msgstr "Toto je poznámka."
    

    The Ukrainian PO:

    #: ../../source/index.rst:2
    msgid "Welcome"
    msgstr "Ласкаво просимо"
    
    #: ../../source/index.rst:4
    msgid "Hello Sphinx!"
    msgstr "Привіт, Sphinx!"
    
    #: ../../source/index.rst:6
    msgid "This is a note."
    msgstr "Це примітка."
    
  4. Now rebuild the docs languages set and check out the files in the browser.

Sync translations with original#

Initial translation is a big task, but not as tricky as keeping translation in sync with the original in the long term. The source text is constantly changing, and it’s too easy to get out of sync with the original.

To simulate real-world scenarios, I will

  • Change “Hello Sphinx” -> “Hi Sphinx”. Hello and Hi are synonymous, so that it will have no impact on translations.

  • Delete “This is a note.” admonition.

  • Add a new “How are you?” paragraph.

Note

Strictly speaking, there’s only an add/delete modification because a change is actually a deletion and an addition.

To help, we will start using the sphinx-intl tool. Its command update will update messages in PO according to new, fresh POTs without losing existing translations.

You can install and run sphinx-intl with uvx, too. Just run uvx sphinx-intl and the command name. E.g., uvx sphin-intl stat to display translation stats.

  1. Update the contents of the index.rst to:

    Welcome
    =======
    
    Hi Sphinx!
    
    How are you?
    
  2. Re-generate a new fresh POTs (sphinx-build -b gettext ...).

  3. Update message in PO according to POT without losing existing translations with the sphinx-intl update command.

    sphinx-intl update
    

    Notice the output

    Update: source/locales/uk/LC_MESSAGES/index.po +2, -2
    Update: source/locales/cs/LC_MESSAGES/index.po +2, -2
    

    The output informs us about the changes made and the number of changes. E.g., we made two additions and two removals.

  4. With arguments to sphinx-intl update, you can you can specify locales folder, pot folder, language, etc. Since we are using default values (build/gettext/ for POTs, locales/ for POs), there is no need to override them. But from practice, I highly recommend being

    • explicit about paths

    • disable line wrapping (lines in POs are, by default, limited to 76) to reduce later Git conflicts (-w 0)

    I.e.,

    sphinx-intl update -d source/locales -p build/gettext -w 0
    
  5. Examine the POs. For example, a Czech PO now looks like:

    #: ../../source/index.rst:2
    msgid "Welcome"
    msgstr "Vítejte"
    
    #: ../../source/index.rst:4
    #, fuzzy
    msgid "Hi Sphinx!"
    msgstr "Ahoj Spjinxi!"
    
    #: ../../source/index.rst:6
    msgid "How are you?"
    msgstr ""
    
    #~ msgid "This is a note."
    #~ msgstr "Toto je poznámka."
    

    Please notice

    • The modification, the message “Hi Sphinx!”, has been marked as fuzzy (#, fuzzy). It means gettext thinks the translation might stay the same because the original has changed only a little (it was “Hello Sphinx!”). But gettext is unsure. By marking it with a fuzzy flag, it’s raising a translator’s attention.

    • The removal, the message “This is a note.”, has been prefixed with #~, which means in Gettext “obsolete” message. It’s no longer used in sources, but it’s retained for historical purposes.

      (sphinx-intl update can with --no-obsolete delete these messages, but I usually prefer to keep them.)

  6. Rebuild the docs language set and check out in the browser.

    $ sphinx-build -b html -D language=en source build/en
    $ sphinx-build -b html -D language=cs source build/cs
    $ sphinx-build -b html -D language=uk source build/uk
    

    Note the fuzzy (“Hi Sphinx”) and missing (“How are you?”) messages fall back to the original language.

  7. Fix translations. Remove fuzzy and fill-in translation. E.g., Ukrainian:

    #: ../../source/index.rst:2
    msgid "Welcome"
    msgstr "Ласкаво просимо"
    
    #: ../../source/index.rst:4
    msgid "Hi Sphinx!"
    msgstr "Привіт, Sphinx!"
    
    #: ../../source/index.rst:6
    msgid "How are you?"
    msgstr "Як справи?"
    
    #~ msgid "This is a note."
    #~ msgstr "Це примітка."
    
  8. Rebuild the docs language set and check out in the browser. No change to English, but the remaining locales are fixed now.

Automate with Nox#

Writing the above commands in the terminal again and again is difficult and error-prone. In the Python world, there are tools like Nox which centralize all “tasks” you might need to do with the project. Nox is like Make for C programmers, or package.json scripts for Node.js programmers.

I am not going to explain Nox here, so only in a hurry:

  • In Nox, tasks are written as a Python script called noxfile.py in the root.

  • The tasks in Nox are properly called sessions, but I will call them tasks.

  • We will need, actually, only two tasks: update translation and build the docs language set.

The resulting noxfile.py:

import nox

# Speed up builds by uv and reusing virtualenvs
nox.options.reuse_existing_virtualenvs = True
nox.options.default_venv_backend = "uv"


@nox.session
@nox.parametrize("language", ["cs", "uk"])
def gettext(session, language):
    """Generate .pot files and update .po files."""
    session.install("sphinx==8.1.3", "sphinx-intl==2.3.2")

    # Generate .pot files from Sphinx
    session.run("sphinx-build", "-b", "gettext", "source", "build/gettext")

    # Update .po from .pot templates
    session.run(
        "sphinx-intl",
        "update",  # update .po files
        "-p",  # from .pot files at
        "build/gettext",
        "-l",  # for language
        language,
        # No line wrapping
        "-w",
        "0",
    )


@nox.session
@nox.parametrize("language", ["en", "cs", "uk"])
def build(session, language):
    """Build documentation for a language."""
    session.install("sphinx==8.1.3")
    session.run(
        "sphinx-build",
        "-b",
        "html",
        "-D",
        f"language={language}",
        "source",
        f"build/{language}",
    )

For Pythonistas, the script should be self-explanatory. For everybody, here are some comments:

  • To massive performance improvement, I again use uv. If you don’t have it installed, delete the nox.options.default_venv_backend = "uv" line.

  • The task gettext will install Sphinx and sphinx-intl, extract POTs, and create/update PO for Czech and Ukrainian (["cs", "uk"])

  • The task build builds the docs for all languages (["en", "cs", "uk"]).

  • To call a task, run nox -s <name>, e.g., nox -s gettext. To run Nox without installing using the uvx, type uvx nox -s gettext.

  • To call multiple tasks, run nox -s <name1> <name2>.

  • To call all tasks from top to bottom, just run nox. In our case, it means to gettext followed by build.

Summary and workflow cheatsheet#

With nox, a simple no-brainer cheatsheet is:

  • On every text change, run nox -s gettext to update POs and commit.

  • Translate missing or fuzzy messages before publishing the docs.

  • To build the docs language set, run nox -s build and add some language selection.

  • In other words, your everyday workflow is gettext -> build, which is launched by just running nox.

I hope you enjoyed the tutorial and it convinced you that localizing Sphinx documentation projects is just more reason why Sphinx is the best documentation generator.