Translate a QGIS Plugin with Qt Linguist

QGIS ships in more than forty languages, and a large share of its users work in one that is not English. A plugin whose interface is English-only is usable by them, in the way a manual in a second language is usable — slowly, and with a running cost of small misunderstandings. The translation machinery is Qt's, it is mature, and wiring it up is an afternoon that then costs almost nothing to maintain.

This recipe belongs to Plugin Settings and Localization. It walks the whole pipeline: marking strings, extracting them, translating, compiling, loading, and keeping the files current when the plugin changes.

What lives where in a translatable pluginThe plugin folder holds the Python sources and user interface files that contain marked strings, a project file listing what to scan, and an i18n folder. Inside the i18n folder, one editable translation file per language sits beside its compiled binary counterpart. Only the compiled files are needed at run time, and both are usually kept in version control.Two files per language: one people edit, one QGIS loadsthe plugin folderparcel tools.py — tr() callsdialog.ui — translatable labelsparcel tools.pro — what to scani18n/inside i18n/parcel tools de.ts — editable, in Linguistparcel tools de.qm — compiled, loadedparcel tools fr.tsparcel tools fr.qm

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • Qt tooling: pylupdate5 (from PyQt5) and lrelease (from Qt), both usually available in the QGIS install or through your package manager.
  • Optionally Qt Linguist, the graphical editor translators use. The .ts format is XML, so a text editor works in a pinch.

Mark every user-visible string

from qgis.PyQt.QtCore import QCoreApplication


class ParcelTools:
    def tr(self, message):
        return QCoreApplication.translate("ParcelTools", message)

    def initGui(self):
        self.action = QAction(self.tr("Analyse parcels"), self.iface.mainWindow())
        self.action.setStatusTip(self.tr("Summarise parcel areas by ward"))

    def report(self, count):
        return self.tr("Processed {} parcels").format(count)

Breakdown: The first argument to translate() is the context, conventionally the class name; it groups strings in the translation file and lets the same English word be translated differently in different places, which matters more than it sounds — "Filter" as a noun and as a verb are different words in most languages. Defining a small tr() method per class is the standard convention. The last method shows the important discipline: the placeholder is inside the translated string, so the translator can move it. Building the same sentence as self.tr("Processed") + str(count) + self.tr("parcels") produces three fragments that cannot be reassembled correctly in a language with different word order.

Strings in .ui files are marked automatically by Qt Designer as long as the "translatable" checkbox stays enabled, which it is by default — see Load a .ui File at Runtime in PyQGIS.

Extract the strings

Create a small project file listing what to scan:

SOURCES = parcel_tools.py \
          analysis_dialog.py \
          options_page.py

FORMS = ui/analysis_dialog.ui

TRANSLATIONS = i18n/parcel_tools_de.ts \
               i18n/parcel_tools_fr.ts \
               i18n/parcel_tools_es.ts

Then run the extraction:

pylupdate5 parcel_tools.pro

Breakdown: pylupdate5 parses the listed Python and .ui files, finds the marked strings, and writes or updates one .ts file per entry in TRANSLATIONS. Updating is the useful part: existing translations are preserved, new strings appear as untranslated, and strings that disappeared from the source are kept but marked obsolete rather than deleted, so a rename does not throw away work. The language is taken from the file name suffix, which must be a valid locale code — de, fr, pt_BR. Adding a language is one more line in TRANSLATIONS and a re-run.

Translate and compile

Open a .ts file in Qt Linguist, work through the untranslated entries, and mark each one done. Then compile:

lrelease i18n/parcel_tools_de.ts

Breakdown: lrelease produces a .qm file — a compact binary form optimised for lookup, and the only file needed at run time. Entries left unfinished in Linguist are excluded from the compiled file, so a half-finished translation falls back to English string by string rather than breaking. Run lrelease on every language before packaging, and ship the .qm files inside the plugin zip; forgetting this step is the single most common reason a plugin with complete translations still appears in English for everybody.

One string, five statesA string is written in English and marked with tr. Extraction records it as untranslated in the translation file. A translator supplies the German text and marks the entry finished. The release tool compiles the finished entry into the binary file. At run time the plugin displays the German text, or falls back to the English source if the entry was never finished.An unfinished entry is not a broken entryin sourceAnalyse parcelsextracteduntranslatedtranslatedmarked finishedcompiledinside the .qmdisplayedin the menuNever finished? The English source is displayed instead —a partial translation is perfectly shippable

Load the translation at startup

import os
from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication


class ParcelTools:
    def __init__(self, iface):
        self.iface = iface
        self.plugin_dir = os.path.dirname(__file__)

        locale = QSettings().value("locale/userLocale", "en")[0:2]
        qm_path = os.path.join(self.plugin_dir, "i18n", f"parcel_tools_{locale}.qm")

        if os.path.exists(qm_path):
            self.translator = QTranslator()
            self.translator.load(qm_path)
            QCoreApplication.installTranslator(self.translator)

