<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marco Montanari</title>
    <description>The latest articles on DEV Community by Marco Montanari (@sirmmo).</description>
    <link>https://dev.to/sirmmo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F695621%2F9d15ef5a-91f6-449f-a579-00314ac8a6bf.jpeg</url>
      <title>DEV Community: Marco Montanari</title>
      <link>https://dev.to/sirmmo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sirmmo"/>
    <language>en</language>
    <item>
      <title>pg_i18n: translatable columns in PostgreSQL without rewriting your app</title>
      <dc:creator>Marco Montanari</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:53:43 +0000</pubDate>
      <link>https://dev.to/sirmmo/pgi18n-translatable-columns-in-postgresql-without-rewriting-your-app-b51</link>
      <guid>https://dev.to/sirmmo/pgi18n-translatable-columns-in-postgresql-without-rewriting-your-app-b51</guid>
      <description>&lt;p&gt;Every project I have worked on eventually needed a second language for some text stored in the database. Product names, category labels, descriptions, menu entries. And every time, the same thing happened: the schema was already there, the API was already reading and writing those columns as plain strings, and nobody wanted to touch either.&lt;/p&gt;

&lt;p&gt;So the pragmatic hack appeared: someone started writing JSON into the text column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-----------------------------------&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Chair&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chair"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"it"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sedia"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Half the rows plain strings, half of them JSON objects, and the application still treating everything as a string. It works until someone asks for a proper list of Italian product names.&lt;/p&gt;

&lt;p&gt;I wrote &lt;a href="https://github.com/sirmmo/pg_i18n" rel="noopener noreferrer"&gt;pg_i18n&lt;/a&gt; to make this state of affairs a feature instead of a bug. It is pure SQL and PL/pgSQL, no compiled code, and it installs either as a PostgreSQL extension or as a script you load with &lt;code&gt;psql&lt;/code&gt;. It works on PostgreSQL 9.5 and up, tested on 14, 16 and 17.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading and writing one language
&lt;/h2&gt;

&lt;p&gt;The core is two functions that understand both shapes of the column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 'Chair'                       -&amp;gt; Chair     (plain string, returned as-is)&lt;/span&gt;
&lt;span class="c1"&gt;-- {"en":"Chair","it":"Sedia"}   -&amp;gt; Sedia&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i18n_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Sedia rossa'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 'Chair' -&amp;gt; {"en": "Chair", "it": "Sedia rossa"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plain string is taken to be in the default language, so writing Italian to it promotes it to a JSON object instead of losing the English. Reading falls back as far as you want: requested language, then default language, then whatever exists. Or not at all, if you set &lt;code&gt;i18n.fallback = 'none'&lt;/code&gt; and want a NULL or an empty string for a language that is not there.&lt;/p&gt;

&lt;p&gt;Everything exists for both &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;jsonb&lt;/code&gt; columns, and the explicit forms are &lt;code&gt;IMMUTABLE&lt;/code&gt;, so they go into expression indexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i18n_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i18n_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%sedia%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part I actually needed: the app does not change
&lt;/h2&gt;

&lt;p&gt;The API reads and writes plain strings and it is not going to be rewritten this quarter. Fine. Rename the table and put a view in its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;products_i18n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_wrap_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{name,description}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'products'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The view exposes &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; as plain strings in the session language. &lt;code&gt;INSTEAD OF&lt;/code&gt; triggers write back into the JSON, touching only the current language. The application keeps running its old queries against &lt;code&gt;products&lt;/code&gt;. The only thing it needs is the language, and that can be set per connection, per transaction, or once per database role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;api_it&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'it'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inserts store &lt;code&gt;{"it": "..."}&lt;/code&gt;, updates change only Italian and leave the other languages alone, &lt;code&gt;RETURNING&lt;/code&gt; gives back the translated row, defaults on omitted columns still apply. I was surprised how little the application notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up: migrating to jsonb
&lt;/h2&gt;

