<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>sade.dev — PostgreSQL in Production</title><description>Keeping a single PostgreSQL cluster healthy in production: connection pooling, backups, read/write splitting, zero-downtime migrations and restore testing.</description><link>https://sade.dev/en/series/postgresql-in-production/</link><language>en-US</language><atom:link href="https://sade.dev/en/series/postgresql-in-production/rss.xml" rel="self" type="application/rss+xml"/><item><title>Multiple DB Users with pgBouncer auth_query</title><link>https://sade.dev/en/notes/pgbouncer-auth-query/</link><guid isPermaLink="true">https://sade.dev/en/notes/pgbouncer-auth-query/</guid><description>A practical setup for dynamic user authentication via `auth_query` instead of `userlist.txt` when running pgBouncer</description><pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;pgBouncer&lt;/code&gt; is excellent as a connection pooler, but its default configuration comes with one hostility: you have to write your user list into &lt;code&gt;userlist.txt&lt;/code&gt; by hand. Every time you add a new DB user, you update the file, compute the hash, reload pgBouncer. An operational headache.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;auth_query&lt;/code&gt; solves this: pgBouncer queries &lt;strong&gt;PostgreSQL itself&lt;/strong&gt; for authentication.&lt;/p&gt;
&lt;h2 id=&quot;setting-up-auth_user&quot;&gt;Setting up auth_user&lt;/h2&gt;
&lt;p&gt;On the PostgreSQL side we create a low-privilege user and a &lt;code&gt;pg_shadow&lt;/code&gt; lookup function:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; ROLE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; pgbouncer &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;LOGIN&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; PASSWORD&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;s3cr€t_p@ssw0rd&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; SCHEMA&lt;/span&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt; pgbouncer&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;GRANT&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; USAGE &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; SCHEMA&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; pgbouncer &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;TO&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; pgbouncer;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;CREATE OR REPLACE&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; FUNCTION&lt;/span&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt; pgbouncer&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;.user_lookup(&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; i_username &lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;                                                 out&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; uname &lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;                                                 out&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; phash &lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;RETURNS&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; record &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;AS&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; $$&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;BEGIN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;    SELECT&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; usename, passwd &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; pg_catalog&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt;pg_shadow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;    WHERE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; usename &lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; i_username &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;INTO&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; uname, phash;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;    RETURN&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;END&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;$$ &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;LANGUAGE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; plpgsql &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;SECURITY&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; DEFINER;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;REVOKE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; ALL &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; FUNCTION&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; pgbouncer&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt;user_lookup&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; PUBLIC;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;GRANT&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; EXECUTE&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; ON&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; FUNCTION&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; pgbouncer&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt;user_lookup&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;TO&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; pgbouncer;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SECURITY DEFINER&lt;/code&gt; matters — the function uses the privileges of the user who defined it to access &lt;code&gt;pg_shadow&lt;/code&gt;. Otherwise the &lt;code&gt;pgbouncer&lt;/code&gt; role can’t read &lt;code&gt;pg_shadow&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;auth_query&lt;/code&gt; runs inside the target database, so the function has to be installed into every database pgBouncer serves.&lt;/p&gt;
&lt;h2 id=&quot;pgbouncer-config&quot;&gt;pgBouncer config&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pgbouncer.ini&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;ini&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;databases&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;* = &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;host&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;127.0.0.1 &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;port&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;5432&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;pgbouncer&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;listen_addr&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; 127.0.0.1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;listen_port&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; 6432&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;auth_type&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; scram-sha-256&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;auth_user&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; pgbouncer&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;auth_query&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; SELECT uname, phash FROM pgbouncer.user_lookup($1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;pool_mode&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; transaction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;max_client_conn&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; 1000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;default_pool_size&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; 25&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No more &lt;code&gt;userlist.txt&lt;/code&gt;. Create a new role in PostgreSQL and authentication works without requiring a pgBouncer restart.&lt;/p&gt;
&lt;h2 id=&quot;md5-or-scram-sha-256&quot;&gt;md5 or scram-sha-256?&lt;/h2&gt;
&lt;p&gt;PostgreSQL 14+ uses &lt;code&gt;scram-sha-256&lt;/code&gt; by default. pgBouncer has supported it since 1.11, and SCRAM pass-through with &lt;code&gt;auth_query&lt;/code&gt; since 1.14. If you’re forced to fall back to md5 on older clusters:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;ini&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;auth_type&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; md5&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But if you’re standing up a new install, use &lt;code&gt;scram-sha-256&lt;/code&gt; — md5 is weak now.&lt;/p&gt;
&lt;h2 id=&quot;which-pool-mode-should-you-choose&quot;&gt;Which pool mode should you choose?&lt;/h2&gt;
&lt;p&gt;There are three modes; for most Laravel/Django apps the choices are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;session&lt;/strong&gt; — the connection stays the same from start to finish. Prepared statements work, but the pooling benefit is weak.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;transaction&lt;/strong&gt; — the connection is released at the end of each transaction. The most common choice. Protocol-level prepared statements need 1.21+; since 1.24 they are enabled by default (&lt;code&gt;max_prepared_statements = 200&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;statement&lt;/strong&gt; — released at the end of each query. Problematic with most ORMs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Practical: start with &lt;strong&gt;transaction&lt;/strong&gt;; switch to session if you run into trouble.&lt;/p&gt;
&lt;h2 id=&quot;common-mistake-the-read-only-role-has-no-password&quot;&gt;Common mistake: the read-only role has no password&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;auth_query&lt;/code&gt; reads &lt;code&gt;pg_shadow.passwd&lt;/code&gt;. If the user comes in via SSO or uses peer authentication, the passwd field is NULL — pgBouncer rejects the login. In that case you’d need to set up trust or peer through &lt;code&gt;pg_hba.conf&lt;/code&gt;, but you don’t want that in production.&lt;/p&gt;
&lt;h2 id=&quot;monitoring&quot;&gt;Monitoring&lt;/h2&gt;
&lt;p&gt;pgBouncer provides its own admin DB:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;psql -p 6432 pgbouncer -U pgbouncer&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&amp;gt; SHOW POOLS;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&amp;gt; SHOW STATS;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&amp;gt; SHOW CLIENTS;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you see &lt;code&gt;cl_waiting &amp;gt; 0&lt;/code&gt;, you may need to raise &lt;code&gt;default_pool_size&lt;/code&gt;.
If &lt;code&gt;sv_idle&lt;/code&gt; is very high, it’s the opposite — the pool is overprovisioned.&lt;/p&gt;</content:encoded><category>postgresql</category><category>pgbouncer</category><category>production</category><category>Note</category><author>info@muhammetsafak.com.tr (Muhammet Şafak)</author></item><item><title>A PostgreSQL Backup Strategy with pgBackRest</title><link>https://sade.dev/en/notes/pgbackrest-postgresql-backup-strategy/</link><guid isPermaLink="true">https://sade.dev/en/notes/pgbackrest-postgresql-backup-strategy/</guid><description>pg_dump alone is not a backup. A PITR-capable, compressed, verifiable backup architecture with pgBackRest.</description><pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The day I stopped wrestling with &lt;code&gt;pg_dump&lt;/code&gt; is the day I think I got more serious as a systems engineer. &lt;code&gt;pg_dump&lt;/code&gt; is an investigation tool — not a production backup tool.&lt;/p&gt;
&lt;p&gt;A production backup has to deliver three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Point-in-time recovery (PITR).&lt;/strong&gt; Being able to say “it was deleted at 02:13 last night.”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integrity verification.&lt;/strong&gt; Testing that the backup file actually works when you open it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retention governed by policy.&lt;/strong&gt; 6-hourly/daily/weekly rotation.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;pg_dump&lt;/code&gt; covers none of the three.&lt;/p&gt;
&lt;h2 id=&quot;pgbackrest-setup&quot;&gt;pgBackRest setup&lt;/h2&gt;
&lt;p&gt;I prefer — and recommend — taking both a local and an S3 backup. Local backups are fast; S3 backups are fire-resistant.&lt;/p&gt;
&lt;h3 id=&quot;off-site-copy&quot;&gt;Off-site copy&lt;/h3&gt;
&lt;p&gt;If the repository lives on a single disk, one VPS fire wipes out everything. pgBackRest can write to an S3 or S3-compatible (R2, MinIO) bucket:&lt;/p&gt;
&lt;p&gt;Preferably, protect the bucket with a policy that locks versions and &lt;strong&gt;forbids deletion&lt;/strong&gt;. Critical in a ransomware scenario.&lt;/p&gt;
&lt;p&gt;To keep costs under control over the long term, set the bucket lifecycle:&lt;/p&gt;
&lt;p&gt;30 Days → Glacier Instant Retrieval → 120 Days → Delete&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/etc/pgbackrest.conf&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;ini&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;global&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;###################################&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;# Local Repository&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;###################################&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo1-path&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;/var/lib/pgbackrest&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo1-retention-full&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;4&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo1-retention-diff&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;14&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo1-bundle&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;y&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo1-block&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;y&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo1-cipher-pass&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo1-cipher-type&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;aes-256-cbc&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;###################################&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;# AWS S3 Repository&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;###################################&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-type&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;s3&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-path&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;/main&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-s3-bucket&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-s3-endpoint&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;s3.{region_name}.amazonaws.com&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-s3-region&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;{region_name}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-s3-key&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-s3-key-secret&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-bundle&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;y&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-block&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;y&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-cipher-type&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;aes-256-cbc&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;repo2-cipher-pass&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;###################################&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;compress-type&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;zst&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;compress-level&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;6&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;process-max&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;4&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;start-fast&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;y&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;log-level-console&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;info&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;log-level-file&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;detail&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;log-path&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;/var/log/pgbackrest&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;main&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;pg1-path&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;/var/lib/postgresql/16/main&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;pg1-port&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;5432&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tighten the permissions:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;sudo&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; chown&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; postgres:postgres&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; /etc/pgbackrest.conf&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;sudo&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; chmod&lt;/span&gt;&lt;span style=&quot;color:#2F798A;--shiki-dark:#4C9A91&quot;&gt; 640&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; /etc/pgbackrest.conf&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the stanza:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;sudo&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; -u&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; postgres&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; pgbackrest&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; --stanza=main&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; stanza-create&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the &lt;code&gt;postgresql.conf&lt;/code&gt; side, we route WAL archiving to pgBackRest:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;ini&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;archive_mode&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; on&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;archive_command&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;pgbackrest --stanza=main archive-push %p&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;archive_timeout&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; 60&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;wal_level&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; replica&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;archive_timeout = 60&lt;/code&gt; — the last 60 seconds of transactions can be lost; enough for most applications, not for financial transactions.&lt;/p&gt;
&lt;h2 id=&quot;backup-plan&quot;&gt;Backup plan&lt;/h2&gt;
&lt;p&gt;On the cron side:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;# Local repository (repo1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0 2 * * 0          pgbackrest --stanza=main --repo=1 --type=full backup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0 2 * * 1-6        pgbackrest --stanza=main --repo=1 --type=diff backup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0 0,6,12,18 * * *  pgbackrest --stanza=main --repo=1 --type=incr backup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;# S3 repository (repo2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0 4 * * 0          pgbackrest --stanza=main --repo=2 --type=full backup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0 4 * * 1-6        pgbackrest --stanza=main --repo=2 --type=diff backup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0 3,9,15,21 * * *  pgbackrest --stanza=main --repo=2 --type=incr backup&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With two repositories configured, &lt;code&gt;--repo&lt;/code&gt; is mandatory: without it pgBackRest backs up only to the highest-priority repository (repo1), so S3 would hold nothing but archived WAL — a copy that cannot be restored on its own.&lt;/p&gt;
&lt;p&gt;Weekly full, daily diff, 6-hourly incremental on each repository. Storage consumption is surprisingly low — the &lt;code&gt;repoN-bundle&lt;/code&gt;/&lt;code&gt;repoN-block&lt;/code&gt; lines in the config enable block incremental backup, so pgBackRest copies only the changed blocks of a file instead of the whole file; both options are off by default.&lt;/p&gt;
&lt;h2 id=&quot;integrity-if-theres-a-backup-can-it-be-restored&quot;&gt;Integrity: if there’s a backup, can it be restored?&lt;/h2&gt;
&lt;p&gt;Taking the backup is not enough. A weekly restore drill:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;pgbackrest&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; --stanza=main&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; --delta&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; restore&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; --pg1-path=/tmp/pg_restore_test&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I start that instance on a temporary port and run a few sanity queries. Skip this step and the backup becomes &lt;strong&gt;false confidence&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;pitr-rehearsal&quot;&gt;PITR rehearsal&lt;/h2&gt;
&lt;p&gt;Rolling back to a specific point in time:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;pgbackrest&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; --stanza=main&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt;  --type=time&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt;  --target=&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;2026-05-07 02:13:00+03&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;  restore&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running this command for the first time &lt;em&gt;during a real incident&lt;/em&gt; is a disaster. Rehearse it beforehand — write the steps into the runbook.&lt;/p&gt;
&lt;h2 id=&quot;retention&quot;&gt;Retention&lt;/h2&gt;
&lt;p&gt;Cleaning up unused backups in the repository with the &lt;code&gt;expire&lt;/code&gt; command:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt;pgbackrest&lt;/span&gt;&lt;span style=&quot;color:#A65E2B;--shiki-dark:#C99076&quot;&gt; --stanza=main&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt; expire&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With &lt;code&gt;repo1-retention-full=4&lt;/code&gt; the last 4 full backups are kept; older ones (and the diff/incr backups depending on them) are cleaned up automatically.&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;For copying a schema or moving a small table, &lt;code&gt;pg_dump&lt;/code&gt; is still the right tool. But on any system that needs PITR, the backup software has to be pgBackRest (or barman, or your database provider’s native tool). The moment a backup is &lt;em&gt;untested&lt;/em&gt;, saying “I have a backup” is a sophisticated way of lying.&lt;/p&gt;</content:encoded><category>postgresql</category><category>backup</category><category>production</category><category>pgbackrest</category><category>Note</category><author>info@muhammetsafak.com.tr (Muhammet Şafak)</author></item><item><title>Read/Write Splitting: Separating Read and Write Load</title><link>https://sade.dev/en/notes/read-write-splitting/</link><guid isPermaLink="true">https://sade.dev/en/notes/read-write-splitting/</guid><description>Before scaling up the database: moving read traffic to replicas, the traps replication lag creates, and when you actually need it</description><pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The primary database’s CPU was constantly maxed out. The interesting part: the write rate was low. Almost all of the load was reads — report pages, listing endpoints, search. A single primary was trying to carry a pile of reads that never needed it in the first place.&lt;/p&gt;
&lt;p&gt;Separating read and write load — read/write splitting — is the known fix for this picture. But applied at the wrong time, or without awareness of the right traps, it brings more problems than it solves.&lt;/p&gt;
&lt;h2 id=&quot;most-load-is-read-heavy&quot;&gt;Most load is read-heavy&lt;/h2&gt;
&lt;p&gt;How read-heavy the traffic is depends on the workload: in measurements of the standard OLTP benchmarks, TPC-E runs 90.69% reads while TPC-C stays at 65.71% — so measure your own ratio instead of assuming it. Every order is written once but read dozens of times: in the list, in the detail view, in a report, on a dashboard. This asymmetry is what makes read/write splitting appealing — because the side you need to scale is obvious.&lt;/p&gt;
&lt;h2 id=&quot;first-is-this-really-a-capacity-problem&quot;&gt;First: is this really a capacity problem?&lt;/h2&gt;
&lt;p&gt;Stop before adding a replica. A full primary doesn’t always mean “out of capacity.” Often a single missing index makes the primary look many times busier than it is.&lt;/p&gt;
&lt;p&gt;Adding a replica — a new server, replication setup, lag monitoring — can amount to expensively buying your way out of a problem a single &lt;code&gt;CREATE INDEX&lt;/code&gt; would have solved. The order in &lt;a href=&quot;/en/systems/data-intensive-systems-breaking-points/&quot;&gt;the breaking points of data-intensive systems&lt;/a&gt; is clear: index discipline first, then replicas. Don’t skip that order.&lt;/p&gt;
&lt;p&gt;Measure your queries with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;. If the primary is genuinely saturating under correctly indexed queries — that’s when you reach for a replica.&lt;/p&gt;
&lt;h2 id=&quot;a-replica-scales-reads-not-writes&quot;&gt;A replica scales reads, not writes&lt;/h2&gt;
&lt;p&gt;Let’s be clear: a read replica adds &lt;strong&gt;nothing&lt;/strong&gt; to your write capacity. The same writes are replayed on every replica. A replica solves a read-load problem; if you have a write-load problem, a replica is the wrong tool.&lt;/p&gt;
&lt;h2 id=&quot;setting-it-up-in-laravel&quot;&gt;Setting it up in Laravel&lt;/h2&gt;
&lt;p&gt;Laravel supports read/write connection splitting natively:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;php&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;// config/database.php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;pgsql&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;    &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;driver&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;pgsql&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;    &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;read&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;   =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;host&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;10.0.0.2&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;]],&lt;/span&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;   // replica&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;    &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;write&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;  =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;host&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;10.0.0.1&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;]],&lt;/span&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;   // primary&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;    &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;sticky&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;    // ...shared settings&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999;--shiki-dark:#666666&quot;&gt;],&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SELECT&lt;/code&gt;s go to the replica, &lt;code&gt;INSERT/UPDATE/DELETE&lt;/code&gt;s go to the primary. The replica gets its own connection pool — separate from the primary’s; if you use &lt;a href=&quot;/en/notes/pgbouncer-auth-query/&quot;&gt;pgBouncer&lt;/a&gt;, they are two distinct pools.&lt;/p&gt;
&lt;h2 id=&quot;replication-lag-the-real-bill&quot;&gt;Replication lag: the real bill&lt;/h2&gt;
&lt;p&gt;The replica trails the primary by a few milliseconds — a few seconds under load. This delay is the real cost of read/write splitting, and its name is the &lt;strong&gt;read-after-write&lt;/strong&gt; problem.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sticky =&amp;gt; true&lt;/code&gt; partly addresses it: if you wrote within a request, the subsequent reads in that same request go to the primary. But &lt;code&gt;sticky&lt;/code&gt; only works within the boundary of &lt;strong&gt;a single request&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Outside that boundary it’s still open: a user updates their profile (request 1, written to the primary), moves to the next page (request 2, read from the replica), and sees their old profile, not yet replayed on the replica. The user sees their own data as stale. This looks like a bug, but it’s actually a tradeoff the architecture accepts — one that has to be accepted deliberately.&lt;/p&gt;
&lt;h2 id=&quot;the-query-classification-discipline&quot;&gt;The query classification discipline&lt;/h2&gt;
&lt;p&gt;Setting up read/write splitting requires every read to answer one question: &lt;strong&gt;can this query read stale data?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Can read from a replica:&lt;/strong&gt; lists, reports, search results, dashboards. A few seconds of delay is irrelevant.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Must read from the primary:&lt;/strong&gt; account balance, stock count, authorization checks, any read a write decision depends on.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This classification is now part of the architecture, and it needs to be documented. A new developer must have somewhere to look for “where should this query read from” — otherwise the classification quietly rots.&lt;/p&gt;
&lt;h2 id=&quot;when-do-you-actually-need-it&quot;&gt;When do you actually need it?&lt;/h2&gt;
&lt;p&gt;Read/write splitting is the right move when these three conditions hold together:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Index discipline is complete; queries are correctly indexed and the primary is still saturating.&lt;/li&gt;
&lt;li&gt;The load is measurably read-heavy.&lt;/li&gt;
&lt;li&gt;There’s the discipline to do and document the stale-read classification.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If any one of these is missing, deferring the replica is cheaper.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;Read/write splitting is a cheaper scaling move than growing vertically — but it isn’t free. The price is replication lag, and the cost of ignoring it is paid by showing users their own data as stale.&lt;/p&gt;
&lt;p&gt;Before splitting reads, know which reads can tolerate staleness.&lt;/p&gt;</content:encoded><category>postgresql</category><category>database</category><category>scaling</category><category>performance</category><category>Note</category><author>info@muhammetsafak.com.tr (Muhammet Şafak)</author></item><item><title>Changing Schema in Production With Zero Downtime</title><link>https://sade.dev/en/notes/zero-downtime-database-migrations/</link><guid isPermaLink="true">https://sade.dev/en/notes/zero-downtime-database-migrations/</guid><description>Running migrations without causing downtime in production: backward-compatible steps, multi-phase column changes, and avoiding table locks</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A deploy ran a single &lt;code&gt;ALTER TABLE&lt;/code&gt; on a table with 50 million rows and locked it for four minutes. For four minutes, every request that touched that table waited; the site was effectively down. The migration itself was correct — the problem was doing it in one step.&lt;/p&gt;
&lt;p&gt;The dangerous thing is not the schema change. The dangerous thing is making a schema change all at once, without thinking about backward compatibility.&lt;/p&gt;
&lt;h2 id=&quot;who-takes-the-lock&quot;&gt;Who takes the lock?&lt;/h2&gt;
&lt;p&gt;Not every schema change costs the same. In modern PostgreSQL, adding a column with a constant &lt;code&gt;DEFAULT&lt;/code&gt; is a metadata operation — it’s fast. The real danger is in operations that lock the table for a long time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CREATE INDEX&lt;/code&gt; — without &lt;code&gt;CONCURRENTLY&lt;/code&gt;, it closes the table to writes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ALTER COLUMN ... TYPE&lt;/code&gt; changes that rewrite the table.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;, or foreign key additions that scan the whole table.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These operations take a strong lock; the ones that take it in &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; mode — a table rewrite, or adding &lt;code&gt;NOT NULL&lt;/code&gt;/&lt;code&gt;CHECK&lt;/code&gt; — queue up every query touching the table, reads included. The rest block writes only. If the table is large, the queue grows.&lt;/p&gt;
&lt;h2 id=&quot;the-dangerous-part-is-the-single-step&quot;&gt;The dangerous part is the single step&lt;/h2&gt;
&lt;p&gt;The solution is not to avoid migrations; it’s to break every dangerous migration into small steps, each of which is safe and backward-compatible on its own. This is called the &lt;strong&gt;expand-contract&lt;/strong&gt; pattern.&lt;/p&gt;
&lt;p&gt;Suppose you want to rename a column. A single-step &lt;code&gt;RENAME COLUMN&lt;/code&gt; instantly breaks running code that reads the old column. Instead, three deploys:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Expand.&lt;/strong&gt; Add the new column. Have the code write to both old and new, still reading from old.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate.&lt;/strong&gt; Move the old data into the new column in batches. Now have the code read from new.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contract.&lt;/strong&gt; Drop the old column.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each step works with both the code from the previous release and the new code. At no moment is the running code incompatible with the schema.&lt;/p&gt;
&lt;h2 id=&quot;safe-recipes&quot;&gt;Safe recipes&lt;/h2&gt;
&lt;p&gt;The zero-downtime versions of common changes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A new &lt;code&gt;NOT NULL&lt;/code&gt; column.&lt;/strong&gt; Adding &lt;code&gt;NOT NULL&lt;/code&gt; in one step scans the table. Split it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;-- 1. Add it as nullable first&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ALTER&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; TABLE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; orders &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ADD&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; COLUMN &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; text&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;-- 2. Backfill existing rows in batches (on the application side)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#5F6E5F;--shiki-dark:#8A9A8A&quot;&gt;-- 3. Add the constraint NOT VALID first, then validate in a separate step&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ALTER&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; TABLE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; orders &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ADD&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; CONSTRAINT&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; orders_status_not_null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;    CHECK&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; IS NOT NULL&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;NOT&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; VALID;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ALTER&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; TABLE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; orders VALIDATE &lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;CONSTRAINT&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; orders_status_not_null;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A constraint added with &lt;code&gt;NOT VALID&lt;/code&gt; applies immediately to new rows but does not scan existing ones; &lt;code&gt;VALIDATE&lt;/code&gt; then scans the table with only a &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; lock — it doesn’t block writes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An index.&lt;/strong&gt; Always &lt;code&gt;CONCURRENTLY&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; INDEX&lt;/span&gt;&lt;span style=&quot;color:#59873A;--shiki-dark:#80A665&quot;&gt; CONCURRENTLY&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; idx_orders_status &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; orders (&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;A foreign key.&lt;/strong&gt; The same two-step pattern: first &lt;code&gt;ADD CONSTRAINT ... NOT VALID&lt;/code&gt;, then &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;lock_timeout&lt;/code&gt;.&lt;/strong&gt; While a migration waits for a lock behind a long-running query, it blocks everything behind it too. To prevent this, give the migration session a short &lt;code&gt;lock_timeout&lt;/code&gt; — if the lock can’t be taken immediately, let the migration fail instead of waiting, and retry it yourself.&lt;/p&gt;
&lt;h2 id=&quot;code-and-schema-must-be-compatible-together&quot;&gt;Code and schema must be compatible together&lt;/h2&gt;
&lt;p&gt;The essence of expand-contract is one rule: at every intermediate step, both the &lt;strong&gt;old code still running&lt;/strong&gt; and the &lt;strong&gt;new code&lt;/strong&gt; must be able to work with the current schema.&lt;/p&gt;
&lt;p&gt;The database schema is what I called a “one-way door” in &lt;a href=&quot;/en/journal/the-cost-of-just-in-case-code/&quot;&gt;the cost of just-in-case code&lt;/a&gt; — rolling it back is expensive. So design the change not as one big, irreversible step, but as small steps that can each be rolled back individually.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;Zero-downtime migration is not a tool but a discipline: breaking every schema change into steps small enough that the running code never notices.&lt;/p&gt;
&lt;p&gt;The dangerous thing is not the change itself, but doing it in one breath.&lt;/p&gt;</content:encoded><category>postgresql</category><category>database</category><category>migrations</category><category>production</category><category>Note</category><author>info@muhammetsafak.com.tr (Muhammet Şafak)</author></item><item><title>Indexes: Too Few Slow Reads, Too Many Kill Writes</title><link>https://sade.dev/en/notes/index-management/</link><guid isPermaLink="true">https://sade.dev/en/notes/index-management/</guid><description>A missing index slows the query; too many indexes make every write expensive. Striking the right balance by measurement, not by guesswork</description><pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I’ve seen two opposite teams. One had put no indexes on the table at all — every query a sequential scan, every list page taking seconds. The other had done the exact opposite: “just in case,” it had created an index on every column, and now every &lt;code&gt;INSERT&lt;/code&gt; crawled along.&lt;/p&gt;
&lt;p&gt;Both are two ends of the same fallacy: thinking an index is a free source of speed.&lt;/p&gt;
&lt;h2 id=&quot;an-index-is-not-free-speed&quot;&gt;An index is not free speed&lt;/h2&gt;
&lt;p&gt;An index speeds up reads, because instead of scanning the whole table the database looks at an ordered structure. But that ordered structure doesn’t stay current on its own: an &lt;code&gt;INSERT&lt;/code&gt; writes an entry into &lt;strong&gt;every index&lt;/strong&gt; on the table, and an &lt;code&gt;UPDATE&lt;/code&gt; does the same as soon as it touches an indexed column. There are exceptions — a HOT update that changes no indexed column leaves the indexes alone, a partial index is skipped for rows outside its &lt;code&gt;WHERE&lt;/code&gt; clause, and a &lt;code&gt;DELETE&lt;/code&gt; leaves its index entries behind for &lt;code&gt;VACUUM&lt;/code&gt; to clean up later — but they are exceptions, not the default.&lt;/p&gt;
&lt;p&gt;So every index is a trade: you buy read speed with write cost. Inserting a single row into a table with five indexes means updating six structures at once. An index isn’t “free read speed,” it’s “read speed paid for with writes.”&lt;/p&gt;
&lt;h2 id=&quot;a-missing-index-measure-dont-guess&quot;&gt;A missing index: measure, don’t guess&lt;/h2&gt;
&lt;p&gt;Find a missing index from the query plan, not from a hunch:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;EXPLAIN ANALYZE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; FROM&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; orders &lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt; status&lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt; &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959;--shiki-dark:#C98A7D&quot;&gt;pending&lt;/span&gt;&lt;span style=&quot;color:#B5695977;--shiki-dark:#C98A7D77&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you see a &lt;code&gt;Seq Scan&lt;/code&gt; on a large table in the output and the query returns a small fraction of the rows, an index is probably missing. Scan the &lt;code&gt;pg_stat_user_tables&lt;/code&gt; table for large tables with a high &lt;code&gt;seq_scan&lt;/code&gt; count — those are your candidates.&lt;/p&gt;
&lt;p&gt;Add the index for a real, slow query. An index added “in case we need it later” is the database-layer version of &lt;a href=&quot;/en/journal/the-cost-of-just-in-case-code/&quot;&gt;speculative generality&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;too-many-indexes-find-the-unused-ones&quot;&gt;Too many indexes: find the unused ones&lt;/h2&gt;
&lt;p&gt;In the other direction, indexes that are never used make every write more expensive for nothing. Find those by measurement too:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes vitesse-light vitesse-dark&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#121212;color:#393a34;--shiki-dark:#dbd7caee;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; indexrelname, idx_scan&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; pg_stat_user_indexes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F;--shiki-dark:#4D9375&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt; idx_scan &lt;/span&gt;&lt;span style=&quot;color:#AB5959;--shiki-dark:#CB7676&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#2F798A;--shiki-dark:#4C9A91&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#393A34;--shiki-dark:#DBD7CAEE&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An index with &lt;code&gt;idx_scan = 0&lt;/code&gt; means it’s speeding up no reads at all but adding cost to every write. (Keep the indexes for primary keys and unique constraints, of course.) This is the first cleanup listed at the write breaking point of &lt;a href=&quot;/en/systems/data-intensive-systems-breaking-points/&quot;&gt;data-intensive systems&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;how-do-you-choose-the-right-index&quot;&gt;How do you choose the right index?&lt;/h2&gt;
&lt;p&gt;An index isn’t just “present or absent”; choosing the right type and shape matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Column order in a composite index.&lt;/strong&gt; An &lt;code&gt;(a, b)&lt;/code&gt; index serves equality on &lt;code&gt;a&lt;/code&gt; + a range query on &lt;code&gt;b&lt;/code&gt;; a lookup on &lt;code&gt;b&lt;/code&gt; alone can still use the index, but it doesn’t narrow the portion that gets scanned — before PostgreSQL 18 the whole index is scanned, and from 18 on the skip scan optimization narrows it down. Put the column filtered by equality first, the one filtered by range last.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partial index.&lt;/strong&gt; If the query always looks at the same subset, limit the index to that subset too: &lt;code&gt;CREATE INDEX ... WHERE status = &amp;#39;active&amp;#39;&lt;/code&gt;. A smaller index, cheaper maintenance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Covering index.&lt;/strong&gt; With &lt;code&gt;INCLUDE&lt;/code&gt; you can add frequently read columns to the index and keep the database from going to the table at all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-B-tree types.&lt;/strong&gt; B-tree for equality/ordering; GIN for &lt;code&gt;jsonb&lt;/code&gt; and full text; small, cheap BRIN for purely ordered, append-heavy data.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;duplicate-and-overlapping-indexes&quot;&gt;Duplicate and overlapping indexes&lt;/h2&gt;
&lt;p&gt;An index on &lt;code&gt;(a)&lt;/code&gt; is redundant if you already have an &lt;code&gt;(a, b)&lt;/code&gt; index — the composite index serves queries starting with &lt;code&gt;a&lt;/code&gt; too. Clean up overlaps like these periodically; each one is a silent write tax.&lt;/p&gt;
&lt;h2 id=&quot;balance-is-set-with-the-query-plan&quot;&gt;Balance is set with the query plan&lt;/h2&gt;
&lt;p&gt;One rule: don’t add an index by guesswork, and don’t drop one by guesswork either. An index is added because a real query plan asks for it; an index is dropped because the statistics show no one is using it. The balance between too few and too many is struck with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and &lt;code&gt;pg_stat_user_indexes&lt;/code&gt;, not with gut feeling.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;Index management starts with shedding the belief that “more indexes is better.” Every index is a read gain and a write cost; good management is keeping the two in balance by measuring them.&lt;/p&gt;
&lt;p&gt;A missing index slows the query; too many slow the whole table. Measurement guards against both.&lt;/p&gt;</content:encoded><category>postgresql</category><category>database</category><category>performance</category><category>indexing</category><category>Note</category><author>info@muhammetsafak.com.tr (Muhammet Şafak)</author></item></channel></rss>