Breakdown: This belongs in __init__, before anything user-visible is created — a translator installed after the menu is built leaves the menu in English until the next restart. Taking the first two characters of the locale means de_AT finds parcel_tools_de.qm, which is usually what you want; keep the full code only where you genuinely have regional variants, such as pt_BR against pt. The exists() check keeps a missing file from being an error: no translation simply means the English source is used. Keeping the translator as an attribute is essential, exactly as with the options factory — a local QTranslator is garbage-collected and the translation silently stops working.

Install the translator before anything is builtDuring plugin construction the translator is installed, and every string created afterwards is translated as it is made. If the translator is installed after initGui has already built the menu and toolbar, those labels were created in English and stay English until the next restart, even though later strings appear translated.Strings are translated when they are created, not when they are shownthe constructorinstall the translator hereinitGuimenu and toolbar labels createdrunningdialogs and messagesinstalled firsteverything appears translatedinstalled too latemenus stay English until restart

Keep translations current

The workflow only breaks in one place: new strings added and never re-extracted. Two habits prevent it.

Re-run extraction before every release. pylupdate5 parcel_tools.pro followed by lrelease on each .ts file, ideally as a step in your build script rather than a thing to remember. In continuous integration, running the extraction and failing if the .ts files change is a neat way to catch a forgotten update — the same pattern as the checks in Run QGIS Plugin Tests in GitHub Actions.

Keep the .ts files in version control and the .qm files too, unless your packaging step builds them. Translators then work through pull requests, and a translation contributed once is never lost to a rebuild.

Two smaller points repay attention. Add a translator comment in Linguist where a string is ambiguous — "Filter" alone is impossible to translate well, and one sentence of context makes it easy. And localise metadata.txt: fields such as description[de] and about[de] are shown by the plugin repository in the user's language, which is what gets the plugin found in the first place.

Write strings that can be translated well

Marking a string makes it translatable; writing it carefully makes the translation good. A few habits separate the two.

Whole sentences, one string. Translators need the full sentence to choose grammar, gender and word order. A string ending in a colon that is completed by another string somewhere else is unanswerable in most languages.

Placeholders, not concatenation — and give them meaning where a sentence has more than one, because the order will change. "{count} of {total} parcels" can be reordered by the translator; two positional slots often cannot be told apart.

No embedded formatting assumptions. A string that starts with a capital because it always appears at the start of a sentence will be wrong the day it appears mid-sentence, and a translator has no way to know which case applies.

Comments for anything ambiguous. In Linguist a note such as "verb: the action of filtering the table" turns an impossible entry into an easy one. English's habit of using the same word as noun and verb is a constant source of mistranslation.

Avoid idiom and humour. They rarely survive, and a translator who cannot render the joke will either drop it or produce something odd.

Leave room. German runs about a third longer than English and Finnish longer still, so a label that exactly fits its button in English will not fit in translation. Layout managers solve this; fixed widths do not.

None of this costs time at the point of writing. All of it costs a great deal to retrofit once a dozen languages have been translated against the original strings.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9PyQt5 tooling: pylupdate5 and lrelease as described.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical workflow; builds moving to Qt 6 use pylupdate6 and lrelease6 with the same arguments.

Plugins targeting both Qt 5 and Qt 6 builds should keep the extraction step in a script that picks whichever tool is present, since the file formats themselves are compatible.

Troubleshooting

  • Nothing is translated. The translator was not kept as an attribute, was installed after the interface was built, or the .qm file is missing from the packaged zip.
  • Some strings are translated and others are not. The untranslated ones were never marked with tr(), or their entries were left unfinished in Linguist.
  • A string is translated in one place and not another. Two different contexts. Check that both call sites use the same context name.
  • pylupdate5 finds nothing. The .pro file lists the wrong paths, or the strings use a tr() the tool cannot see — it does static analysis, so a string built at run time cannot be extracted.
  • A sentence reads oddly in translation. It was assembled from fragments. Rewrite it as one string with placeholders.
  • The plugin shows English on a German system. The QGIS interface language, not the operating system locale, is what locale/userLocale reports. Check what QGIS itself is set to.

Conclusion

Mark strings with a per-class tr(), list your sources in a .pro file, extract with pylupdate5, translate in Qt Linguist, compile with lrelease, and install a QTranslator in __init__ while keeping a reference to it. Re-extract before every release, keep the translation files in version control, and use placeholders rather than concatenation so translators can produce sentences that actually read well.

Frequently Asked Questions

Do I need a translation for every language QGIS supports? No. Ship what you have; missing languages fall back to the source text. Even one additional language meaningfully widens who can use the plugin.

Can I translate the plugin name? The name in metadata.txt is an identifier and should stay stable, but description and about accept localized variants with a language suffix.

How do I handle plurals? Use the numeric form of QCoreApplication.translate(), which lets each language define its own plural rules — several have more than two forms.

What happens to a translation when I reword the English? Extraction marks the old entry obsolete and the new one untranslated, keeping the previous text visible in Linguist so the translator can adapt rather than start over.

Can translations be crowdsourced? Yes — the .ts format is supported by the common translation platforms, and a plugin's translations are a low-risk first contribution for a new collaborator.