&lt;p&gt;Once every writer goes through the functions or the view, the text columns can become real &lt;code&gt;jsonb&lt;/code&gt; with a constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;i18n_migration_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;--  col_type | total | nulls | plain | translated | other_json&lt;/span&gt;
&lt;span class="c1"&gt;--  text     | 12040 |    15 |  9871 |       2154 |          0&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_migrate_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{name,description}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain strings become &lt;code&gt;{"en": "..."}&lt;/code&gt;, the column type changes, and a &lt;code&gt;CHECK&lt;/code&gt; makes sure only translation objects get in from now on. The same queries keep working because PostgreSQL picks the &lt;code&gt;jsonb&lt;/code&gt; overloads by column type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filling the gaps automatically
&lt;/h2&gt;

&lt;p&gt;This is where it got fun. With the languages in the row, "which rows are missing German" is a cheap question, and answering it with a machine translation is a good first draft for product names and labels.&lt;/p&gt;

&lt;p&gt;PostgreSQL cannot call HTTP APIs portably, so the split is: the database detects rows missing a configured language and puts them on a queue table, a small Python worker outside the database calls the provider and writes back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_auto_enable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{en,it,de}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deepl'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'furniture product names, keep brand names untranslated'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i18n_backfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products_i18n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PG_I18N_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deepl &lt;span class="nv"&gt;DEEPL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... ./pg_i18n_worker.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Providers are DeepL, Google Cloud Translation, and OpenRouter, which means any LLM with a prompt that includes the per-column hint above. The rules that keep it safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;only missing languages are requested, and only what is still missing at write-back time is written. A human translation entered while a job is in flight wins;&lt;/li&gt;
&lt;li&gt;a changed source text does not retranslate existing languages;&lt;/li&gt;
&lt;li&gt;claims use &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, so you can run several workers;&lt;/li&gt;
&lt;li&gt;two views, &lt;code&gt;i18n_coverage&lt;/code&gt; and &lt;code&gt;i18n_missing_translations&lt;/code&gt;, show what is done and what is still open.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Detecting the language of what was inserted
&lt;/h3&gt;

&lt;p&gt;One more real-world problem: the API knows nothing about languages, so a plain string is assumed to be English, and someone in an Italian session pastes an English text that lands under &lt;code&gt;it&lt;/code&gt;. With detection enabled on a column, the worker asks the provider what language the text actually is. If it disagrees with the key the text was stored under, the text moves to the right key and the other languages are filled from it. A language outside your configured set stays under its own key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inserted, {en,it} configured:  'Bonjour'
after the worker:              {"en": "Hello", "fr": "Bonjour", "it": "Ciao"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where it fits, and where it does not
&lt;/h2&gt;

&lt;p&gt;It fits when translations belong inside the row and the application is not going to change: legacy databases with mixed content, string-only APIs, catalogues, CMS labels, multi-tenant apps where each role has its own language.&lt;/p&gt;

&lt;p&gt;It does not fit long documents that need per-language versioning and an approval workflow. There a translations table or a translation management system is the better tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sirmmo/pg_i18n
&lt;span class="nb"&gt;cd &lt;/span&gt;pg_i18n &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="nb"&gt;install
&lt;/span&gt;psql &lt;span class="nt"&gt;-d&lt;/span&gt; mydb &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'CREATE EXTENSION pg_i18n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or &lt;code&gt;psql -d mydb -f i18n.sql -f i18n_auto.sql&lt;/code&gt; on a managed database where you cannot install extensions. The test suite runs against throwaway Docker containers, including an end-to-end run of the worker with an offline provider.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://ingmmo.com/pg_i18n/" rel="noopener noreferrer"&gt;https://ingmmo.com/pg_i18n/&lt;/a&gt; · Source: &lt;a href="https://github.com/sirmmo/pg_i18n" rel="noopener noreferrer"&gt;https://github.com/sirmmo/pg_i18n&lt;/a&gt; · MIT.&lt;/p&gt;

&lt;p&gt;I would like to hear how other people have handled the "JSON in a text column" situation, and which provider you would want next.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>i18n</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
