{"id":8136,"date":"2026-09-03T06:00:00","date_gmt":"2026-09-03T06:00:00","guid":{"rendered":"https:\/\/poeditor.com\/blog\/?p=8136"},"modified":"2026-09-03T08:00:35","modified_gmt":"2026-09-03T08:00:35","slug":"python-gettext-localization","status":"publish","type":"post","link":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/","title":{"rendered":"Python gettext localization: How to translate a Python app"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"820\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization-1024x820.png\" alt=\"\" class=\"wp-image-8156\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization-1024x820.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization-300x240.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization-767x614.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization-1536x1230.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization.png 1600w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Python has had a localization module in its standard library for a long time. <a href=\"https:\/\/docs.python.org\/3\/library\/gettext.html\" rel=\"nofollow\">gettext<\/a> uses the GNU gettext file formats, it has no dependencies, and it behaves the same in a Django site, a Flask app, or a command-line script.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Python gettext localization involves four main steps: marking the strings you want translated, extracting them into catalog files, translating those catalogs, and loading the right one when the app runs. Preparing the code is Python internationalization (i18n); producing the translations is localization (l10n).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide covers the full cycle on a small Flask to-do app\u2014marking strings in Python and Jinja, extracting them with Babel, managing the translations in POEditor, handling plurals and ambiguous words, and switching the interface between English and French.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Setting up the project<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"821\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-list-with-tasks-1-1024x821.png\" alt=\"\" class=\"wp-image-8190\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-list-with-tasks-1-1024x821.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-list-with-tasks-1-300x240.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-list-with-tasks-1-767x615.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-list-with-tasks-1-1536x1231.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07-list-with-tasks-1-2048x1642.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The TO-DO app with a list of tasks<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">To follow this guide you need Python 3.9 or newer\u2014pgettext(), which we use later for translation context, was added in 3.8.<br>We\u2019ll localize TO-DO app, a small Flask project where users register, log in, and manage their to-do lists:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The app uses a few routes, SQLite, and Jinja templates, with the kinds of strings you would normally find in a real application: page titles, form labels, flash messages, button text, and counts that change with the number of items. If you\u2019re bringing your own app, everything from the extraction step onwards works the same way.<br>Install the dependencies:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pip install Flask Flask-Babel<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Flask-Babel pulls in Babel, so that\u2019s the only install you need:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># requirements.txt\nFlask==3.1.3\nFlask-Babel==4.0.0\nBabel==2.18.0<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Python\u2019s documentation names three extraction tools: xgettext, pygettext.py and <a href=\"https:\/\/babel.pocoo.org\/en\/latest\/cmdline.html\" rel=\"nofollow\">Babel<\/a>. Babel is a good fit here because it reads Jinja templates as well as .py files, and it can also compile catalogs and format dates and numbers by locale.<br><a href=\"https:\/\/python-babel.github.io\/flask-babel\/\" rel=\"nofollow\">Flask-Babel<\/a> hooks it into Flask and selects a locale per request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The structure we\u2019re working with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app\/<br>\u251c\u2500 wsgi.py<br>\u251c\u2500 requirements.txt<br>\u251c\u2500 babel.cfg&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # tells Babel which files to scan<br>\u251c\u2500 messages.pot &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # extracted source strings (generated)<br>\u2514\u2500 todoapp\/<br>&nbsp; \u251c\u2500 __init__.py &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # create_app(), Babel setup, locale selection<br>&nbsp; \u251c\u2500 config.py &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # supported locales and Babel settings<br>&nbsp; \u251c\u2500 auth.py &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # register \/ log in \/ log out<br>&nbsp; \u251c\u2500 todos.py&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # list and task CRUD<br>&nbsp; \u251c\u2500 templates\/<br>&nbsp; \u2502&nbsp; \u251c\u2500 base.html&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # header with the language switcher<br>&nbsp; \u2502&nbsp; \u251c\u2500 index.html<br>&nbsp; \u2502&nbsp; \u251c\u2500 list.html<br>&nbsp; \u2502&nbsp; \u2514\u2500 auth\/<br>&nbsp; \u2502 &nbsp; &nbsp; \u251c\u2500 login.html<br>&nbsp; \u2502 &nbsp; &nbsp; \u2514\u2500 register.html<br>&nbsp; \u2514\u2500 translations\/ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # one folder per language<br>&nbsp; &nbsp; &nbsp; \u2514\u2500 en\/LC_MESSAGES\/<br>&nbsp; &nbsp; &nbsp; &nbsp; \u251c\u2500 messages.po<br>&nbsp; &nbsp; &nbsp; &nbsp; \u2514\u2500 messages.mo<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run it with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>flask --app wsgi run<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Flask prints the address it\u2019s serving on. Register an account and look around: every string you see is hardcoded English.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How localization works in Python<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Python localization with gettext is a six-step cycle. Steps 1 and 2 repeat whenever you add or change text; steps 3 to 6 whenever you add a language.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Mark the translatable strings<\/strong> with _() or one of its variants.<\/li>\n\n\n\n<li><strong>Extract them<\/strong> into a .pot template, using Babel.<\/li>\n\n\n\n<li><strong>Create one `.po` catalog per language<\/strong> from that template.<\/li>\n\n\n\n<li><strong>Translate the catalogs<\/strong>, by hand or in a platform such as POEditor.<\/li>\n\n\n\n<li><strong>Compile each `.po` into a `.mo`<\/strong>, the binary format gettext reads.<\/li>\n\n\n\n<li><strong>Select a locale and load its catalog<\/strong> when the app runs.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Babel covers steps 2, 3 and 5, gettext covers 1 and 6, and step 4 is where POEditor comes in.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">msgid, msgstr, and the three file types<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Each catalog entry pairs a source string, the msgid, with its translation, the msgstr:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>msgid \"Create list\"<br>msgstr \"Cr\u00e9er une liste\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">At runtime gettext looks up the <code>msgid<\/code> for the active locale and returns the msgstr. With no catalog, or no entry, it returns the <code>msgid<\/code>\u2014the English text. That is why an untranslated app still runs, and why TO-DO app already offers French but renders in English apart from the dates:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"469\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12-french-selected-no-catalogue-1-1024x469.png\" alt=\"\" class=\"wp-image-8191\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12-french-selected-no-catalogue-1-1024x469.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12-french-selected-no-catalogue-1-300x137.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12-french-selected-no-catalogue-1-766x351.png 766w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12-french-selected-no-catalogue-1-1536x704.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12-french-selected-no-catalogue-1-2048x938.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>French selected with no French catalog compiled yet<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Three file types carry those entries. <strong>`.pot`<\/strong> is the template: every extracted source string with empty translations, regenerated from source rather than edited. <strong>`.po`<\/strong> is one language\u2019s catalog, holding the translations plus metadata\u2014source references, contexts, plural rules. <strong>`.mo`<\/strong> is the compiled .po that gettext actually reads, so an edited translation does nothing until you recompile.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">They live in a tree keyed by locale, each catalog named after the gettext <strong>domain<\/strong>\u2014messages here:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>translations\/<br>\u251c\u2500\u2500 en\/LC_MESSAGES\/messages.po, messages.mo<br>\u2514\u2500\u2500 fr\/LC_MESSAGES\/messages.po, messages.mo<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">The two gettext APIs<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The <strong>GNU-style API<\/strong> mirrors the C library: you bind a domain to a directory and install _() for the whole process.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Python\u2019s documentation is explicit: \u201cIf you use this API you will affect the translation of your entire application globally.\u201d That suits a command-line tool that picks its language once, from the environment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <strong>class-based API<\/strong> returns a translation object instead:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># app.py<br>import gettext<br><br>translation = gettext.translation(<br>&nbsp; &nbsp; \"messages\", localedir=\"locale\", languages=&#091;\"fr\"],<br>)<br>_ = translation.gettext<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation calls this \u201cthe recommended way of localizing your Python applications and modules\u201d, and points to it for applications that \u201cneed to switch languages on the fly\u201d\u2014any web app, since it serves several languages from one process.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Frameworks wrap this API rather than replacing it. Flask-Babel and Django both call into <code>gettext<\/code>, use the same <code>.po<\/code> and <code>.mo<\/code> files, and add per-request locale selection and template integration. TO-DO app imports <code>gettext<\/code> from <code>flask_babel<\/code>; everything from step 2 onward is identical either way.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Marking strings for translation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Marking a string means wrapping it in a function call, which does two jobs: at runtime it looks the string up in the active catalog, and at extraction time it tells Babel the string needs translating.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">In Python modules<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Import <code>gettext<\/code> as <code>_<\/code>, the name extraction tools look for by default. With Flask-Babel it comes from <code>flask_babel<\/code> rather than the standard library:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/auth.py\nfrom flask_babel import gettext as _<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then wrap the user-facing strings. The registration view, with its bare literals replaced:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/auth.py\nerror = _(\"Password must be at least 6 characters long.\")\nflash(_(\"Account created. You can log in now.\"), \"success\")\nreturn render_template(\"auth\/register.html\", title=_(\"Create an account\"))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Page titles count, since they reach the <code>&lt;title&gt;<\/code> tag and the <code>&lt;h1&gt;<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When a message contains a value, keep the whole sentence inside the call and pass the value as a <strong>named<\/strong> placeholder:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/auth.py\nerror = _(\"The username \u201c%(username)s\u201d is already taken.\", username=username)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A translator can move <code>%(username)s<\/code> anywhere the sentence requires, and can tell what will be substituted. Positional <code>%s <\/code>gives them neither.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">In Jinja templates<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Flask-Babel installs the same functions into the Jinja environment, so templates call _() with no import:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todoapp\/templates\/index.html #}\n&lt;label for=\"name\" class=\"form-label\"&gt;{{ _('New list') }}&lt;\/label&gt;\n&lt;input id=\"name\" name=\"name\" class=\"form-control\"\n&nbsp; &nbsp; &nbsp; placeholder=\"{{ _('e.g. Groceries') }}\"&gt;\n&lt;button type=\"submit\" class=\"btn btn-primary\"&gt;{{ _('Create list') }}&lt;\/button&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the <code>placeholder<\/code>. Attribute text sits outside the tags and is easy to miss, but a user reads it\u2014and so do <code>title<\/code> and <code>aria-label<\/code>, including the ones your UI framework generates. TO-DO app\u2019s _(<code>\"Menu\"<\/code>) and _(<code>\"Close\"<\/code>) strings come from Bootstrap\u2019s navbar toggle and dismissible alerts:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todoapp\/templates\/list.html #}<br>&lt;button type=\"submit\" title=\"{{ _('Toggle done') }}\"&gt;<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">What to leave alone<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not every string is for a human. Leaving these unmarked keeps the catalog to what translators can work on:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Route and endpoint names<\/strong> \u2014 url_for(&#8216;todos.index&#8217;), blueprint names. A translated route breaks routing.<\/li>\n\n\n\n<li><strong>Form fields and query parameters<\/strong> \u2014 request.form.get(&#8220;username&#8221;), ?show=open. Part of the HTTP contract, not the interface.<\/li>\n\n\n\n<li><strong>Database identifiers<\/strong>, CSS classes, element ids and data attributes.<\/li>\n\n\n\n<li><strong>Config keys and locale codes<\/strong> \u2014 &#8220;en&#8221; and &#8220;fr&#8221; are identifiers that happen to look like words.<\/li>\n\n\n\n<li><strong>Log messages and developer-facing errors<\/strong>, unless you want them localized.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">One deliberate exception: TO-DO app marks its own name, so a translator <em>can<\/em> localize it. The French catalog leaves it as \u201cTO-DO app\u201d.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Plurals and ambiguous strings<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two kinds of string need more than _(). They\u2019re both part of marking strings for translation, so they need to be handled before extraction.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Counts with ngettext<\/h3>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"759\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-one-task-done-1024x759.png\" alt=\"\" class=\"wp-image-8192\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-one-task-done-1024x759.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-one-task-done-300x222.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-one-task-done-768x569.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-one-task-done-1536x1138.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08-one-task-done-2048x1517.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>\u201cOpen\u201d and \u201cDone\u201d appearing as a filter, a status badge, and an action<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A sentence mentioning a number changes shape with that number. <code>ngettext()<\/code> takes the singular, the plural and the count:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todoapp\/templates\/index.html #}<br>{{ ngettext('You have %(num)d list.', 'You have %(num)d lists.',<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; todo_lists|length) }}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In plain Python you substitute the value yourself with <code>% {\"num\": count}<\/code>. The version to avoid:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Don't do this.<br>if count == 1:<br>&nbsp; &nbsp; text = _(\"You have 1 list.\")<br>else:<br>&nbsp; &nbsp; text = _(\"You have %(num)d lists.\") % {\"num\": count}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That hard-codes English grammar into application logic. English has two plural forms, splitting at one; French and Polish have three; Arabic has six. ngettext() applies the <a href=\"https:\/\/cldr.unicode.org\/index\/cldr-spec\/plural-rules\" rel=\"nofollow\">plural rule<\/a> stored in the catalog for the active language, which Babel writes into the header when the catalog is created:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 &amp;&amp; n % 1000000 == 0 ? 1 : 2;\\n\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A pluralized entry has a <code>msgid_plural<\/code> and one <code>msgstr<\/code> per form:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>msgid \"You have %(num)d list.\"\nmsgid_plural \"You have %(num)d lists.\"\nmsgstr&#091;0] \"Vous avez %(num)d liste.\"\nmsgstr&#091;1] \"Vous avez %(num)d de listes.\"\nmsgstr&#091;2] \"Vous avez %(num)d listes.\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A language with six forms adds <code>msgstr[3]<\/code> and beyond. Your source doesn\u2019t change.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Context with pgettext<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The other problem is a word whose meaning depends on context. TO-DO app\u2019s list page uses \u201cOpen\u201d and \u201cDone\u201d in three roles: filters over the whole list, a badge showing one task\u2019s state, and the button that changes it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">English uses the same word for all three; many languages don&#8217;t. The filters describe a set, the badge one task, the button an instruction. <code>pgettext()<\/code> attaches a context, so the same text in a different context becomes a separate catalog entry:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/todos.py\nfilters = (\n&nbsp; &nbsp; (\"all\", pgettext(\"task filter\", \"All\"), len(all_items)),\n&nbsp; &nbsp; (\"open\", pgettext(\"task filter\", \"Open\"), open_count),\n&nbsp; &nbsp; (\"done\", pgettext(\"task filter\", \"Done\"), done_count),\n)\n\n{# todoapp\/templates\/list.html #}\n{{ pgettext('task status', 'Done') if item.done\n&nbsp; else pgettext('task status', 'Open') }}\n\n{{ _('Undo') if item.done else _('Done') }}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The button keeps a plain <code>_()<\/code>, because there is only one way to read it. \u201cDone\u201d now produces three entries, distinguished by <code>msgctxt<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>msgctxt \"task filter\"\nmsgid \"Done\"\nmsgstr \"Termin\u00e9es\"\n\nmsgctxt \"task status\"\nmsgid \"Done\"\nmsgstr \"Termin\u00e9e\"\n\nmsgid \"Done\"\nmsgstr \"Terminer\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three French words\u2014plural, singular, infinitive\u2014from one English one. Without the context a translator sees \u201cDone\u201d once, picks whichever case they imagined, and two of the three read wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Name contexts after the role, not the location: <code>\"task status\" <\/code>still makes sense after you move the badge.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Both at once with npgettext<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For a string needing a context <em>and<\/em> plural forms, <code>npgettext()<\/code> takes the context first:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todoapp\/templates\/list.html #}<br>{{ npgettext('task counter', '%(num)d task open',<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; '%(num)d tasks open', open_count) }}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Extracting the strings into a catalog<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Extraction scans your source files for marked strings and writes them to a <code>.pot<\/code> template. Since you\u2019ll run it whenever the source changes, it\u2019s worth configuring Babel once.&nbsp;<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tell Babel where to look<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>babel.cfg<\/code>, at the project root, says which files to scan and how to parse them:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># babel.cfg\n&#091;python: todoapp\/**.py]\n&#091;jinja2: todoapp\/templates\/**.html]\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two mappings, because the strings live in two kinds of file. The <code>jinja2<\/code> extractor finds <code>{{ _('\u2026') }}<\/code> calls a Python parser would skip\u2014miss that line and every template string goes untranslated, with no error to tell you.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Run the extraction<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>pybabel extract -F babel.cfg -k _l -o messages.pot \\<br>&nbsp; &nbsp; --project=\"TO-DO app\" --version=1.0 .<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>-F<\/code> points at the config, <code>-o<\/code> names the output, and the trailing <code>. <\/code>is the directory to scan. <code>--project<\/code> and <code>--version<\/code> land in the .pot header, which translation tools display.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>-k<\/code> _l adds a keyword. Babel looks for <code>_<\/code>, <code>gettext<\/code>, <code>ngettext<\/code>, <code>pgettext <\/code>and <code>npgettext<\/code> by default; <code>_l<\/code> picks up <code>lazy_gettext<\/code>, which you need for strings evaluated at import time, before a request exists and a locale has been chosen.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Read the .pot file<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The result is a header followed by one entry per string:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># messages.pot\n#: todoapp\/auth.py:28\nmsgid \"Please log in to continue.\"\nmsgstr \"\"\n\n#: todoapp\/todos.py:112\nmsgctxt \"task filter\"\nmsgid \"Done\"\nmsgstr \"\"\n\n#: todoapp\/templates\/index.html:23\n#, python-format\nmsgid \"You have %(num)d list.\"\nmsgid_plural \"You have %(num)d lists.\"\nmsgstr&#091;0] \"\"\nmsgstr&#091;1] \"\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>#:<\/code> comment records where each string came from, which is how you find it in the code when a translator asks what it refers to. Contexts arrive as <code>msgctxt<\/code>, plurals as <code>msgid_plural<\/code> with one empty <code>msgstr<\/code> per form, and <code>#<\/code>, <code>python-format<\/code> marks strings with placeholders so tools can check translations keep them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every <code>msgstr<\/code> is empty. A template is only the list of what needs translating, regenerated from source and never edited by hand.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Create the language catalogs<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The app doesn\u2019t read the <code>.pot<\/code>. Each language gets a <code>.po<\/code>, initialized from it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pybabel init -i messages.pot -d todoapp\/translations -l fr\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That writes <code>todoapp\/translations\/fr\/LC_MESSAGES\/messages.po<\/code> with the same entries and the French plural rule in its header.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Later, once you\u2019ve re-extracted, <code>update<\/code> merges the new template into catalogs that already hold translations:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pybabel update -i messages.pot -d todoapp\/translations<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It adds new entries, drops dead ones, and flags likely renames as fuzzy for a translator to check. Existing translations survive.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The source language needs no catalog of its own, since an unmatched lookup returns the <code>msgid<\/code>. TO-DO app ships one anyway, each translation a copy of its source string, so every language goes through the same code path.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Managing the translations in POEditor<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Editing <code>.po<\/code> files by hand works for one language and one person. Past that you want somewhere to see what\u2019s missing and let translators work without touching the repository.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">POEditor reads and writes standard Gettext files, so the app doesn\u2019t change. Log in and create a project for it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Add the languages<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A project needs a language before you can import anything. Add English as the source and French as the target\u2014both come from the same list:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"920\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/23-poeditor-add-languages-1024x920.png\" alt=\"\" class=\"wp-image-8193\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/23-poeditor-add-languages-1024x920.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/23-poeditor-add-languages-300x270.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/23-poeditor-add-languages-767x689.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/23-poeditor-add-languages.png 1505w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Adding English and French to the project<\/em><\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Import the .pot file<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Go to <a href=\"\/kb\/import-options\"><strong>Import<\/strong><\/a>, choose <code>messages.pot<\/code>, and pick a language\u2014the same page handles <code>.po<\/code> files that carry translations, so it asks even though a .<code>pot<\/code> has none. Select English.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"993\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/25-poeditor-import-terms-1024x993.png\" alt=\"\" class=\"wp-image-8194\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/25-poeditor-import-terms-1024x993.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/25-poeditor-import-terms-300x291.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/25-poeditor-import-terms-767x744.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/25-poeditor-import-terms-1536x1489.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/25-poeditor-import-terms-2048x1985.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Importing messages.pot on the Import Terms page<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">POEditor reports what it did: <em>File successfully processed. 67 terms found: 67 terms added; 0 translations found: 0 added, 0 updated in English.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Sixty-seven, not sixty-eight: the metadata entry at the top of the <code>.pot<\/code> isn\u2019t a term. \u201c0 translations\u201d is expected of a template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The terms list carries the <code>msgctxt<\/code> values from <code>pgettext()<\/code> across as a <strong>CONTEXT<\/strong> label:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"888\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/26-poeditor-import-result-1024x888.png\" alt=\"\" class=\"wp-image-8195\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/26-poeditor-import-result-1024x888.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/26-poeditor-import-result-300x260.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/26-poeditor-import-result-768x666.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/26-poeditor-import-result-1536x1332.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/26-poeditor-import-result-2048x1776.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The imported terms, with context labels on the ambiguous strings<\/em><\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Set a reference language<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Under <strong>Project settings \u2192 Edit Details<\/strong>, set <a href=\"\/kb\/setting-a-default-reference-language\"><strong>Default Reference Language<\/strong><\/a> to English, so translators see the source alongside the field they\u2019re filling in. Press <em>Save project details<\/em>\u2014changing the dropdown alone doesn\u2019t save it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"915\" height=\"1024\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27-poeditor-reference-language-915x1024.png\" alt=\"\" class=\"wp-image-8196\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27-poeditor-reference-language-915x1024.png 915w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27-poeditor-reference-language-268x300.png 268w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27-poeditor-reference-language-768x859.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27-poeditor-reference-language-1373x1536.png 1373w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27-poeditor-reference-language.png 1508w\" sizes=\"auto, (max-width: 915px) 100vw, 915px\" \/><figcaption class=\"wp-element-caption\"><em>Setting English as the Default Reference Language<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">There\u2019s a catch: importing a <code>.pot<\/code> creates terms but no translations, so English sits at 0% with nothing to show. Since the terms are actual text rather than labels or keys, you can use <strong>Copy Terms to Translations<\/strong> from the English language page. It fills every empty box with its corresponding term while leaving existing translations unchanged.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"510\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27b-poeditor-copy-terms-menu-1024x510.png\" alt=\"\" class=\"wp-image-8197\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27b-poeditor-copy-terms-menu-1024x510.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27b-poeditor-copy-terms-menu-300x149.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27b-poeditor-copy-terms-menu-767x382.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27b-poeditor-copy-terms-menu-1536x765.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27b-poeditor-copy-terms-menu-2048x1020.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Copy Terms to Translations in the English language\u2019s actions menu<\/em><\/figcaption><\/figure>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"407\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27c-poeditor-copy-terms-dialog-1024x407.png\" alt=\"\" class=\"wp-image-8198\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27c-poeditor-copy-terms-dialog-1024x407.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27c-poeditor-copy-terms-dialog-300x119.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27c-poeditor-copy-terms-dialog-768x305.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/27c-poeditor-copy-terms-dialog.png 1498w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Confirming the copy\u2014existing translations are not touched<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">It fills the singular of pluralized strings and leaves the plural forms empty, since it can\u2019t guess them. Type those in and English is complete.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Translate into French<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Open the French language and work down the list. Translations save as you go:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"691\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/28-poeditor-translating-french-1024x691.png\" alt=\"\" class=\"wp-image-8199\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/28-poeditor-translating-french-1024x691.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/28-poeditor-translating-french-300x203.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/28-poeditor-translating-french-767x518.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/28-poeditor-translating-french-1536x1037.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/28-poeditor-translating-french-2048x1382.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Translating the French catalog<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This is where the context and plural rules from earlier come into play. Strings marked with <code>pgettext()<\/code> show their context, so a translator sees that one \u201cDone\u201d is a filter and the other one task\u2019s state, and renders them <strong>Termin\u00e9es<\/strong> and <strong>Termin\u00e9e<\/strong>. Pluralized strings get a <a href=\"\/kb\/plural-forms\">tab per plural form<\/a>, using CLDR categories\u2014for French, <strong>ONE<\/strong>, <strong>MANY<\/strong> and <strong>OTHER<\/strong>:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"407\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/29-poeditor-plural-forms-1024x407.png\" alt=\"\" class=\"wp-image-8200\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/29-poeditor-plural-forms-1024x407.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/29-poeditor-plural-forms-300x119.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/29-poeditor-plural-forms-768x305.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/29-poeditor-plural-forms-1536x610.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/29-poeditor-plural-forms-2048x814.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Plural form tabs and context labels on the French translation page<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">POEditor\u2019s <a href=\"\/kb\/qa-checks\">QA Checks<\/a> also cover placeholders, including Python\u2019s <code>%(name)s<\/code> style, and flag a translation that drops the <code>%(num)d <\/code>as soon as it\u2019s saved.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"566\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1024x566.png\" alt=\"\" class=\"wp-image-8201\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1024x566.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-300x166.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-767x424.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1536x850.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-2048x1133.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>English and French both complete<\/em><\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Export the catalog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Go to <strong><a href=\"\/kb\/export-options\">Export<\/a><\/strong>, choose <strong>Gettext PO (.po)<\/strong> and download:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"679\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/30-poeditor-export-1024x679.png\" alt=\"\" class=\"wp-image-8202\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/30-poeditor-export-1024x679.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/30-poeditor-export-300x199.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/30-poeditor-export-768x509.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/30-poeditor-export-1536x1019.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/30-poeditor-export-2048x1358.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Exporting the French catalog as Gettext PO<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Quick tip:<\/strong> the format list also offers <strong>Gettext MO (.mo)<\/strong>, the compiled catalog, which lets you skip the <code>pybabel compile<\/code> step below. Keep the Babel step if compiling is already part of a build or CI process.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Loading the translations at runtime<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Three things have to happen for a visitor to see the translated catalog: it needs compiling, the app needs to pick a locale per request, and the visitor needs a way to change it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Compile the catalog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Save the exported file where gettext expects it\u2014locale directory, locale code, <code>LC_MESSAGES<\/code>, domain name:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>todoapp\/translations\/fr\/LC_MESSAGES\/messages.po<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then compile:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>pybabel compile -d todoapp\/translations<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That writes <code>messages.mo<\/code> beside each <code>messages.po<\/code>. gettext reads the <code>.mo<\/code>, so editing a <code>.po<\/code> does nothing until you recompile\u2014and catalogs load once, so restart the app.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Select a locale for each request<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Which locales the app offers, and where the catalogs live, are configuration:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/config.py\nLANGUAGES = {\"en\": \"English\", \"fr\": \"Fran\u00e7ais\"}\nBABEL_DEFAULT_LOCALE = \"en\"\nBABEL_TRANSLATION_DIRECTORIES = \"translations\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A web app can\u2019t pick a language at startup, since different visitors want different ones. Flask-Babel calls a selector on every request:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/__init__.py<br>def select_locale():<br>&nbsp; &nbsp; chosen = session.get(\"locale\")<br>&nbsp; &nbsp; if chosen in Config.LANGUAGES:<br>&nbsp; &nbsp; &nbsp; &nbsp; return chosen<br>&nbsp; &nbsp; return request.accept_languages.best_match(list(Config.LANGUAGES)) or \\<br>&nbsp; &nbsp; &nbsp; &nbsp; Config.BABEL_DEFAULT_LOCALE<br><br>babel.init_app(app, locale_selector=select_locale)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three sources in priority order: the visitor\u2019s choice, the Accept-Language header, then the default. The membership test keeps an edited session value or a made-up URL from pushing an unsupported locale into gettext.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Let the visitor choose<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A route records the choice and sends them back where they were:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/__init__.py<br>@app.route(\"\/lang\/&lt;locale&gt;\")<br>def set_language(locale):<br>&nbsp; &nbsp; if locale in app.config&#091;\"LANGUAGES\"]:<br>&nbsp; &nbsp; &nbsp; &nbsp; session&#091;\"locale\"] = locale<br>&nbsp; &nbsp; return redirect(request.referrer or url_for(\"todos.index\"))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">with a link per locale in the header, labelled from <code>LANGUAGES<\/code>. Write each label in its own language: \u201cFran\u00e7ais\u201d rather than \u201cFrench\u201d. People scanning for their own language look for it the way they write it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Restart the app and switch to French:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"566\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1-1024x566.png\" alt=\"\" class=\"wp-image-8203\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1-1024x566.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1-300x166.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1-767x424.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1-1536x850.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/31-poeditor-both-complete-1-2048x1133.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The list page in French, rendered from the catalog exported out of POEditor<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Every string now comes from the compiled catalog: the plural counter, the badges and filters with their contexts, the buttons, the flash messages. Not one source string changed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">When a translation doesn\u2019t appear<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing raises when a lookup fails\u2014gettext returns the source string, so the symptom is English where you expected French:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The `.mo` is in the wrong place.<\/strong> It must be &lt;translations&gt;\/&lt;locale&gt;\/LC_MESSAGES\/&lt;domain&gt;.mo; a missing LC_MESSAGES is the usual culprit.<\/li>\n\n\n\n<li><strong>The filename doesn\u2019t match the domain.<\/strong> Domain messages means messages.mo.<\/li>\n\n\n\n<li><strong>The locale isn\u2019t in `LANGUAGES`<\/strong>, so the selector never returns it.<\/li>\n\n\n\n<li><strong>The string isn\u2019t marked<\/strong>, so it never reached the .pot.<\/li>\n\n\n\n<li><strong>The `.po` wasn\u2019t recompiled<\/strong>, or the app wasn\u2019t restarted.<\/li>\n\n\n\n<li><strong>The entry is marked fuzzy.<\/strong> pybabel compile skips fuzzy entries, and pybabel update adds that flag to strings it thinks changed. A translation that\u2019s visibly in the .po and still renders in English is usually this.<\/li>\n\n\n\n<li><strong>Only the plural counts are wrong.<\/strong> Check the catalog\u2019s Plural-Forms header: it needs nplurals=N; plural=EXPRESSION;. Babel falls back to the English rule if it can\u2019t parse that, which looks right until a count where your language disagrees.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Formatting dates and numbers<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Translating words is only part of the job. A French visitor reading <code>Aug 27, 2026, 9:53 AM<\/code> is reading English conventions in French, and gettext can\u2019t help: that string isn\u2019t in a catalog, it\u2019s generated from a <code>datetime<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is Babel\u2019s other half. It ships the CLDR locale data, so it knows how each locale writes dates, times and numbers, with no translations needed. TO-DO app puts the formatters in the Jinja environment:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># todoapp\/__init__.py<br>app.jinja_env.globals.update(<br>&nbsp; &nbsp; format_datetime=format_datetime, format_date=format_date,<br>&nbsp; &nbsp; format_number=format_number, format_decimal=format_decimal,<br>)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Format the value, then pass the result into a translated sentence as a named placeholder:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# todoapp\/templates\/index.html #}\n{{ _('%(count)s in total', count=format_number(todo_list.total_items)) }}\n{{ _('created %(when)s',\n&nbsp; &nbsp; when=format_datetime(todo_list.created_at_dt, 'medium')) }}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Concatenating a translated fragment with a formatted value would take word order away from the translator. Keeping the sentence in one <code>_()<\/code> call lets them put the date where their language wants it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Dates take a length\u2014<code>'short'<\/code>, <code>'medium'<\/code>,<code> 'long'<\/code> or <code>'full'<\/code>\u2014and each locale decides what those mean. You choose how much detail to show, not an order of day, month and year that suits one language:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>en&nbsp; &nbsp; Aug 27, 2026, 9:53 AM&nbsp; &nbsp; &nbsp; 4 in total\nfr&nbsp; &nbsp; 27 ao\u00fbt 2026, 09:54&nbsp; &nbsp; &nbsp; &nbsp; 4 au total<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Month name, component order and the 12- versus 24-hour clock all change, from the same <code>datetime<\/code> and the same template. This is why selecting French changed the dates earlier, before a single string had been translated.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Keeping the catalogs in sync<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Setup happens once. What repeats is a developer adding a string and the catalogs falling behind the code:<br><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>edit code&nbsp; -&gt;&nbsp; pybabel extract&nbsp; -&gt;&nbsp; pybabel update&nbsp; -&gt;&nbsp; POEditor<br>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |<br>&nbsp; &nbsp; app&nbsp; &lt;-&nbsp; pybabel compile &nbsp; &lt;-&nbsp; export .po &nbsp; &lt;-&nbsp; translate<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing detects a missed step. A string added without re-extracting is never translated in any language, and looks fine in development because English falls back to the source.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two things help. Commit the <code>.po<\/code> files alongside the code that produced them, so a translation and its string move through review together. And run pybabel extract in CI, failing the build if <code>messages.pot<\/code> comes back different\u2014that turns a forgotten<code> _()<\/code> into a red build instead of a bug report from a French-speaking user.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Automating the import and export<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Once the manual import and export process becomes repetitive, the <a href=\"\/docs\/api)\">POEditor API<\/a> can handle both steps. Uploading a freshly extracted template:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST https:\/\/api.poeditor.com\/v2\/projects\/upload \\\n&nbsp; -F api_token=\"YOUR_API_TOKEN\" \\\n&nbsp; -F id=\"YOUR_PROJECT_ID\" \\\n&nbsp; -F updating=\"terms\" \\\n&nbsp; -F file=@\"messages.pot\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>updating=\"terms\"<\/code> syncs the term list without touching translations. Pulling a finished language back:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST https:\/\/api.poeditor.com\/v2\/projects\/export \\\n&nbsp; -d api_token=\"YOUR_API_TOKEN\" \\\n&nbsp; -d id=\"YOUR_PROJECT_ID\" \\\n&nbsp; -d language=\"fr\" \\\n&nbsp; -d type=\"po\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That returns a temporary URL. Download it to the catalog path, run <code>pybabel compile<\/code>, and the round trip is two scripts your release process can call.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Connecting it to your repository<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">POEditor also <a href=\"\/kb\/localization-file-management-with-github-bitbucket-and-gitlab-integrations\">integrates with<\/a> GitHub, GitLab, Bitbucket and Azure DevOps, which removes the scripts: push a regenerated <code>messages.pot<\/code> and the new terms appear; when French reaches 100%, the updated <code>messages.po<\/code> is pushed back. There\u2019s an <a href=\"\/kb\/how-to-set-up-and-use-the-poeditor-mcp-server\">MCP server<\/a> too, which exports <code>.po<\/code>, <code>.pot<\/code> and <code>.mo<\/code> for MCP-compatible AI assistants.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The project structure stays the same. Automation only removes the manual file handling in the middle.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common Python gettext mistakes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three common mistakes account for most bugs in a multilingual Python application. None of them raise an exception, so you have to recognize them by sight.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Leaving strings unmarked<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>An unwrapped string is never extracted, never translated, and never reported:\n\n{# Missed. #}\n&lt;button type=\"submit\"&gt;Add&lt;\/button&gt;\n\n{# Marked. #}\n&lt;button type=\"submit\"&gt;{{ _('Add') }}&lt;\/button&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Template attributes are where this happens most: <code>placeholder<\/code>, <code>title<\/code>, <code>aria- label<\/code>. The reliable check isn\u2019t reading code, it\u2019s reading the app in a language you have translated\u2014anything still in English is unmarked or uncompiled.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Building sentences from fragments<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You might be tempted to assemble the sentence from parts:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Don't do this.<br>message = _(\"You have\") + \" \" + str(count) + \" \" + _(\"lists\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The translator gets three pieces and no way to reorder them, and word order, agreement and punctuation all differ between languages. Translate whole sentences and pass the values in with <code>ngettext()<\/code>. The same rule covers a translated verb plus a translated noun, and sentences split across two template lines: if it reads as one sentence to the user, it should be one catalog entry.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Letting placeholders drift<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Placeholders are code inside a translated string, and a translator in a text editor can change them:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>msgid \"Welcome back, %(username)s!\"\nmsgstr \"Bon retour, %(nom)s !\"&nbsp; &nbsp; # raises at runtime<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The name must match the keyword argument the app passes, so a renamed placeholder is a crash in one language only, on a page nobody on the team reads. A translation platform that validates placeholders can catch this before the file reaches the application.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The full cycle now runs on a real app: strings marked with <code>_()<\/code>, <code>ngettext()<\/code>, <code>pgettext()<\/code> and <code>npgettext()<\/code>; a <code>.pot<\/code> generated by Babel; a French catalog translated in POEditor; a compiled <code>.mo<\/code> that gettext reads; and a locale picked per request, with a switcher for visitors who want to override it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing in the source changed along the way. The English strings are still in the code exactly as written. Adding German means adding a language in POEditor, translating, exporting <code>de\/LC_MESSAGES\/messages.po<\/code> and compiling\u2014no Python to edit, no templates to touch. The work scales with languages, not with the size of the codebase.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To localize a Python app of your own: mark a handful of user-facing strings with <code>_()<\/code>, add a <code>babel.cfg<\/code> and run <code>pybabel extract<\/code>, import the <code>.pot<\/code> into a POEditor project, translate a few strings, then export the .po, compile it and load the locale. That\u2019s an afternoon\u2019s work and it exercises every step in this guide\u2014a project this size fits well inside <a href=\"\/pricing\/\">POEditor\u2019s free plan<\/a>, which allows up to 1,000 strings.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once one language works, the rest is repetition, and repetition is the part worth automating.<\/p>\n\n\n<div class=\"call-action my-4 d-flex justify-content-between align-items-md-center gap-4 flex-column flex-lg-row\"><div><h3 class=\"fs-4\">Ready to power up localization?<\/h3><span class=\"fs-6\">Subscribe to the POEditor platform today!<\/span><\/div><a class=\"btn btn-b-primary d-flex align-items-center justify-content-center px-4 py-3 flex-shrink-0\" \n\t\t\t\t\thref=\"https:\/\/poeditor.com\/pricing\/?utm_source=blog&#038;utm_medium=btn&#038;utm_campaign=cta_pricing\">See pricing<\/a><\/div>\n","protected":false},"excerpt":{"rendered":"<p>Python has had a localization module in its standard library for a long time. gettext uses the GNU gettext file formats, it has no dependencies, and it behaves the same in a Django site, a Flask app, or a command-line script. Python gettext localization involves four main steps: marking the strings you want translated, extracting [&hellip;]<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-8136","post","type-post","status-publish","format-standard","hentry","category-tutorials"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.4 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Python gettext localization: How to translate a Python app - POEditor Blog<\/title>\n<meta name=\"description\" content=\"Learn how to localize Python applications with gettext, from marking translatable strings to creating, compiling, and loading PO and MO files.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/poeditor.com\/blog\/python-gettext-localization\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Python gettext localization: How to translate a Python app - POEditor Blog\" \/>\n<meta property=\"og:description\" content=\"Learn how to localize Python applications with gettext, from marking translatable strings to creating, compiling, and loading PO and MO files.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/poeditor.com\/blog\/python-gettext-localization\/\" \/>\n<meta property=\"og:site_name\" content=\"POEditor Blog\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/POEditor\" \/>\n<meta property=\"article:published_time\" content=\"2026-09-03T06:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-09-03T08:00:35+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1600\" \/>\n\t<meta property=\"og:image:height\" content=\"1281\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"POEditor\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:creator\" content=\"@poeditor\" \/>\n<meta name=\"twitter:site\" content=\"@poeditor\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"POEditor\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"18 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/\"},\"author\":{\"name\":\"POEditor\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\"},\"headline\":\"Python gettext localization: How to translate a Python app\",\"datePublished\":\"2026-09-03T06:00:00+00:00\",\"dateModified\":\"2026-09-03T08:00:35+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/\"},\"wordCount\":3478,\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/Python-gettext-localization-1024x820.png\",\"articleSection\":[\"Tutorials\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/\",\"name\":\"Python gettext localization: How to translate a Python app - POEditor Blog\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/Python-gettext-localization-1024x820.png\",\"datePublished\":\"2026-09-03T06:00:00+00:00\",\"dateModified\":\"2026-09-03T08:00:35+00:00\",\"description\":\"Learn how to localize Python applications with gettext, from marking translatable strings to creating, compiling, and loading PO and MO files.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/#primaryimage\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/Python-gettext-localization.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/Python-gettext-localization.png\",\"width\":1600,\"height\":1281,\"caption\":\"Python gettext localization\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/python-gettext-localization\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Python gettext localization: How to translate a Python app\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"name\":\"POEditor Blog\",\"description\":\"All about translation and localization management\",\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\",\"name\":\"POEditor\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"width\":512,\"height\":512,\"caption\":\"POEditor\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/POEditor\",\"https:\\\/\\\/x.com\\\/poeditor\",\"https:\\\/\\\/www.linkedin.com\\\/company\\\/poeditor\\\/\",\"https:\\\/\\\/www.youtube.com\\\/channel\\\/UCXAk1u8N49VRMAqNneENCFA\"]},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\",\"name\":\"POEditor\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"caption\":\"POEditor\"},\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/author\\\/poeditor\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Python gettext localization: How to translate a Python app - POEditor Blog","description":"Learn how to localize Python applications with gettext, from marking translatable strings to creating, compiling, and loading PO and MO files.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/","og_locale":"en_US","og_type":"article","og_title":"Python gettext localization: How to translate a Python app - POEditor Blog","og_description":"Learn how to localize Python applications with gettext, from marking translatable strings to creating, compiling, and loading PO and MO files.","og_url":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/","og_site_name":"POEditor Blog","article_publisher":"https:\/\/www.facebook.com\/POEditor","article_published_time":"2026-09-03T06:00:00+00:00","article_modified_time":"2026-09-03T08:00:35+00:00","og_image":[{"width":1600,"height":1281,"url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization.png","type":"image\/png"}],"author":"POEditor","twitter_card":"summary_large_image","twitter_creator":"@poeditor","twitter_site":"@poeditor","twitter_misc":{"Written by":"POEditor","Est. reading time":"18 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/#article","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/"},"author":{"name":"POEditor","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17"},"headline":"Python gettext localization: How to translate a Python app","datePublished":"2026-09-03T06:00:00+00:00","dateModified":"2026-09-03T08:00:35+00:00","mainEntityOfPage":{"@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/"},"wordCount":3478,"publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"image":{"@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization-1024x820.png","articleSection":["Tutorials"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/","url":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/","name":"Python gettext localization: How to translate a Python app - POEditor Blog","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/#primaryimage"},"image":{"@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization-1024x820.png","datePublished":"2026-09-03T06:00:00+00:00","dateModified":"2026-09-03T08:00:35+00:00","description":"Learn how to localize Python applications with gettext, from marking translatable strings to creating, compiling, and loading PO and MO files.","breadcrumb":{"@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/poeditor.com\/blog\/python-gettext-localization\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/#primaryimage","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/Python-gettext-localization.png","width":1600,"height":1281,"caption":"Python gettext localization"},{"@type":"BreadcrumbList","@id":"https:\/\/poeditor.com\/blog\/python-gettext-localization\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/poeditor.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Python gettext localization: How to translate a Python app"}]},{"@type":"WebSite","@id":"https:\/\/poeditor.com\/blog\/#website","url":"https:\/\/poeditor.com\/blog\/","name":"POEditor Blog","description":"All about translation and localization management","publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/poeditor.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/poeditor.com\/blog\/#organization","name":"POEditor","url":"https:\/\/poeditor.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","width":512,"height":512,"caption":"POEditor"},"image":{"@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/POEditor","https:\/\/x.com\/poeditor","https:\/\/www.linkedin.com\/company\/poeditor\/","https:\/\/www.youtube.com\/channel\/UCXAk1u8N49VRMAqNneENCFA"]},{"@type":"Person","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17","name":"POEditor","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","caption":"POEditor"},"url":"https:\/\/poeditor.com\/blog\/author\/poeditor\/"}]}},"_links":{"self":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8136","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/comments?post=8136"}],"version-history":[{"count":15,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8136\/revisions"}],"predecessor-version":[{"id":8204,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8136\/revisions\/8204"}],"wp:attachment":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/media?parent=8136"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/categories?post=8136"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/tags?post=8136"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}