<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://prateek-arora.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://prateek-arora.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-01T09:47:45+05:30</updated><id>https://prateek-arora.github.io/feed.xml</id><title type="html">Prateek Arora</title><subtitle>Notes on distributed systems, databases, frontend, and AI by Prateek Arora, a full-stack engineer with five years in fintech and SaaS.</subtitle><author><name>Prateek Arora</name></author><entry><title type="html">A read-only safety belt that breaks your app’s writes</title><link href="https://prateek-arora.github.io/2026/09/read-only-safety-belt/" rel="alternate" type="text/html" title="A read-only safety belt that breaks your app’s writes" /><published>2026-09-29T00:00:00+05:30</published><updated>2026-09-29T00:00:00+05:30</updated><id>https://prateek-arora.github.io/2026/09/read-only-safety-belt</id><content type="html" xml:base="https://prateek-arora.github.io/2026/09/read-only-safety-belt/"><![CDATA[<p>The setting I added so my tool could never write to your database turned out to break your app’s
writes instead.</p>

<p>The tool is <a href="https://github.com/Prateek-Arora/pglens">PgLens</a>, a Postgres index advisor I’m
building. It only needs to read, so it logs in as a role with no write grants, and it used to start
every connection with:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SET</span> <span class="k">SESSION</span> <span class="k">CHARACTERISTICS</span> <span class="k">AS</span> <span class="n">TRANSACTION</span> <span class="k">READ</span> <span class="k">ONLY</span><span class="p">;</span>
</code></pre></div></div>

<p>Before the first release I wanted to see what happens if someone points it at a connection pooler,
like Supabase’s port 6543 or a Neon <code class="language-plaintext highlighter-rouge">-pooler</code> host. 
So I tried it with PgBouncer in transaction mode. 
PgLens connected, then the application connected and tried to write:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CREATE TABLE orders_archive (id int);
ERROR:  cannot execute CREATE TABLE in a read-only transaction
</code></pre></div></div>

<p>The app did nothing wrong. It just got the connection PgLens had used a moment earlier.
Not great for a tool whose whole pitch is that it’s safe to point at production.</p>

<figure>
<video controls="" muted="" playsinline="" preload="none" poster="/assets/video/pgbouncer-read-only-leak.jpg" src="/assets/video/pgbouncer-read-only-leak.mp4"></video>
<figcaption>The leak and the fix, on PgBouncer in transaction mode with a pool of one server connection.</figcaption>
</figure>

<h2 id="why">Why</h2>

<p>A transaction pooler hands the same server connection to the next client.
Session settings stay on that connection, so my <code class="language-plaintext highlighter-rouge">SET</code> went with it.</p>

<p>PgBouncer’s docs do say session <code class="language-plaintext highlighter-rouge">SET</code> doesn’t work in transaction mode.
I read that as: your setting might not stick.
It sticks, just for the wrong client.</p>

<h2 id="the-fix">The fix</h2>

<p>Don’t set anything on the session. Pass the guards as startup options instead, which last only as
long as that backend:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>options=-c default_transaction_read_only=on -c statement_timeout=30s -c lock_timeout=5s
</code></pre></div></div>

<p>PgBouncer refuses them (<code class="language-plaintext highlighter-rouge">unsupported startup parameter in options</code>), which is what I want:
PgLens stops and tells you to connect directly.</p>

<p>Since poolers can be configured to ignore that,
PgLens also checks on connect that the backend pid stays the same and read-only is on.
An integration test runs it against a real PgBouncer.</p>

<h2 id="if-you-build-tools-that-connect-to-other-peoples-databases">If you build tools that connect to other people’s databases</h2>

<ul>
  <li>Don’t <code class="language-plaintext highlighter-rouge">SET</code> anything on a connection unless you know it goes straight to Postgres.</li>
  <li>Put guards in startup options, and check they took effect.</li>
  <li>Test against a transaction-mode pooler. Some of your users are behind one.</li>
</ul>

<p>The alternatives I rejected are in
<a href="https://github.com/Prateek-Arora/pglens/blob/main/docs/decisions.md#adr-0053">ADR-0053</a>.</p>

<p><strong>Update, 1 Oct:</strong> PgBouncer 1.26.0, out on 23 September, now tracks <code class="language-plaintext highlighter-rouge">default_transaction_read_only</code>
on Postgres 14 and later, so this exact leak is fixed there. Settings it doesn’t track, like
<code class="language-plaintext highlighter-rouge">statement_timeout</code>, still leak. It also tells the client its pool mode at login, so a tool can
allow session mode and refuse only transaction mode.</p>]]></content><author><name>Prateek Arora</name></author><category term="postgresql" /><summary type="html"><![CDATA[Through PgBouncer in transaction mode, a monitoring tool's read-only setting lands on the next client's connection.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://prateek-arora.github.io/assets/og/read-only-safety-belt.png" /><media:content medium="image" url="https://prateek-arora.github.io/assets/og/read-only-safety-belt.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>