<?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: PlanetScale</title>
    <description>The latest articles on DEV Community by PlanetScale (planetscale).</description>
    <link>https://dev.to/planetscale</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%2Forganization%2Fprofile_image%2F3455%2F4f4b177d-73df-471e-a3ca-bb6349f304f4.png</url>
      <title>DEV Community: PlanetScale</title>
      <link>https://dev.to/planetscale</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/planetscale"/>
    <language>en</language>
    <item>
      <title>The feedback loops behind Kubernetes</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:24:18 +0000</pubDate>
      <link>https://dev.to/planetscale/the-feedback-loops-behind-kubernetes-130d</link>
      <guid>https://dev.to/planetscale/the-feedback-loops-behind-kubernetes-130d</guid>
      <description>&lt;p&gt;&lt;em&gt;Written by Fatih Arslan&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the last decade, Kubernetes has been the backdrop to most of my work: operating clusters, helping build hosted Kubernetes, and writing Kubernetes operators. At PlanetScale, that now means running stateful systems like Postgres and MySQL in production. Kubernetes has many faces, but here I want to talk about one face only: why it is so good at running workloads at scale.&lt;/p&gt;

&lt;p&gt;People ask me what an operator actually does. The canonical answer is: "it reconciles desired state." This is correct, but it also tells you almost nothing.&lt;/p&gt;

&lt;p&gt;An operator is a feedback controller. It's the same closed loop that runs a thermostat or keeps your car at a fixed speed on cruise control. In our case, the thing being controlled is a database. I have been building these loops for years, and the best way I know to make them click is to ignore Kubernetes at the beginning. Kubernetes is full of control theory, even if we don't call it that in the day-to-day.&lt;/p&gt;

&lt;p&gt;Before we look at a single line of Kubernetes, we're going to run a production database by hand and slowly let the feedback loop appear on its own. Then we'll map that loop to Kubernetes, with the pieces production needs: a store, watches, queues, retries, and more. At the end, we'll look at what one of these loops looks like in a real operator.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: A working understanding of containers and &lt;code&gt;kubectl&lt;/code&gt; helps, but you don't need to be a Kubernetes expert. I'll use terms like &lt;em&gt;idempotent&lt;/em&gt;, &lt;em&gt;fan-in&lt;/em&gt;, and &lt;em&gt;eventual consistency&lt;/em&gt;, and introduce the parts that matter as we go.&lt;/p&gt;

&lt;p&gt;We're going to start slow and gradually ramp things up. Each part builds on the previous.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Part 1: running Postgres by hand
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One container, one machine
&lt;/h3&gt;

&lt;p&gt;Let's start from scratch. I want to run Postgres on a Linux box, and I need it inside a container. To start it, we run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;docker run -d --name pg \
  -e POSTGRES_PASSWORD=secret \
  postgres:18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Postgres is running. My app connects to it, writes some rows, and everything works fine. But then the machine goes away: the cloud provider reclaims the instance (hardware fails, or a spot instance gets taken back), or I ship a new version of my setup, which means stopping the old container and starting a fresh one in its place. Either way, the container is replaced, and my data is gone. The container storage was ephemeral, and I did not attach any persistent volume to it.&lt;/p&gt;

&lt;p&gt;There is already a gap between what I &lt;em&gt;want&lt;/em&gt; (Postgres, running, with my data) and what I &lt;em&gt;have&lt;/em&gt; (a container whose storage disappears when the container or node goes away). The rest of this post is about that gap and the machinery we build to close it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick a node, by hand
&lt;/h3&gt;

&lt;p&gt;Imagine we have hundreds of nodes (servers) we can use. I already have other workloads running on them. I need to decide &lt;em&gt;which one&lt;/em&gt; runs this database. So I &lt;code&gt;ssh&lt;/code&gt; into the box that looks the least busy and start the container there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh node-07 &lt;span class="s1"&gt;'docker run -d --name pg ... postgres:18'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I picked &lt;code&gt;node-07&lt;/code&gt; because it looked idle enough. I start keeping track of it, save it in some sort of config file, and push it to some repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  It needs a real disk
&lt;/h3&gt;

&lt;p&gt;Container storage is ephemeral, so I have to attach a real block device. In the cloud this is an EBS volume (e.g. on AWS); on bare metal it's a physical disk. Assuming it's a block device, this is what we usually do: provision the volume, attach it to the node, format it, mount it, and point Postgres' data directory at the mount.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# provision + attach first with cloud CLI, then on the node:&lt;/span&gt;
mkfs.ext4 /dev/nvme1n1
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/lib/pg-data
mount /dev/nvme1n1 /var/lib/pg-data
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /var/lib/pg-data:/var/lib/postgresql &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are a lot of steps, and each one can fail halfway. And if the disk fills up later, Postgres stops accepting writes and we have to resize the volume by hand: first through the cloud provider, then again inside the filesystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  One isn't enough
&lt;/h3&gt;

&lt;p&gt;A single Postgres instance is a single point of failure. We want high availability: one primary and two replicas. These need to be on three different machines, with streaming replication between them. So we do the same steps again, three times, on &lt;code&gt;node-07&lt;/code&gt;, &lt;code&gt;node-12&lt;/code&gt;, and &lt;code&gt;node-19&lt;/code&gt;. I also wire up replication by hand: &lt;code&gt;primary_conninfo&lt;/code&gt;, replication slots, all of it.&lt;/p&gt;

&lt;p&gt;Now we have three nodes with three Postgres instances. One of the instances is the primary (here it's &lt;code&gt;node-07&lt;/code&gt;). But this raises new problems, like what to do if the primary's node dies?&lt;/p&gt;

&lt;h3&gt;
  
  
  They have to find each other
&lt;/h3&gt;

&lt;p&gt;Here is another thing we have to solve. The replicas need to reach the primary, and the primary needs to accept their connections. And every one of these addresses is an IP that changes when a container restarts.&lt;/p&gt;

&lt;p&gt;The first thing I do is hard-code the IPs. I write &lt;code&gt;node-07&lt;/code&gt;'s address into the replicas' config, I list the replicas' addresses in the primary's &lt;code&gt;pg_hba.conf&lt;/code&gt;, and I keep a small &lt;code&gt;/etc/hosts&lt;/code&gt; table and save it somewhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# on each replica's postgresql.auto.conf, until the primary is recreated with a new IP
&lt;/span&gt;&lt;span class="n"&gt;primary_conninfo&lt;/span&gt; = &lt;span class="s1"&gt;'host=10.4.7.21 port=5432 user=replicator ...'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But we still have a problem: the first time the primary is recreated with a different IP, the whole cluster falls apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbugplo11s0535xyjed93.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbugplo11s0535xyjed93.png" alt="Running Postgres by hand" width="800" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The watchdog script
&lt;/h3&gt;

&lt;p&gt;Now, this is where we start thinking about how to solve these issues. Everything described so far can break, and will continue to break even if I fix it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A replica process dies and doesn't come back.&lt;/li&gt;
&lt;li&gt;A disk gets full.&lt;/li&gt;
&lt;li&gt;The primary fails and a replica has to be promoted.&lt;/li&gt;
&lt;li&gt;A config I changed on two nodes but forgot on the third one. They are now out of sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's assume we've set up a simple uptime monitor and we're going to get paged for all these cases. To avoid getting paged at night, we do the sensible thing: write a script. So we decide to write a loop that wakes up every few seconds, looks at each node, and fixes whatever's wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  for &lt;/span&gt;node &lt;span class="k"&gt;in &lt;/span&gt;node-07 node-12 node-19&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'pg_isready -q'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'docker start pg'&lt;/span&gt;       &lt;span class="c"&gt;# it died, bring it back&lt;/span&gt;
    &lt;span class="k"&gt;fi

    &lt;/span&gt;&lt;span class="nv"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"df --output=pcent /var/lib/pg-data | tail -1 | tr -dc 0-9"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$usage&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 80 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;grow_volume &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;                  &lt;span class="c"&gt;# disk filling, make it bigger&lt;/span&gt;
    &lt;span class="k"&gt;fi
  done
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's written in Bash, and probably has tons of bugs. You notice something here? The loop doesn't care &lt;em&gt;how&lt;/em&gt; the database got into a bad state. Every five seconds it looks at the current state of the world and asks this question: does reality match what I want?&lt;/p&gt;

&lt;p&gt;If a process is down, start it. If a disk is filling, grow it. Run the loop once or run it a thousand times and the result is the same, because each action is conditional on the current state. The script is &lt;em&gt;idempotent&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Changing a parameter
&lt;/h3&gt;

&lt;p&gt;Let's make things a little more complex. I need to raise &lt;code&gt;max_connections&lt;/code&gt; from 100 to 500. This one is not a reload-only change. PostgreSQL says it can only be set at server start, so the manual version is to ssh into each box, edit &lt;code&gt;postgresql.conf&lt;/code&gt;, restart Postgres, and check that it took on all three.&lt;/p&gt;

&lt;p&gt;Because I know that ssh'ing into the nodes manually isn't a thing I want anymore, I do the same thing we did previously: I write the desired value down in one place and teach the loop to enforce it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;WANT_MAX_CONNECTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;500

&lt;span class="k"&gt;for &lt;/span&gt;node &lt;span class="k"&gt;in &lt;/span&gt;node-07 node-12 node-19&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;have&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"psql -tAc 'show max_connections'"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WANT_MAX_CONNECTIONS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"sed -i 's/^max_connections.*/max_connections = &lt;/span&gt;&lt;span class="nv"&gt;$WANT_MAX_CONNECTIONS&lt;/span&gt;&lt;span class="s2"&gt;/' /var/lib/pg-data/postgresql.conf"&lt;/span&gt;
    ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"docker restart pg"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same idea as before. I read what I &lt;em&gt;want&lt;/em&gt; (a variable). Observe what I &lt;em&gt;have&lt;/em&gt; (a query). If they differ, I take an action to close the difference. Again, I don't track whether I changed it last time. All I do is compare and &lt;a href="https://dictionary.cambridge.org/dictionary/english/converge" rel="noopener noreferrer"&gt;converge&lt;/a&gt;, every loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we actually built
&lt;/h3&gt;

&lt;p&gt;I started with a desired state that was written down in one place: three instances, this disk size, &lt;code&gt;max_connections = 500&lt;/code&gt;. Every few seconds I observe the actual state of the system. I compute the difference. I take whatever action closes that difference. Then I do it again, forever.&lt;/p&gt;

&lt;p&gt;That's a &lt;strong&gt;closed feedback loop&lt;/strong&gt;. The word "closed" matters. It means the output of the system is fed back into the next decision. I don't run &lt;code&gt;docker start&lt;/code&gt; and assume the database is fine. I check the database again. If it is still wrong, I act again. If it is already correct, I do nothing.&lt;/p&gt;

&lt;p&gt;The nice part is that the same loop works for different problems. It can restart a dead process, grow a disk, or push &lt;code&gt;max_connections = 500&lt;/code&gt;. The action changes, but the shape stays the same: read what I want, observe what I have, compare them, act, repeat. If I draw the same thing as a block diagram, with the control theory names added, it would look like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9njn2mq6lvl5v77ksu1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9njn2mq6lvl5v77ksu1.png" alt="control theory names added" width="800" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is how the vocabulary from &lt;a href="https://en.wikipedia.org/wiki/Control_theory" rel="noopener noreferrer"&gt;control theory&lt;/a&gt; maps cleanly onto my shell script:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;setpoint&lt;/strong&gt; is my desired state, the variables at the top of the script (disk size, max_connections and so on).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;measured output&lt;/strong&gt; is what I observe: &lt;code&gt;pg_isready&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;, &lt;code&gt;show max_connections&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;error&lt;/strong&gt; (e) is the difference between them.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;controller&lt;/strong&gt; is the body of the loop, the &lt;code&gt;if&lt;/code&gt; statements that decide what to do. It is not the whole script.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;actuator&lt;/strong&gt; is what carries out the action: &lt;code&gt;ssh&lt;/code&gt; plus &lt;code&gt;docker start&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;plant&lt;/strong&gt; is the system being controlled, Postgres and its disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That also gives us a nice way to understand &lt;strong&gt;open-loop&lt;/strong&gt; control. My very first attempt, &lt;code&gt;ssh&lt;/code&gt; in, run the command, and walk away, was open-loop: fire an action and assume it worked. The Bash script is closed-loop because it keeps feeding the measured state back into the next decision.&lt;/p&gt;

&lt;p&gt;A Bash loop is not a production control plane. Just to name a few issues with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It has no concurrency control, so two copies of the script can race each other. Imagine both deciding to promote a different replica.&lt;/li&gt;
&lt;li&gt;It keeps its only real state, "am I mid-failover?", in a shell variable that could die with the process.&lt;/li&gt;
&lt;li&gt;It polls every node every five seconds whether anything changed or not, which is fine for three nodes, but too expensive for three thousand nodes.&lt;/li&gt;
&lt;li&gt;It has no idea what to do when the &lt;code&gt;ssh&lt;/code&gt; itself times out.&lt;/li&gt;
&lt;li&gt;And the moment I want a second kind of resource, a connection pooler, a backup job, a read replica in another region, I'm copy-pasting this whole structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What if the script also fails? Who runs it then? We could keep hardening this script, but look at where it goes: we would need a real store for the desired state, watches instead of polling, a work queue, retries, leader election. We would be rebuilding Kubernetes. The real platform already exists, and it's Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: how we reinvented Kubernetes
&lt;/h2&gt;

&lt;p&gt;Now we can map what we hand-rolled in Part 1 to Kubernetes. Almost all of it already exists there. The operator is the part we care about.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other loops
&lt;/h3&gt;

&lt;p&gt;Let's go through some of the pieces we built by hand before the watchdog loop. You already know these components by name. What you might not have noticed is that they also work like controllers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spinning up the container: the kubelet&lt;/strong&gt;. First, a quick definition: a Pod is the smallest thing Kubernetes runs, one or more containers scheduled together on a node and sharing its network. For us it's the Postgres container. On every node runs an agent called the kubelet. Its desired state is the set of Pods assigned to its node, which it learns from the API server. Its observed state is the set of containers actually running, which it gets from the container runtime. When they differ, it starts the missing container, kills the extra one, or restarts the crashed one. My &lt;code&gt;if ! pg_isready; then docker start; fi&lt;/code&gt; is the kubelet's job, just done properly. The kubelet doesn't shell into anything; it talks to containerd over a gRPC socket, which talks to runc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Picking a node: the scheduler&lt;/strong&gt;. Remember me choosing &lt;code&gt;node-07&lt;/code&gt;? That's the scheduler's whole reason to exist. It watches for Pods with no node assigned, filters out the nodes that can't work, scores the rest, and writes the decision to one field: &lt;code&gt;pod.Spec.NodeName&lt;/code&gt;. The scheduler doesn't start the container; it records the placement and lets the kubelet pick it up. You will realize that most things in Kubernetes are decoupled like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attaching the disk: CSI and the PV/PVC sync&lt;/strong&gt;. My multi-step &lt;code&gt;mkfs&lt;/code&gt; and &lt;code&gt;mount&lt;/code&gt; script becomes a &lt;code&gt;PersistentVolumeClaim&lt;/code&gt;, which is a declarative request for storage. The &lt;a href="https://github.com/container-storage-interface/spec/blob/master/spec.md" rel="noopener noreferrer"&gt;Container Storage Interface&lt;/a&gt; (CSI) driver turns that request into a real volume. CSI itself is a set of controllers and sidecars: one provisions, one attaches, one resizes, and so on, while the kubelet calls the driver's node plugin to do the actual mount. It's a family of controllers. If there is a PVC but no disk behind it, one controller creates the disk. If the PVC size increases, another controller calls the provider API (e.g., AWS &lt;code&gt;ModifyVolume&lt;/code&gt;). Again, I write intent, and a controller does the actual work. (note: I wrote one of the early production CSI drivers, &lt;a href="https://github.com/digitalocean/csi-digitalocean" rel="noopener noreferrer"&gt;csi-digitalocean&lt;/a&gt;, and a &lt;a href="https://arslan.io/2018/06/21/how-to-write-a-container-storage-interface-csi-plugin/" rel="noopener noreferrer"&gt;long post about building one&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making them find each other: the CNI and Services&lt;/strong&gt;. The &lt;code&gt;/etc/hosts&lt;/code&gt; problem is solved at a layer we no longer have to think about. A CNI plugin gives Pods their network identity; Cilium, for example, does this with eBPF instead of a pile of &lt;code&gt;iptables&lt;/code&gt; rules. For stateful workloads, a StatefulSet plus a &lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/#headless-services" rel="noopener noreferrer"&gt;headless Service&lt;/a&gt; gives each replica its own stable DNS name, which is exactly what a Postgres replica needs. The hard-coded IP that broke our cluster becomes a name that keeps working. DNS is only one tool here; other service discovery systems like etcd, ZooKeeper, and Consul solve similar problems.&lt;/p&gt;

&lt;p&gt;As you see, all the problems we solved with &lt;code&gt;ssh&lt;/code&gt; and various scripts are replaced by Kubernetes components and drivers. And these are just a few of them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F690hhvh2hxyajkkmzdkr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F690hhvh2hxyajkkmzdkr.png" alt="Kubernetes small containers" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All of this so far is useful context, but the part we care about is &lt;em&gt;our watchdog&lt;/em&gt; loop, because that's the one we get to write ourselves.&lt;/p&gt;

&lt;p&gt;That's the operator.&lt;/p&gt;

&lt;h3&gt;
  
  
  The for-loop translated to Kubernetes
&lt;/h3&gt;

&lt;p&gt;In Kubernetes, our watchdog script is a &lt;strong&gt;controller&lt;/strong&gt;, and the standard way to write one in Go is a library called &lt;a href="https://github.com/kubernetes-sigs/controller-runtime" rel="noopener noreferrer"&gt;controller-runtime&lt;/a&gt;. At its heart, it's a function with a basic signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Reconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;reconcile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reconcile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// req contains a namespace/name. That's it. That's the whole input.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is missing here: the function isn't told what changed. There is no diff. It isn't handed the old object and the new object. It isn't given an event type. It gets a key, a namespace and a name, and nothing else. It's minimal by design, because it has to work for many different controllers. The function's job is to fetch the object with that namespace/name, look at the world, and converge to the desired state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge-triggered notifications, level-triggered logic
&lt;/h3&gt;

&lt;p&gt;There are two ways to build any closed feedback loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edge-triggered&lt;/strong&gt;: act on transitions, on events. "The disk crossed 80%." "The Pod was deleted." "The number of replicas increased by 2."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level-triggered&lt;/strong&gt;: act on the current state, regardless of how you got there. "The disk is at 85%." "The Pod is missing." "The number of replicas is 3."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My first mental model of controllers, and probably yours at some point, was edge-triggered: listen to a stream of changes, and for each change, try to converge.&lt;/p&gt;

&lt;p&gt;The problem is that this is very fragile. In distributed systems, if one component is fragile, the fragility spreads to the rest of the system. Why is edge-triggering fragile? Say your controller is down for thirty seconds. It misses the events from those thirty seconds, and its view of the world is now permanently wrong. If two events arrive out of order, you process them out of order. If an event is delivered twice, you act twice. You're rebuilding your state from a stream of events, and you've inherited all of event sourcing's hard problems.&lt;/p&gt;

&lt;p&gt;Here is a very concrete example. Assume you have 1 replica, and you increase it to 3 replicas. Because you have only subscribed to changes, either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You miss the event (maybe the queue dropped it, or the consumer, your app, dropped it due to a crash or a full buffer).&lt;/li&gt;
&lt;li&gt;You receive it twice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the first case, you won't be able to self-correct. In the second case, if your handler blindly applies the delta again, you'll end up with 5 replicas (you overshoot), instead of 3.&lt;/p&gt;

&lt;p&gt;The level-triggered model fixes all of that. Remember, our shell script never asked "what changed?" It asked "what is true right now?", every five seconds, from scratch. Miss a loop, and the next one catches up. Run the loop twice, and you get the same result. The current state of the world is the only input that matters, and it's always available to read. So in the level-triggered case, our example above becomes this: you read &lt;code&gt;replicas=3&lt;/code&gt;, you check the current number of replicas, which is 1, and you increase by 2.&lt;/p&gt;

&lt;p&gt;If you miss the event, no one cares. In the next reconcile loop you'll catch it. If your app crashes, it comes back, reads again and detects that it did not increase it yet, increases it.&lt;/p&gt;

&lt;p&gt;Kubernetes controllers combine both: &lt;strong&gt;edge-triggered notifications&lt;/strong&gt;, &lt;strong&gt;level-triggered logic&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Events (the edges) are only a hint that it's worth looking again. They tell you &lt;em&gt;when&lt;/em&gt; to reconcile, never &lt;em&gt;what&lt;/em&gt; to do. The reconcile itself is level-based: it reads the current state (e.g., &lt;code&gt;replicas=3&lt;/code&gt;) and drives toward the desired state (e.g., &lt;code&gt;create 2 replicas&lt;/code&gt;), ignoring the triggering event completely. That's &lt;em&gt;why&lt;/em&gt; &lt;code&gt;Reconcile&lt;/code&gt; only gets a key. The framework makes it hard to write edge-triggered logic, on purpose. Edge-triggered logic is how you get a controller that's fragile and permanently wrong after its first hiccup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zwlmg0arn72qi51cs7l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zwlmg0arn72qi51cs7l.png" alt="edge-triggered vs level-triggered scaling" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our bash script stumbled into this property by accident, at least for the sake of the example. But the &lt;code&gt;controller-runtime&lt;/code&gt; framework gives it to you on purpose. It's why a Kubernetes controller can crash, get restarted ten minutes later, and converge correctly with no special recovery code. There is no recovery code. There is just the loop. The controller can reconstruct the world from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Informers, the work queue, and a cache
&lt;/h3&gt;

&lt;p&gt;So where do the edges come from? And what stops a controller from DDoSing the API server by listing everything every five seconds like my script did?&lt;/p&gt;

&lt;p&gt;The answer is the informer. An informer opens a single watch against the API server for a given resource type, streams every add, update, and delete, and keeps a complete in-memory cache of the objects we're interested in. Two things matter here:&lt;/p&gt;

&lt;p&gt;First, the informer turns each watch event into a key and puts it on a &lt;strong&gt;work queue&lt;/strong&gt;. The queue does a lot of work for you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It &lt;em&gt;coalesces&lt;/em&gt;: if the same object is updated five times before you get to it, you reconcile it once, against the latest state (level-triggered again).&lt;/li&gt;
&lt;li&gt;It &lt;em&gt;rate-limits&lt;/em&gt;: an object that keeps erroring backs off exponentially instead of spinning. This is &lt;a href="https://en.wikipedia.org/wiki/Damping" rel="noopener noreferrer"&gt;damping&lt;/a&gt;, the same reason a crash-looping container backs off instead of restarting hot.&lt;/li&gt;
&lt;li&gt;It lets you run a pool of workers pulling keys in parallel, which is your fan-out. Events fan in from the watch, collapse in the queue, and fan out to the workers. This is something you need to tune. The higher you set the pool, the more pressure you put on the system: more writes, more API calls, more load on the provider, and more CPU usage in the operator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcnfw7nae7l28iv4z7poq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcnfw7nae7l28iv4z7poq.png" alt="controllers, work queue, informer, API server" width="799" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Second, and this is a detail that bites people a lot: &lt;strong&gt;your reads and your writes in Kubernetes don't go to the same place&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In controller-runtime, the client you're handed reads from the informer's local cache. Cache reads are cheap, they don't touch the API server, and that's how a controller reconciles thousands of objects without falling over. But your writes go straight to the API server. The cache only learns about your write when the resulting watch event comes back around, a moment later.&lt;/p&gt;

&lt;p&gt;Because of that, a read can be stale. You need to be prepared for this.&lt;/p&gt;

&lt;p&gt;If you write a field of an object and then read the same object again from the cache, the reconciler might think it's not updated yet. You write again, and you get a Conflict error. Retrying with a fresh read can be fine, but blindly retrying against the same stale cached view just spins.&lt;/p&gt;

&lt;p&gt;Most of the time, what you want is to drop the call and &lt;em&gt;requeue&lt;/em&gt;. In the next reconcile, the &lt;code&gt;GET&lt;/code&gt; will see the updated object, and your write will never happen. That's how everything self-converges.&lt;/p&gt;

&lt;p&gt;Here is another edge case. Picture this sequence inside a reconcile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// I want N replicas. I see fewer, so I create the missing ones.&lt;/span&gt;
&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;listChildPods&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c"&gt;// reads the CACHE&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newPod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c"&gt;// writes the API SERVER&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an event fires again a second later, before the cache has caught up with the Pods you just created. You list from the cache, and the new Pods aren't there yet. Your code decides it still needs to create them, and you create duplicates. This is the classic stale-cache double-create, and it's nasty because it only shows up under timing you can't reproduce on your laptop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0wjv96uf9uks3dip1k8o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0wjv96uf9uks3dip1k8o.png" alt="Reads from the cache, writes go to the API server" width="799" height="575"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are two ways out. The correct one is the &lt;strong&gt;expectations pattern&lt;/strong&gt;, the same trick the built-in ReplicaSet controller uses: you record that you expect to see N creations in memory, and you don't act again until the cache has caught up to your own writes. It works, but it's not easy to implement and it's a fair amount of machinery. Read more &lt;a href="https://ahmet.im/blog/controller-pitfalls/" rel="noopener noreferrer"&gt;on Ahmet's blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The pragmatic one, which a lot of people use, is to bypass the cache for the reads where a stale view would cause a double-create or double-delete, and go straight to the API server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The cached client can be stale right after our own writes, which&lt;/span&gt;
&lt;span class="c"&gt;// would make us miscount and create duplicates. For this one read,&lt;/span&gt;
&lt;span class="c"&gt;// go direct to the API server instead of the cache. Slower,&lt;/span&gt;
&lt;span class="c"&gt;// but consistent for this decision.&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apiReader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;labelSelector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not only a Kubernetes issue. In any system with a read cache and a write-through path, read-after-write is not consistent unless you make it so. Most of the time the cache is exactly what you want: cheap, local, and eventually consistent. Eventual consistency is fine because the loop runs again. But the moment a decision would be destructive or non-idempotent if you acted on a stale read, you need to know which path you're on. Kubernetes solves many hard problems, but it also gives you a few new ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setpoint and measured variable: spec and status
&lt;/h3&gt;

&lt;p&gt;Back to the control diagram. My script kept its setpoint in shell variables and its measured state in the output of &lt;code&gt;df&lt;/code&gt; and &lt;code&gt;psql&lt;/code&gt;. Kubernetes gives both a permanent home, on the object itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.spec&lt;/code&gt; is the &lt;strong&gt;setpoint&lt;/strong&gt;, the desired state. It's owned by whoever created the object (a human, or another controller), and the reconciler treats it as read-only intent. It's an anti-pattern to write to the &lt;code&gt;.spec&lt;/code&gt; from inside the controller. If you do it, stop reading, go and fix your codebase. There are only a handful of exceptions, but a controller should generally never set its own setpoint.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.status&lt;/code&gt; is the &lt;strong&gt;measured variable&lt;/strong&gt;, the observed state. It's owned by the controller, written through a separate status subresource, and it's where you record what's actually true. The better the status, the better the controller can decide. A good &lt;code&gt;.status&lt;/code&gt; field is what makes a controller pleasant to operate. The word &lt;em&gt;observability&lt;/em&gt; comes from control theory; &lt;a href="https://en.wikipedia.org/wiki/Observability" rel="noopener noreferrer"&gt;Kalman coined it&lt;/a&gt; around 1960 to ask whether you can infer a system's internal state from its outputs. &lt;code&gt;.status&lt;/code&gt; is also your response to any third-party system. If someone wants to learn the outcome of your actions, &lt;code&gt;.status&lt;/code&gt; is the place to look at.&lt;/p&gt;

&lt;p&gt;That split is the whole declarative model in two fields. It comes with a piece of bookkeeping that's pure control theory: &lt;code&gt;.metadata.generation&lt;/code&gt; increments when desired state changes, and by convention the controller writes back &lt;code&gt;.status.observedGeneration&lt;/code&gt; to say "the state I'm reporting reflects this version of your intent."&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;observedGeneration &amp;lt; generation&lt;/code&gt;, the status you're looking at does not reflect the latest setpoint yet. That one comparison is how you tell "converged" from "still working on it."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrr1cvtmoyn1gu1als0q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrr1cvtmoyn1gu1als0q.png" alt="spec and status" width="800" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is why the reconcile is &lt;strong&gt;stateless&lt;/strong&gt;, and why that matters. Our shell script kept "am I mid-failover?" in a variable that died with the process. A Kubernetes controller keeps nothing important in memory. Every fact it needs is on an API object: the spec it's driving toward, the status it last observed, the conditions describing where things stand. Kill the controller, restart it on another node, and it picks up exactly where it left off, not because it saved its progress, but because there was never any in-memory progress to lose. The state lives in the cluster (API server, &lt;code&gt;etcd&lt;/code&gt; is what holds the state). The controller is just the loop that reads it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-healing by design
&lt;/h3&gt;

&lt;p&gt;This is the part I like most.&lt;/p&gt;

&lt;p&gt;When a controller creates a child object (a Pod, a PVC), it stamps an &lt;code&gt;ownerReference&lt;/code&gt; on the child pointing back at the parent. That reference does two things. It sets up garbage collection: delete the parent, and Kubernetes can cascade the delete to its children. And it gives the controller a way to map child changes back to the parent: "when any object I own changes, enqueue my parent for a reconcile." &lt;code&gt;ownerReference&lt;/code&gt; allows you to link controllers to each other and create chains. If done right, all your controllers and systems fit together.&lt;/p&gt;

&lt;p&gt;Here is an example. Follow the loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A node dies and takes a Pod with it.&lt;/li&gt;
&lt;li&gt;The Pod's deletion is a watch event, an edge.&lt;/li&gt;
&lt;li&gt;Through the ownership link, that edge becomes a reconcile request for the parent.&lt;/li&gt;
&lt;li&gt;The parent reconciles, observes its children (level-triggered), sees one is missing and the count is below the setpoint, and creates a replacement.&lt;/li&gt;
&lt;li&gt;The replacement is an unscheduled Pod, an edge for the scheduler.&lt;/li&gt;
&lt;li&gt;The scheduler detects the unscheduled Pod, assigns a node.&lt;/li&gt;
&lt;li&gt;The kubelet gets triggered because that's an edge for that node's kubelet and it starts the container.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's multiple feedback loops, each watching the layer below, each reacting to an edge and converging to its own level, chained together through the API server with nobody orchestrating the whole thing.&lt;/p&gt;

&lt;p&gt;Control theory has a name for loops stacked like this: &lt;strong&gt;cascade control&lt;/strong&gt;. The output of an outer loop becomes the setpoint of an inner loop. A controller never writes its own &lt;code&gt;.spec&lt;/code&gt;, but it writes &lt;em&gt;other&lt;/em&gt; objects' &lt;code&gt;.spec&lt;/code&gt; all the time. My operator writes the PVC's spec, and that spec is the setpoint the CSI controllers converge to. Each loop worries only about its own layer and trusts the loop below.&lt;/p&gt;

&lt;p&gt;So we wrote &lt;code&gt;if ! pg_isready; then docker start; fi&lt;/code&gt; and maybe thought we're good. Kubernetes turns that one line into several independent controllers that have never heard of each other, but still cooperate because they share the API server and watch each other's objects. I like this part a lot. Nobody calls a central orchestrator. Nobody passes a private message. The system heals itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "observe" actually means in a real operator
&lt;/h3&gt;

&lt;p&gt;Up to here I've been a little vague about the "measure" step, because in the examples the measured state is just "list the child Pods." But in a real database operator it's a lot more than that.&lt;/p&gt;

&lt;p&gt;When an operator I work on reconciles a single Postgres instance, the first thing it does, before it decides anything, is build a snapshot of reality from every source that knows something true about that instance. Not just Kubernetes. Kubernetes barely knows anything about whether Postgres is actually healthy.&lt;/p&gt;

&lt;p&gt;The sources gathered at the top of every reconcile:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Kubernetes cache&lt;/strong&gt;: the Pod, its PVC, the PV behind it, the Node it's on, the ConfigMap holding its config. These are the cheap local reads, the stuff we already talked about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database's effective configuration&lt;/strong&gt;. Not what we last wrote down, but what the server has actually loaded, so we can compare the two and detect drift. Other entities can rewrite or reload the config on disk without us knowing, so the only honest source of truth is the running server itself, never our last write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database's own view of its health&lt;/strong&gt;. Its role, whether it's healthy, how far behind its followers are, whether it's currently accepting writes. Some of this comes from the agents that sit next to the database and manage it; some we get by opening a connection and asking the database directly. These calls carry a tight timeout and are allowed to fail, more on that below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A background collector&lt;/strong&gt;. Some signals are too expensive or too rate-limited to fetch on every reconcile: disk usage, or whether a volume operation we kicked off earlier is still in flight and where it sits in its cooldown window. A separate collector, often a background goroutine, gathers these on a slow cadence and keeps the last value per volume in memory. The reconcile reads that value instantly, without blocking on anything. Think of these as custom workqueues you implement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8z34npwtd78hxdbr7ow7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8z34npwtd78hxdbr7ow7.png" alt="Observation fan-in" width="800" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In code, the snapshot is just a struct, and the reconcile's first move is to populate it. This is simplified, but faithful to the real shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The observation snapshot: everything we know about this instance, right now.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;reconcileHandler&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// The object (.spec = setpoint, .status = measured).&lt;/span&gt;
    &lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostgresInstance&lt;/span&gt;

    &lt;span class="c"&gt;// Kubernetes objects.&lt;/span&gt;
    &lt;span class="n"&gt;pod&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;corev1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pod&lt;/span&gt;
    &lt;span class="n"&gt;pvc&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;corev1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PersistentVolumeClaim&lt;/span&gt;
    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;corev1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;

    &lt;span class="c"&gt;// Database state.&lt;/span&gt;
    &lt;span class="n"&gt;dbState&lt;/span&gt; &lt;span class="n"&gt;DatabaseState&lt;/span&gt;

    &lt;span class="c"&gt;// What Postgres actually loaded, not what we last wrote.&lt;/span&gt;
    &lt;span class="n"&gt;effectiveConfig&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;

    &lt;span class="c"&gt;// Collected out-of-band.&lt;/span&gt;
    &lt;span class="n"&gt;diskUsage&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;

    &lt;span class="c"&gt;// The volume operation already in flight, if any.&lt;/span&gt;
    &lt;span class="n"&gt;storageOp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;StorageOperation&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Reconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;newReconcileHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;inst&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostgresInstance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;reconcileHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;reconcileHandler&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Cheap local reads.&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pvc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fetchKubeObjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Active database calls.&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dbState&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queryDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;effectiveConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readEffectiveConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Values from the collector/metric.&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diskUsage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collector&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pvc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;storageOp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collector&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InFlightOp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pvc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things about this are deliberate, and only look obvious after you've been burned once or twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gather once, at the top&lt;/strong&gt;. Every sub-decision in the reconcile reads from this one snapshot. We don't re-query the database in the middle of the loop, or read the disk usage again three functions deep. If we did, different parts of the same reconcile could see different versions of reality. This sounds like a small detail, but it changes the whole design.&lt;/p&gt;

&lt;p&gt;For example, the database might be the leader when we check at the top and a replica by the time another helper checks again. Then you get decisions that are individually reasonable, but wrong together. We have a rule in the codebase against stashing state back onto this handler mid-reconcile to pass between steps, because it reintroduces exactly the inconsistency we gathered the snapshot to avoid. Making the &lt;code&gt;reconcileHandler&lt;/code&gt; immutable is one way to enforce that rule in the type system instead of relying on code review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial failures are tolerated&lt;/strong&gt;. Reaching the database can fail while the Kubernetes reads succeed. That's not always an error that aborts the reconcile. It's a measured fact: "Postgres is currently unreachable." That itself is something to record in status. A control loop that gives up entirely whenever one sensor is unavailable is a control loop that's down a lot. We degrade instead. Think of a car. If the rain sensor for the wipers is broken, the whole car doesn't stop. You can still drive, but you need to turn on a few things yourself.&lt;/p&gt;

&lt;p&gt;Once the data snapshot exists, the reconcile is a sequence of small, idempotent steps, each comparing one slice of desired against observed and acting to close the gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;reconcileHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reconcile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;
    &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reconcileConfigMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c"&gt;// push desired config&lt;/span&gt;
    &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reconcileDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c"&gt;// reload/restart if params drifted&lt;/span&gt;
    &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reconcilePVC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;         &lt;span class="c"&gt;// grow the disk if needed&lt;/span&gt;
    &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reconcilePod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;         &lt;span class="c"&gt;// create/replace the Pod&lt;/span&gt;
    &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reconcileStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;      &lt;span class="c"&gt;// always last: write what we observed&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Status is written last on purpose, because it's the measured variable: you record what's true after you've taken your actions and observed the result. Again, in our operators, it's not possible to write the status mid-reconcile.&lt;/p&gt;

&lt;p&gt;Each step is independently idempotent. Each returns a result, either "I'm done" or "requeue me in 30 seconds, I'm waiting on something," and the results merge. It reads almost exactly like the body of my shell loop. The difference is that "observe the state" grew from &lt;code&gt;df&lt;/code&gt; and &lt;code&gt;pg_isready&lt;/code&gt; into a fan-in across multiple systems, and "take an action" grew from &lt;code&gt;ssh&lt;/code&gt; into typed, conflict-aware API writes.&lt;/p&gt;

&lt;p&gt;This is the operator. The kubelet, the scheduler, CSI, and CNI are infrastructure we get by using Kubernetes. This loop, with its messy real-world observe step, is the part we actually write and deal with. Because we know how the underlying system works, we can design it without treating Kubernetes like a black box.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not every edge comes from the API server
&lt;/h3&gt;

&lt;p&gt;There's one more piece, and it lets me close a loop from Part 1 that I left deliberately: the disk-usage check.&lt;/p&gt;

&lt;p&gt;My shell script polled df on every node every five seconds. For three nodes, fine. For thousands of databases, you can't reconcile every one of them every few seconds just to check a number that rarely changes; you'd spend all your CPU re-deriving "still at 40%, still at 40%, still at 40%." This is the level-triggered model's one real cost: re-checking everything is correct, but it isn't free.&lt;/p&gt;

&lt;p&gt;The fix is to add a sensor that emits its own edges. A background collector polls our metrics pipeline for disk usage on a slow cadence, keeps the last value per volume in memory, and only emits an event when usage crosses a threshold, not while it sits above or below one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Edge detection. We fire only on the transition across the threshold,&lt;/span&gt;
&lt;span class="c"&gt;// not every cycle we happen to be above it. Hovering at 81% is silent;&lt;/span&gt;
&lt;span class="c"&gt;// crossing 80% upward is an event.&lt;/span&gt;
&lt;span class="n"&gt;crossedUp&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;previousUsage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;pvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GrowThreshold&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;pvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GrowThreshold&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;crossedUp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// -&amp;gt; generic event -&amp;gt; work queue -&amp;gt; reconcile&lt;/span&gt;
    &lt;span class="n"&gt;relay&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That event goes into the same work queue as the API watch events and triggers a normal reconcile of the affected instance. Same rule as before: the event wakes us up, the reconcile decides from the current state.&lt;/p&gt;

&lt;p&gt;For example, say we have a 10GiB disk and it's using 8GiB. The collector saw it cross the threshold, so it wakes the reconciler. The reconciler reads the current usage, sees that it crossed the 80% threshold, and sets a new size on the PVC. After that, CSI handles the rest.&lt;/p&gt;

&lt;p&gt;And because edges can be missed (the collector could be down, an event could be dropped from a full channel), there's a &lt;strong&gt;resyncer&lt;/strong&gt;: a periodic timer that enqueues every object for reconcile every minute or so, regardless of events. It's the safety net. It's our &lt;code&gt;sleep 5 loop&lt;/code&gt;. There's also &lt;code&gt;RequeueAfter&lt;/code&gt;, which a reconcile returns to say "wake me again in 30 seconds," the controller's way of polling a slow external operation without holding a worker.&lt;/p&gt;

&lt;p&gt;There are two more questions: &lt;strong&gt;how often should the loop run, and who is allowed to run it?&lt;/strong&gt; Control theory calls the first one the &lt;em&gt;sampling interval&lt;/em&gt;. The rule of thumb: act faster than the thing you're tracking changes, but not faster than it can respond. Reconciling a disk that fills over hours every few milliseconds just burns CPU to learn the same thing again.&lt;/p&gt;

&lt;p&gt;So the operator puts boundaries around it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;coalescing delay&lt;/strong&gt; handles noisy edge events: a burst of events for one object becomes one reconcile (think of it like a fan-in), not a thousand.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;resyncer&lt;/strong&gt; is the safety net: every object gets looked at once in a while, even when nothing fires.&lt;/li&gt;
&lt;li&gt;And &lt;strong&gt;leader election&lt;/strong&gt; answers the &lt;em&gt;who&lt;/em&gt;: only one copy of the operator runs the loop at a time. Two controllers writing to the same database object is not "more reliable." Even with idempotent controllers, they'll be requeueing due to conflicts and consuming unnecessary compute. In theory, a perfectly written controller should tolerate this. In practice, software is rarely perfect, and the safer boundary is worth it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To close out Part 2, let me redraw the control loop again. The diagram in Part 1 had a few basic boxes. Now, the same loop represents a closed feedback loop more realistically:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3zcigj948k5jzek0wifq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3zcigj948k5jzek0wifq.png" alt="Disturbances" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is one new arrow in this diagram: disturbances. A controller has two jobs. The first is &lt;a href="https://en.wikipedia.org/wiki/Setpoint_%28control_system%29" rel="noopener noreferrer"&gt;setpoint tracking&lt;/a&gt;: someone edits the .spec, and the loop chases the new intent. The second is &lt;a href="https://en.wikipedia.org/wiki/Control_theory" rel="noopener noreferrer"&gt;disturbance rejection&lt;/a&gt;: the world changes on its own. A node dies, a customer starts a bulk import, someone deletes a Pod by hand. The level-triggered reconcile treats both the same way: it only sees the gap.&lt;/p&gt;

&lt;p&gt;Our controller doesn't always touch Postgres directly. Sometimes it writes a PVC and lets CSI do the storage work. Sometimes it creates a Pod and lets the scheduler and kubelet do their part. This is what a production operator looks like: one loop we write, surrounded by other loops we don't write.&lt;/p&gt;

&lt;p&gt;Notice that every decision in this loop has been binary: start the Pod or don't, grow the disk or don't, rewrite the config or don't. That's an &lt;em&gt;on/off controller&lt;/em&gt;, and it covers most of what an operator does. But not every question is yes/no; once the answer becomes &lt;em&gt;how much&lt;/em&gt; rather than &lt;em&gt;whether&lt;/em&gt;, you need a controller with memory and a sense of trend: how long you've been off, and how fast it's changing. That's a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;All of this works, and most of the time it runs without anyone watching it. But the abstractions still leak, and they usually leak at a bad time.&lt;/p&gt;

&lt;p&gt;Eventual consistency and the split between cache reads and API writes mean that a freshly-created object might not be visible to the thing that just created it. When something goes wrong, we're debugging Kubernetes objects, database state, metrics, volume operations, and sometimes the cloud provider at the same time. The bug is usually not in one clean place.&lt;/p&gt;

&lt;p&gt;The declarative model is wonderful until it meets an operation that's inherently imperative and stateful, like a failover, a major-version upgrade, or a data migration. Then you have to turn a blocking, non-idempotent action into an idempotent one. That's a whole other blog post.&lt;/p&gt;

&lt;p&gt;That complexity is easy to underestimate. If you're not dealing with sophisticated systems, if you can sacrifice availability, or if you don't care about scalability, maybe all this machinery isn't needed at all. Operators do not remove complexity. They move it into code someone has to understand.&lt;/p&gt;

&lt;p&gt;I still think it's worth it. For running thousands of databases that have to heal themselves without anyone watching, I don't know a better alternative. The hard parts are hard because the problem is hard, not because Kubernetes made it hard.&lt;/p&gt;

&lt;p&gt;Kubernetes is not only a container runtime. It's not only a YAML processor, or an orchestrator, or whatever word we use that year. For me, the useful way to read Kubernetes is this: &lt;strong&gt;Kubernetes is a framework for feedback controllers&lt;/strong&gt;, plus a consistent store to hold their setpoints and a shared event bus to wake them up.&lt;/p&gt;

&lt;p&gt;Once you see that, the rest fits together. The kubelet, the scheduler, CSI, and your operator all read and write facts onto shared objects, and each one tries to move its own small part of the system toward the desired state. The core idea is still the same one we started with: write down what you want, look at what exists, make the next change, and repeat. Events wake the loop up, but the current state decides what happens.&lt;/p&gt;

&lt;p&gt;Kubernetes didn't invent these ideas; a thermostat had them long before us. The mapping to control theory is not perfect, and some boundaries are fuzzy. But the core idea holds. We are writing feedback loops in Go and applying them to databases. Mechanical and electrical engineers figured out how to build stable, long-running systems before us. Software engineering is still catching up, and Kubernetes gives us a practical way to use those ideas in production.&lt;/p&gt;

</description>
      <category>planetscale</category>
      <category>database</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>See what your database is doing right now with Connections</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 20 Jul 2026 16:21:39 +0000</pubDate>
      <link>https://dev.to/planetscale/see-what-your-database-is-doing-right-now-with-connections-5bdi</link>
      <guid>https://dev.to/planetscale/see-what-your-database-is-doing-right-now-with-connections-5bdi</guid>
      <description>&lt;p&gt;&lt;em&gt;Written by Brett Warminski&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Much of database debugging eventually turns into carefully inspecting what each connection is doing. In Postgres, this means watching &lt;code&gt;pg_stat_activity&lt;/code&gt; in a loop. In Vitess, it means watching &lt;code&gt;SHOW FULL PROCESSLIST&lt;/code&gt; the same way.&lt;/p&gt;

&lt;p&gt;Tools like Query Insights are useful for exploring the recent history of queries. They can tell you what was slow, what's consuming resources, and where to spend tuning effort.&lt;/p&gt;

&lt;p&gt;But during an active incident, the questions are more immediate. What's happening this second? Did the last thing I changed fix it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual monitoring
&lt;/h2&gt;

&lt;p&gt;Here's a manual version of this workflow in Postgres:&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;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tx_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocked_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'idle'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tx_age&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it over and over again in a terminal and it's a pretty effective view of the database.&lt;/p&gt;

&lt;p&gt;It's also a rough interface.&lt;/p&gt;

&lt;p&gt;You're scanning rows as they move around, trying to reconstruct what's blocking progress, and hunting for the one detail that actually matters for the fix.&lt;/p&gt;

&lt;p&gt;The worst version of this problem is when you can't connect at all because the database has exhausted all of its connections. You can't fix what you can't connect to.&lt;/p&gt;

&lt;p&gt;That workflow shaped the design of Connections, a new feature of the pscale CLI available today for PlanetScale Postgres and Vitess (MySQL) databases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simpler debugging with Connections
&lt;/h2&gt;

&lt;p&gt;Here's that same debugging flow using the new &lt;code&gt;pscale branch connections top&lt;/code&gt; functionality with a Postgres database, instead of pasting that &lt;code&gt;pg_stat_activity&lt;/code&gt; query in a loop and comparing output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pscale branch connections top &amp;lt;database&amp;gt; &amp;lt;branch&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connections opens an interactive live view that refreshes about once a second and sorts the sessions most likely to matter toward the top. There are keyboard shortcuts to navigate the list of connections and inspect each one in more detail.&lt;/p&gt;

&lt;p&gt;Columns in the list include the Process ID (PID), status, number of blocked queries, why they're waiting, and more.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxuz6rebire9teu1zn5e0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxuz6rebire9teu1zn5e0.png" alt="Columns in the list include the Process ID (PID), status, number of blocked queries, why they're waiting, and more."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Say your writes are backing up and the app is timing out. In this example, an idle transaction from &lt;code&gt;checkout-api&lt;/code&gt; is holding up three other writes. Open the row, and the blocker tree shows the queue behind it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5i4wrxz6jgumaten2ys.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5i4wrxz6jgumaten2ys.png" alt="Say your writes are backing up and the app is timing out. In this example, an idle transaction from  raw `checkout-api` endraw  is holding up three other writes. Open the row, and the blocker tree shows the queue behind it"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From there you can decide whether the right fix is to cancel a query or terminate the connection. You no longer need to remember the syntax of &lt;code&gt;pg_stat_activity&lt;/code&gt;, retrace the blocker chain by hand or copy and paste PIDs around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep enough history to see the pattern
&lt;/h2&gt;

&lt;p&gt;Another problem with running that query in a loop is that the interesting moment flies by. Connections keeps a recent rolling history, so you can pause, step forward and backward with &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;]&lt;/code&gt;, and see how the state has changed.&lt;/p&gt;

&lt;p&gt;You can also capture a session to a file. You can record everything you see in Connections by pressing &lt;code&gt;C&lt;/code&gt;. This includes the recent history already buffered in memory and keeps appending from there. Perfect for handing off logs to agents to assist with debugging.&lt;/p&gt;

&lt;p&gt;That also makes it easier to write a postmortem, share what happened with a teammate, or replay the same view later instead of describing it from memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Available even when connections are exhausted
&lt;/h2&gt;

&lt;p&gt;The stress of debugging an active incident is worse when you can't even connect to the database yourself.&lt;/p&gt;

&lt;p&gt;Connections uses a reserved administrative connection, so the inspection path still works when regular application connections are exhausted.&lt;/p&gt;

&lt;p&gt;Managed databases should remove the need to SSH into a box, not remove your ability to debug an incident.&lt;/p&gt;

&lt;p&gt;You can still get in, see what is running, and act from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  For Postgres and Vitess
&lt;/h2&gt;

&lt;p&gt;The PlanetScale CLI's new Connections feature also works with Vitess databases (MySQL). In this case, the live view is the PlanetScale version of watching &lt;code&gt;SHOW FULL PROCESSLIST&lt;/code&gt;, with the ability to cancel the current query or terminate the connection from this unified interface.&lt;/p&gt;

&lt;p&gt;The main difference is scope. Vitess connections are shown for one keyspace (and one shard) at a time. If a branch has multiple keyspaces, or a sharded keyspace, pass &lt;code&gt;--keyspace&lt;/code&gt; and &lt;code&gt;--shard&lt;/code&gt; to choose the tablet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pscale branch connections top &amp;lt;database&amp;gt; &amp;lt;branch&amp;gt; &lt;span class="nt"&gt;--keyspace&lt;/span&gt; &amp;lt;keyspace&amp;gt; &lt;span class="nt"&gt;--shard&lt;/span&gt; &amp;lt;shard&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same live monitoring, pause, history, capture, and replay workflow applies. The actions are MySQL-specific: canceling a query runs &lt;code&gt;KILL QUERY&lt;/code&gt;, and terminating a connection runs &lt;code&gt;KILL&lt;/code&gt;. See the &lt;a href="https://planetscale.com/docs/vitess/monitoring/connections" rel="noopener noreferrer"&gt;Inspect live Vitess connections guide&lt;/a&gt; for the full command behavior.&lt;/p&gt;

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

&lt;p&gt;Connections is available for PlanetScale Postgres and Vitess. Update to the latest version of &lt;code&gt;pscale&lt;/code&gt; and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pscale branch connections top &amp;lt;database&amp;gt; &amp;lt;branch&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the &lt;a href="https://planetscale.com/docs/cli/connections" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;, the &lt;a href="https://planetscale.com/docs/postgres/monitoring/connections" rel="noopener noreferrer"&gt;Inspect live Postgres connections guide&lt;/a&gt;, and the &lt;a href="https://planetscale.com/docs/vitess/monitoring/connections" rel="noopener noreferrer"&gt;Inspect live Vitess connections guide&lt;/a&gt; for more details.&lt;/p&gt;

&lt;p&gt;Try it next time you need to troubleshoot active database connections.&lt;/p&gt;

</description>
      <category>database</category>
      <category>planetscale</category>
    </item>
    <item>
      <title>Problem solving with PlanetScale Insights</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 13 Jul 2026 18:53:51 +0000</pubDate>
      <link>https://dev.to/planetscale/problem-solving-with-planetscale-insights-5fd5</link>
      <guid>https://dev.to/planetscale/problem-solving-with-planetscale-insights-5fd5</guid>
      <description>&lt;p&gt;&lt;em&gt;Written by Simeon Griggs&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There are so many ways your database can disappoint you. It'll make your application perform in ways you don't expect and upset your users.&lt;/p&gt;

&lt;p&gt;In a sufficiently complex application, finding and eliminating performance problems can be difficult. Fortunately, PlanetScale gives you the tools to isolate the problem. PlanetScale Insights, available in the dashboard and through the MCP server, provides accurate, up-to-date information on how the queries in your codebase perform in production.&lt;/p&gt;

&lt;p&gt;But with so many different metrics available, how do you differentiate good numbers from bad, signal from noise, or know what the most likely fix is once you've pinned down the problem?&lt;/p&gt;

&lt;p&gt;For this post, I'll walk through exploring Query Insights for a demo e-commerce app connected to a PlanetScale Postgres PS-10 database with a few million rows of data. I set up a flow of constant, regular traffic along with a few "unexpected" spikes.&lt;/p&gt;

&lt;p&gt;PlanetScale Insights also works for PlanetScale Vitess/MySQL databases and has many of the same features. This post focuses only on PlanetScale Postgres.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/OAPHvq51hWU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency timeline graph
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr8wkpmlgvixvzvpsqjie.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr8wkpmlgvixvzvpsqjie.png" alt="Latency timeline graph" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The default view of the PlanetScale Insights dashboard shows query performance, counts, and row reads and writes for the past 24 hours. You can navigate through up to seven days' worth of traffic data.&lt;/p&gt;

&lt;p&gt;Query latency is the best starting point for isolating query performance issues. You can toggle trend lines in the graph on and off; the query list below aligns with the same timeline.&lt;/p&gt;

&lt;p&gt;On this page, latency percentiles are computed from all query pattern executions performed within the observable time window. How fast most runs are versus the slow tail. That is how you differentiate the median run (p50) versus the worst few percent (p99 and above).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;p50&lt;/strong&gt;: Half of this query's executions complete faster than this value, half slower. This is the median latency for that pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;p95&lt;/strong&gt;: 95% of executions complete faster than this; only 1 in 20 are slower. This filter identifies patterns that occasionally misbehave, but tuning them often will not move overall database latency (for example, workload p50) very much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;p99&lt;/strong&gt;: 99% of executions complete faster than this; only 1 in 100 are slower. This is where gains can be made for that pattern's worst runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;p99.9&lt;/strong&gt;: Only 1 in 1,000 executions are slower. These are usually extreme outliers for that pattern: lock contention, cold caches, missing indexes, table scans, and similar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max&lt;/strong&gt;: The single slowest execution of this pattern in the time window. Useful for spotting worst-case scenarios, but a single anomaly can skew this number and may be related to an almost random event that never reoccurs. Always compare it against the percentiles above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a deeper dive on &lt;a href="https://youtu.be/AZD1D9gtB-A" rel="noopener noreferrer"&gt;understanding latency percentiles, watch Ben's video&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F73bgqwxe8mezcfyiu7ad.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F73bgqwxe8mezcfyiu7ad.png" alt="PlanetScale Insights" width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Given this screenshot of Insights from my example application, the tabs at the top show that at 12:05 GMT+1 the p50 is 1.4ms and the p99 is 2s.&lt;/p&gt;

&lt;p&gt;Point-in-time performance numbers can be useful, but execution trends over time matter much more to find real, unexpected outliers.&lt;/p&gt;

&lt;p&gt;From the graph we can see the p99 is consistently far higher than the p50 and p95, with one huge spike where it got as high as 12s.&lt;/p&gt;

&lt;p&gt;Generally, you may think that if "only" 1/100 queries are slow this latency may have a limited blast radius. But if a page load in your application triggers 10s or 100+ queries to your database the impact could be widespread and affect more users than you think.&lt;/p&gt;

&lt;p&gt;These slower p99 queries we need to find and resolve. Let's find the guilty parties.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query list
&lt;/h2&gt;

&lt;p&gt;Below the latency graph, filtered to the same timeline, is a list of queries. From here, you can investigate the performance of each individual query that was run on your database at the same time. There are many columns of data you can read to investigate query performance. Which data is useful to you will depend on what you're debugging.&lt;/p&gt;

&lt;p&gt;If you're not sure which numbers to look for, the tabs on the top right have preconfigured columns.&lt;/p&gt;

&lt;p&gt;For example, if your database consistently shows high CPU usage, click the "Resources" tab to view CPU usage metrics. You can click any column to sort by that metric.&lt;/p&gt;

&lt;p&gt;Since we're looking to fix query latency, we'll click the Performance preset and sort queries by p99 latency (ms).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7b7b0xied1xq92b93msh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7b7b0xied1xq92b93msh.png" alt="click the Performance preset and sort queries by p99 latency (ms)" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note that the Performance preset also includes the "Rows read/returned" column; this is often the simplest identifier of slow queries. It contrasts &lt;strong&gt;rows the engine had to read&lt;/strong&gt; with &lt;strong&gt;rows actually returned&lt;/strong&gt;—when reads are high but returns are low, the database is doing a lot of work per useful row, often because of missing or unsuitable indexes. Most often, these queries can be fixed with an index.&lt;/p&gt;

&lt;p&gt;Solving the response time issues for some of these queries will be simpler than for others. A number of these queries have a little (i) information icon beside them showing that the queries are being performed without an index and may benefit from one.&lt;/p&gt;

&lt;p&gt;(It's also worth noting that some of these queries are slow because they're deliberately bad queries. I needed an exceptionally unoptimized application for this blog post. So, for example, we're not going to "fix" a query for a random product ID.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching for queries
&lt;/h2&gt;

&lt;p&gt;The search box above the query list lets you perform targeted searches for specific queries. You may write part of an SQL query in this box, but there are additional search syntaxes to query by feature, latency, tag, or more. Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;indexed:false&lt;/code&gt; — find all queries not using an index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;index:table_name.index_name&lt;/code&gt; — find all queries using a specific index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;p50:&amp;gt;250&lt;/code&gt; — filter by latency threshold&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;query_count:&amp;gt;1000&lt;/code&gt; — filter by execution count&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tag:key:value&lt;/code&gt; — filter by tag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clicking the &lt;code&gt;SYNTAX&lt;/code&gt; button on the right side of the search box reveals the full set of filters you can use to narrow down your query filtering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other graphs
&lt;/h2&gt;

&lt;p&gt;Along with Query latency there are graphs to show other activity trends in your database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2v9gks8d2ks7bb60xovv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2v9gks8d2ks7bb60xovv.png" alt="activity trends" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Queries&lt;/strong&gt; tab shows total queries per second over time. If latency rises at the same time as query volume, you may be looking at a traffic spike rather than a single query pattern getting worse.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Rows read&lt;/strong&gt; tab shows how many rows the database reads per second. High rows read, especially compared to rows returned in the query list, can indicate that the database is reading unnecessary rows and may benefit from a better index.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Rows written&lt;/strong&gt; tab shows rows written per second over the selected time period. It gives you a separate view of write volume alongside query latency, query count, and rows read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query details
&lt;/h2&gt;

&lt;p&gt;Let's click in to look at an individual query and what Insights can tell us.&lt;/p&gt;

&lt;p&gt;If your application writes raw, sensible SQL, your query might look as simple as this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8wldi2oiv6sa0b05al9a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8wldi2oiv6sa0b05al9a.png" alt="query" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're using an ORM, your query could be incomprehensible at first glance. Fortunately, the "Summarize query" button runs the query pattern through an LLM to describe its purpose in plain English.&lt;/p&gt;

&lt;p&gt;You may also notice the query has been anonymized. Because parameters in a query may contain sensitive information, they're replaced with placeholders when logged in to Insights. In this instance, the search term &lt;code&gt;%turbo%&lt;/code&gt; is rendered as the parameter &lt;code&gt;$1&lt;/code&gt;, but it is not visible in Insights.&lt;/p&gt;

&lt;p&gt;The page of a query pattern also contains a table of &lt;strong&gt;notable queries&lt;/strong&gt;, individual executions that took longer than 1 second, read more than 10,000 rows, or produced an error. This could help determine whether your query is not always slow and perhaps reveal a common time when it runs slower than normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking action on a query
&lt;/h2&gt;

&lt;p&gt;On this page, you can see the same performance graphs as the query list page, but isolated to just this one query. In this screenshot, we see a recommendation from Insights to add an index if the performance is poor. A lot of the time, this is a great idea.&lt;/p&gt;

&lt;p&gt;Unfortunately, because this query is using a wildcard search, a BTREE index won't help.&lt;/p&gt;

&lt;p&gt;If the query were simpler, like below, an index on the &lt;code&gt;name&lt;/code&gt; column would greatly improve performance.&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="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;count&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'turbo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, we may be better off with a GIN trigram index, as they are better designed for wildcard searches. Fortunately, &lt;code&gt;pg_trgm&lt;/code&gt; is a supported extension in PlanetScale Postgres, so I was able to experiment with it. It improved query performance, but only slightly.&lt;/p&gt;

&lt;p&gt;Often, an index can fix a slow query. Other times, slow queries reveal bad schema or application design. Both are important to resolve; the latter is just a little more complicated, as you may need to rip and replace the query in order to improve application performance.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The most common mistake of a smart engineer is to optimize a thing that should not exist."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmxc2cqgknrvlnzg27kr6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmxc2cqgknrvlnzg27kr6.png" alt=" " width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the selected query is using indexes, statistics are shown below the latency graphs, along with tags attached to that query (more in the section below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Insights MCP
&lt;/h2&gt;

&lt;p&gt;Fortunately, there's never been a better time to fix complex problems.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://planetscale.com/docs/connect/mcp" rel="noopener noreferrer"&gt;PlanetScale MCP server&lt;/a&gt; has access to the same data you're able to browse in the dashboard. This means you can task an agent with finding and suggesting fixes for slow queries within your codebase. With your application as its context and real-world production data available via tool calls to Insights, you no longer have excuses for slow database queries.&lt;/p&gt;

&lt;p&gt;At PlanetScale, we have workflows configured to do this daily. See the video below for more details.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/T7aof_ilvkQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;In the case of our slow query that can't be fixed with an index, this is a great job for an agent. It can not only read Insights data but also perform queries on its own. While experimenting with indexes on this database, I observed the agent reading the output of &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; to ensure the index was being used and to report the impact on results.&lt;/p&gt;

&lt;p&gt;Consider a prompt something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I need you to help resolve a slow database query in this application. Make suggestions on whether we can resolve this by adding an index. If so, let's test the results before and after. Additionally, we may need to rethink the query and consider whether there are more efficient ways to obtain the same data to improve application performance."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To help keep your agent focused, include the details of the PlanetScale database in your application's &lt;code&gt;AGENTS.md&lt;/code&gt;, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## PlanetScale&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Organization: ready-set-go
&lt;span class="p"&gt;-&lt;/span&gt; Database: tutorial-insights
&lt;span class="p"&gt;-&lt;/span&gt; Branch: main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: MCP permissions are set when you authenticate the server. It is not advised to give an agent write access to your production database.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Grouping queries with tags
&lt;/h2&gt;

&lt;p&gt;So far, we've looked at identifying queries by grouping together the slow ones. There are other reasons to group queries together, though, which can help with debugging as well as improve performance.&lt;/p&gt;

&lt;p&gt;On the Tags page, we can see queries grouped by metadata related to them. There are built-in key-value pairs, such as the application name and remote address of the connection that ran the query.&lt;/p&gt;

&lt;p&gt;Custom metadata can be included with queries as SQLCommenter comments. Not all ORMs support comments; check your documentation if you are not writing raw SQL.&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="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;count&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;name&lt;/span&gt; &lt;span class="k"&gt;ilike&lt;/span&gt; &lt;span class="s1"&gt;'%turbo%'&lt;/span&gt;
&lt;span class="cm"&gt;/* application='store', action='search' */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These comments are then logged as key-value pairs as queries are performed, allowing you to investigate the performance of a specific subset of queries based on their application, intention, and more.&lt;/p&gt;

&lt;p&gt;So if you're not debugging "why is this query slow," but instead "why is this section of the application slow," you might benefit from grouping that section's queries with the same tag.&lt;/p&gt;

&lt;p&gt;For more on tags, see &lt;a href="https://planetscale.com/blog/enhanced-tagging-in-postgres-query-insights" rel="noopener noreferrer"&gt;Enhanced tagging in Postgres Query Insights&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Tags are also the backbone of Traffic Control, the killer app of PlanetScale Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traffic Control
&lt;/h2&gt;

&lt;p&gt;Some slow queries are unavoidable. We've already determined that our application has a slow query that can't be easily fixed with an index. One option is to remove it entirely in favor of something else. An alluring third option is to put controls on how many resources the query can actually use.&lt;/p&gt;

&lt;p&gt;Traffic Control allows you to do just that. Where timeouts in Postgres can be used as a blunt instrument to stop queries running over a certain time, Traffic Control gives you fine-grained control over how many resources a query can consume, as well as controls over concurrency and more. Perhaps our slow search query actually only runs from an admin panel.&lt;/p&gt;

&lt;p&gt;So it's less of a concern that a single query is slow, but more of a concern if multiple administrators run it concurrently and bring down the database's performance.&lt;/p&gt;

&lt;p&gt;The same tags we applied to observe a category of query behavior can have "resource budgets" applied to them to limit the amount of resources they are permitted to consume.&lt;/p&gt;

&lt;p&gt;Insights now identifies slow queries, recommends improvements, and controls whether they can run at all.&lt;/p&gt;

&lt;p&gt;See more in the &lt;a href="https://planetscale.com/docs/postgres/traffic-control" rel="noopener noreferrer"&gt;Traffic Control documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continual improvement
&lt;/h2&gt;

&lt;p&gt;So far, we've covered manual performance investigation. You and your agent are digging through Insights for improvements. As your application runs, Insights also gathers its own data on anomalous behavior and preemptively suggests upgrades.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpqsz48rf6ztszsqrky79.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpqsz48rf6ztszsqrky79.png" alt="As your application runs, Insights also gathers its own data on anomalous behavior and preemptively suggests upgrades." width="799" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Anomalies&lt;/strong&gt; page highlights when database performance is well outside the expected range. This can reveal unexpected query patterns, traffic spikes, or other problems with your database.&lt;/p&gt;

&lt;p&gt;If you have an anomaly in your Insights dashboard, you can click in to see more details about the time of the anomaly and which queries contributed to it. Match this timeframe against any other application logging platforms you have to identify the root cause. It could be an unexpected one-time outlier, or it could be the result of recently updated application code, and is likely to repeat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://planetscale.com/docs/vitess/monitoring/anomalies" rel="noopener noreferrer"&gt;Learn more in the Anomalies documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Insights also monitors traffic to regularly produce schema recommendations. These may include the index suggestion we saw earlier, or other helpful tips to potentially improve the health of your database.&lt;/p&gt;

&lt;p&gt;Recommendations typically include SQL statements you can run in your database to take action.&lt;/p&gt;

&lt;p&gt;Both the &lt;strong&gt;Anomalies&lt;/strong&gt; and &lt;strong&gt;Recommendations&lt;/strong&gt; data found within Insights are available from the PlanetScale MCP server if you would like an Agent to help you decide whether to take action on the database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgc2p2m5hgavgcdzbpk3k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgc2p2m5hgavgcdzbpk3k.png" alt="PlanetScale Insights Recommendations" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Error tracking
&lt;/h2&gt;

&lt;p&gt;Slow queries aren't the only problem Insights can surface. The &lt;strong&gt;Errors&lt;/strong&gt; page captures every database error from the past 24 hours and plots them on a timeline, letting you spot patterns you'd otherwise miss in application logs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg58h4ur954oagrmf0qo9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg58h4ur954oagrmf0qo9.png" alt="PlanetScale Insights Errors" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In my demo store, I simulated a retry storm during checkout — a flaky network that caused the same order to be submitted multiple times with the same idempotency key. The errors tab immediately surfaced the &lt;code&gt;duplicate key value violates unique constraint&lt;/code&gt; message on the &lt;code&gt;orders_idempotency_key_key&lt;/code&gt; index. Clicking into it revealed each occurrence: the exact query, when it ran, how long it took, and the tags I'd attached to identify the &lt;code&gt;checkout&lt;/code&gt; action. From there, I could see the errors clustered in tight bursts, a telltale sign of retries hitting the same unique constraint rather than a systemic problem.&lt;/p&gt;

&lt;p&gt;This is the kind of issue that often goes unnoticed. The application catches the exception, retries successfully, and the user never sees a failure — but the database is doing unnecessary work. The Errors page makes these invisible problems visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;PlanetScale Insights is the best way to see how your database actually performs in production, providing you and your agents with the metrics that matter to improve your database schema, queries, or completely change access patterns.&lt;/p&gt;

&lt;p&gt;In a future article, we'll look at how to inspect common database problems by viewing specific metrics in Insights. If there's an issue with your queries you can't yet get to the bottom of, &lt;a href="https://planetscale.com/contact" rel="noopener noreferrer"&gt;let us know&lt;/a&gt;!&lt;/p&gt;

</description>
      <category>database</category>
      <category>planetscale</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Transparency in benchmarking</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 06 Jul 2026 16:56:24 +0000</pubDate>
      <link>https://dev.to/planetscale/transparency-in-benchmarking-3b0b</link>
      <guid>https://dev.to/planetscale/transparency-in-benchmarking-3b0b</guid>
      <description>&lt;p&gt;&lt;em&gt;Written by Ben Dicken&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Database benchmarks are imperfect. They are also useful.&lt;/p&gt;

&lt;p&gt;No benchmark can tell you exactly how a database will perform for your application. Workload shape, data size, region placement, storage, configuration, and cost all matter. But fair benchmarks help customers understand tradeoffs, compare options, and ask better questions before choosing infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DeWitt clause
&lt;/h2&gt;

&lt;p&gt;Many cloud vendors include language in their terms that restricts comparative benchmarking. These restrictions are called &lt;a href="https://en.wikipedia.org/wiki/DeWitt_Clause" rel="noopener noreferrer"&gt;"DeWitt clauses"&lt;/a&gt;, named after database researcher David DeWitt. That is a strange legacy for someone whose work helped move the database industry forward by measuring real systems and publishing results.&lt;/p&gt;

&lt;p&gt;Previously, PlanetScale also included a DeWitt clause in our &lt;a href="https://planetscale.com/legal/aup" rel="noopener noreferrer"&gt;Acceptable Use Policy&lt;/a&gt; (AUP). Recently, we removed this in favor of a more open "Benchmarking" section in our AUP.&lt;/p&gt;

&lt;p&gt;The new section reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You may perform benchmark tests (“Benchmark”) of the Services, provided that the Benchmark is conducted in good faith and uses a fair and transparent methodology. Please refer to PlanetScale's published &lt;a href="https://planetscale.com/blog/on-benchmarking" rel="noopener noreferrer"&gt;benchmarking best practices&lt;/a&gt;. Except with respect to Beta Features, you may disclose the results of the Benchmark. If you perform or disclose, or direct or permit any third party to perform or disclose, any Benchmark of the Services, you (i) will include in such disclosure, and will disclose to PlanetScale, all information necessary to replicate such Benchmark, and (ii) agree that PlanetScale may perform and disclose the results of Benchmarks of your products or services, irrespective of any restrictions on Benchmarks in the terms governing your products and services.&lt;/p&gt;

&lt;p&gt;Any Benchmark must be conducted in accordance with the Agreement, including this Acceptable Use Policy. The Benchmark must not interfere with the Services or misrepresent the configuration, methodology, results, or cost of the Services or any compared service.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Benchmarks have gained a bad reputation because they are frequently conducted poorly. Sometimes this is done with malicious intent, often referred to as "benchmarketing." At other times it is done out of ignorance. Many engineers are not trained in all aspects of fair benchmarking.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new standard
&lt;/h2&gt;

&lt;p&gt;Anyone benchmarking PlanetScale should follow the best practices outlined in our &lt;a href="https://planetscale.com/blog/on-benchmarking" rel="noopener noreferrer"&gt;benchmarking guide&lt;/a&gt;. These practices come from our deep experience benchmarking databases in the cloud where topology, server location, region, workload, and instance type differences materially impact the result.&lt;/p&gt;

&lt;p&gt;We encourage other vendors, analysts, and practitioners to use the same standard. Benchmarks should be deep, thorough, technically sound, and transparent enough for others to understand and reproduce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our ask
&lt;/h2&gt;

&lt;p&gt;We invite other vendors to adopt this same language and standard in their own AUPs. Allow public benchmarking, remove DeWitt clauses, and hold benchmarks to clear expectations for fairness and transparency.&lt;/p&gt;

&lt;p&gt;Customers should be able to compare the systems they rely on.&lt;/p&gt;

</description>
      <category>database</category>
      <category>planetscale</category>
    </item>
    <item>
      <title>Egress problems and where to find them</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 29 Jun 2026 16:16:49 +0000</pubDate>
      <link>https://dev.to/planetscale/egress-problems-and-where-to-find-them-l50</link>
      <guid>https://dev.to/planetscale/egress-problems-and-where-to-find-them-l50</guid>
      <description>&lt;p&gt;&lt;em&gt;Written by Simeon Griggs&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Name something in recent history that got better and cheaper (other than the TVs at the entrance of Costco). I'll wait.&lt;/p&gt;

&lt;p&gt;Better performance and lower costs rarely come together, but optimizing your queries to reduce egress gives you both.&lt;/p&gt;

&lt;p&gt;So once you hit scale, or ideally before scale bites you, improving the efficiency of your queries by making the responses smaller and their frequency lower can pull off a rare double: make your application faster and cheaper.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/MXgCAI_il10"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Definitions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Egress: Data transferred out from your database over the public internet. Most cloud providers bill for this, so it's something we want to minimize.&lt;/li&gt;
&lt;li&gt;Ingress: Data transferred into your database over the public internet. Most cloud providers either do &lt;em&gt;not&lt;/em&gt; bill for this, or do so only in specific scenarios.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PlanetScale includes 100GB of egress on High Availability (HA) plans. Non-HA $5/month Postgres includes 10GB of egress. Usage is &lt;a href="https://planetscale.com/docs/postgres/pricing#public-traffic" rel="noopener noreferrer"&gt;metered&lt;/a&gt; beyond those allowances, so it's worth knowing about and minimizing where possible.&lt;/p&gt;

&lt;p&gt;This post focuses largely on Postgres, but the general principles apply to all databases across all the major cloud providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common culprits
&lt;/h2&gt;

&lt;p&gt;If your egress numbers are approaching the included quota, or exceeding it by more than you’d like, your problems likely stem from two things: you're either fetching too much, too often, or both.&lt;/p&gt;

&lt;p&gt;Consider the case of a content-heavy application. The database is full of documents made of rich text and block content. That content is stored in a JSONB column using the &lt;a href="https://www.portabletext.org/" rel="noopener noreferrer"&gt;Portable Text&lt;/a&gt; specification.&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;integer&lt;/span&gt;                  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;nextval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'posts_id_seq'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;                     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;                     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;     &lt;span class="n"&gt;jsonb&lt;/span&gt;                    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;  &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt;  &lt;span class="n"&gt;posts_pkey&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt;  &lt;span class="n"&gt;posts_slug_unique&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Too much out
&lt;/h2&gt;

&lt;p&gt;Fetching too much is easily done. Performing a &lt;code&gt;SELECT *&lt;/code&gt; query will return every value from every column in every matching result and will return more data as more columns are added. Likewise, "unbounded queries," that is, a query without a limit, will linearly return more data as more matching data exists.&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="c1"&gt;-- ❌ returns unlimited columns and rows&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;posts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- ✅ returns limited columns and rows&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Selecting specific columns has the added benefit of making your code more declarative about the data your application requires. While PlanetScale measures the data transfer size of your queries, it can't make assumptions about how much of that query response was used. The more specific your queries are, the simpler the debugging process becomes.&lt;/p&gt;

&lt;p&gt;For a JSONB column, you may also consider using Postgres' built-in syntax to extract specific values from the data if not all values are required.&lt;/p&gt;

&lt;p&gt;For example, perhaps you want to build a table of contents from level 2 and 3 headings from our Portable Text column. An unspecific query would just return the entire content 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;SELECT&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, we can use the &lt;code&gt;jsonb_agg()&lt;/code&gt; function in Postgres to filter the array of objects down to just the headings we're looking for.&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;jsonb_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;headings&lt;/span&gt;
 &lt;span class="k"&gt;FROM&lt;/span&gt;
   &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;block&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="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'_type'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'block'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'style'&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'h2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h3'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Including JSON filtering will introduce some CPU overhead, so it's a tradeoff. Monitor resource usage and see if the reduced egress is worth it.&lt;/p&gt;

&lt;p&gt;Fetch only the rows, columns, and data from those columns that your application requires.&lt;/p&gt;

&lt;p&gt;Pagination also bounds how much data leaves your database per request. Without it, a growing dataset means ever-larger responses. Two common approaches:&lt;/p&gt;

&lt;p&gt;Offset/limit skips a number of rows and returns a fixed page size. Simple to implement, but the database still scans all skipped rows, so deeper pages cost more.&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- page 1&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- page 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cursor pagination&lt;/strong&gt; uses the last value from the previous page as the starting point. It performs consistently regardless of depth.&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;-- page 1&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;posts&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;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- page 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For more detail on each approach to pagination, see &lt;a href="https://planetscale.com/learn/courses/mysql-for-developers/examples/offset-limit-pagination" rel="noopener noreferrer"&gt;Offset limit pagination&lt;/a&gt; and &lt;a href="https://planetscale.com/learn/courses/mysql-for-developers/examples/cursor-pagination" rel="noopener noreferrer"&gt;Cursor pagination&lt;/a&gt; in the MySQL for Developers course.&lt;/p&gt;

&lt;h2&gt;
  
  
  Too much in
&lt;/h2&gt;

&lt;p&gt;While most cloud providers do not typically charge for ingress, there are instances where your ingress operations quietly result in egress.&lt;/p&gt;

&lt;p&gt;ORMs can have this happen by default when returning data from an insert operation. Here is an example insertion operation using Drizzle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello-world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// ❌ returns everything with no parameters&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&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 plaintext"&gt;&lt;code&gt;The function call above would result in an SQL query like this.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Hello world'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'hello-world'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this particular instance, we're only writing the &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;slug&lt;/code&gt;, so the response is relatively small in terms of bytes transferred. It's worth noting, however, that more columns were returned than were written.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----+-------------+-------------+---------+-------------------------------+-------------------------------+
| id | title       | slug        | content | created_at                    | updated_at                    |
|----+-------------+-------------+---------+-------------------------------+-------------------------------|
| 5  | Hello world | hello-world | []      | 2026-05-11 16:04:03.049885+01 | 2026-05-11 16:04:03.049885+01 |
+----+-------------+-------------+---------+-------------------------------+-------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;content&lt;/code&gt; column is small for now, but if we were writing an &lt;code&gt;UPDATE&lt;/code&gt; to an existing and very large document, it would be returned with every operation.&lt;/p&gt;

&lt;p&gt;Now imagine our content editor upserts changes to an edited document every second. This could be a massive payload of our Portable Text JSON, with every insert operation returning the full body of the inserted item, essentially doubling the operation's egress.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello-world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// ✅ returns only the id column&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Too often
&lt;/h2&gt;

&lt;p&gt;If every user of your application requesting the same data results in a fresh request to your database, you're wasting your egress quota.&lt;/p&gt;

&lt;p&gt;In the simplified diagram below, that means trying to avoid every &lt;strong&gt;user request&lt;/strong&gt; from triggering fresh egress to generate a &lt;strong&gt;response&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsgbrh6edq2wuvkoj0iio.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsgbrh6edq2wuvkoj0iio.png" alt="egress and ingress diagram" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Caching and Content Delivery Networks (CDNs) exist largely to improve performance. One way they achieve this is by reducing data transfer. By loading a local copy of the data your application needs instead of fetching it fresh from the database.&lt;/p&gt;

&lt;p&gt;An application-level cache (like Redis) between your database and application, or a network-level cache (like a CDN) between your application and a user, can help reduce the frequency of requests to your database.&lt;/p&gt;

&lt;p&gt;Preventing unnecessary work in your database is increasingly important as your dataset grows and the frequency of requests increases. A single JSONB column of Portable Text, for example, could get into megabytes in size, and you won't want it requested from the database with each page load, should your article hit the front page of Hacker News.&lt;/p&gt;

&lt;h2&gt;
  
  
  Too internet
&lt;/h2&gt;

&lt;p&gt;Egress is typically charged when data travels over the public Internet. PlanetScale supports AWS PrivateLink and GCP Private Service Connect to improve security and reduce egress costs (another win-win combo).&lt;/p&gt;

&lt;p&gt;If your application is hosted within the same infrastructure as your database (and it should be), you may be able to use either of these private connections to skip this public internet hop.&lt;/p&gt;

&lt;p&gt;PlanetScale charges much lower rates for data transferred over these private connections, however, both ingress and egress are billed. See the documentation for more pricing details and to see if this is an option for you.&lt;/p&gt;

&lt;p&gt;Read more: &lt;a href="https://planetscale.com/docs/postgres/connecting/private-connections" rel="noopener noreferrer"&gt;Private connections in the PlanetScale docs&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Identifying egress usage
&lt;/h2&gt;

&lt;p&gt;PlanetScale Postgres offers us ways to measure the bytes returned by individual queries, but not to observe egress bytes usage patterns over time. Let's look first at what it takes to measure a query.&lt;/p&gt;

&lt;h2&gt;
  
  
  With EXPLAIN
&lt;/h2&gt;

&lt;p&gt;If we prepend &lt;code&gt;EXPLAIN&lt;/code&gt; to the same unbounded, unspecific query as before, we're shown the query plan for the response.&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;EXPLAIN&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;posts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----------------------------------------------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;QUERY&lt;/span&gt; &lt;span class="n"&gt;PLAN&lt;/span&gt;                                               &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="c1"&gt;----------------------------------------------------------|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Seq&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;560&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;116&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----------------------------------------------------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query plan shows us &lt;code&gt;rows=560&lt;/code&gt;, an estimate of the number of rows returned, and &lt;code&gt;width=116&lt;/code&gt;, an estimate of the size of each row. These estimates are based on averages and won't reflect the size of any particular row, especially for variable-length columns like JSONB.&lt;/p&gt;

&lt;p&gt;The only way to accurately measure the transfer size of a query is to run it. Let's measure the difference between querying the full content column of a post compared to just extracting the headings.&lt;/p&gt;

&lt;p&gt;We could use &lt;code&gt;pg_column_size()&lt;/code&gt; to measure the size of the content column, but it would return the TOAST-compressed size, not the size of the data being sent over the wire. &lt;code&gt;octet_length()&lt;/code&gt; will return a closer approximation of the relative size.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: Postgres uses &lt;a href="https://www.postgresql.org/docs/current/storage-toast.html" rel="noopener noreferrer"&gt;TOAST&lt;/a&gt; (The Oversized-Attribute Storage Technique) to compress and store large values, such as our JSONB column, so its on-disk size is dramatically smaller than its measured egress size. TOAST-compressed data is decompressed and serialized before being sent over the wire.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Full content column&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;octet_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&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;posts&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="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="c1"&gt;----------------|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;37&lt;/span&gt; &lt;span class="n"&gt;kB&lt;/span&gt;          &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----------------+&lt;/span&gt;

&lt;span class="c1"&gt;-- Just the headings&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;octet_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonb_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&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;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;block&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="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'_type'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'block'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'style'&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'h2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'h3'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="c1"&gt;----------------|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;5127&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;----------------+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less data is smaller, big surprise!&lt;/p&gt;

&lt;p&gt;This is useful information for this specific query, but measuring queries individually is tedious. Ideally, we want to monitor the size of every query generated by our application and see usage patterns over their lifetime. Fortunately, Insights does this for us.&lt;/p&gt;

&lt;h2&gt;
  
  
  With Insights
&lt;/h2&gt;

&lt;p&gt;PlanetScale Insights monitors the queries performed in your database. These statistics can be viewed in the dashboard and are made available to agents via the PlanetScale MCP server.&lt;/p&gt;

&lt;p&gt;Often, developers use Insights to measure query latency to improve performance, but it also provides many other statistics, such as bytes returned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6ek6y65hn9d8bl6dgdv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6ek6y65hn9d8bl6dgdv.png" alt="PlanetScale Insights" width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open Insights and from the query list select the "Data" tab. These tabs contain preset columns relevant to debugging specific scenarios. Here we've sorted by "Bytes returned per query" and can see the largest transfer size of all queries in the currently selected time period.&lt;/p&gt;

&lt;p&gt;Consider a query that returns 37 KB per call. Run 100 times, it transfers less than 4 MB and is probably not worth optimizing. Run 100,000 times, it transfers nearly 4 GB. Sort by the queries with the highest total bytes returned to find improvements which may have the most impact.&lt;/p&gt;

&lt;p&gt;Look for frequently run, large-byte-transferred queries to identify opportunities for improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Egress and ingress metrics
&lt;/h2&gt;

&lt;p&gt;For PlanetScale Postgres databases, the overall volume of egress and ingress can also be measured in the Metrics tab. At the bottom of this tab are graphs for ingress and egress.&lt;/p&gt;

&lt;p&gt;From here, you can look for spikes that correlate with queries run at particular times to find any outliers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxwyz9q302qjuxxd8yzbh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxwyz9q302qjuxxd8yzbh.png" alt=" " width="799" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read more: &lt;a href="https://planetscale.com/docs/postgres/monitoring/metrics" rel="noopener noreferrer"&gt;Metrics in the PlanetScale docs&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tagging classes of queries
&lt;/h2&gt;

&lt;p&gt;Additionally, on PlanetScale Postgres, if you know your application contains several related queries you'd like to monitor collectively for egress or performance, query tagging is a way to link them.&lt;/p&gt;

&lt;p&gt;Query tags are added using the SQL Commenter format. By adding tags, for example, we can tag every query that requests or updates a row with &lt;code&gt;portable-text&lt;/code&gt; so that we can measure all these queries together.&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt;
    &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="cm"&gt;/* returns='portable-text' */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the "Tags" page in the dashboard, we can now view queries with just this tag and measure their transfer sizes more cleanly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd4s17kkpuh1jvj6cdf7d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd4s17kkpuh1jvj6cdf7d.png" alt="Tags in PlanetScale" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read more: &lt;a href="https://planetscale.com/docs/postgres/monitoring/query-tags" rel="noopener noreferrer"&gt;Tags in the PlanetScale docs&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Don't wait until things start getting expensive before thinking about egress. Optimizing early can result in more declarative queries, cleaner code, faster responses, and lower resource demands on your database.&lt;/p&gt;

&lt;p&gt;Connect your agent to the &lt;a href="https://planetscale.com/docs/connect/mcp" rel="noopener noreferrer"&gt;PlanetScale MCP server&lt;/a&gt; and prompt your agent to find opportunities to improve your application's database egress usage.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;From the point of view of an application developer that understands efficient database usage patterns, interrogate our code base for examples where we are querying for columns of data that the application is not using, returning data from updates or inserts that we do not need, or improvements to reduce the frequency or quantity of queries performed for the same data. Read &lt;a href="https://planetscale.com/blog/database-egress" rel="noopener noreferrer"&gt;https://planetscale.com/blog/database-egress&lt;/a&gt; for more details.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>planetscale</category>
      <category>database</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The only scalable delete in Postgres is DROP TABLE</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:06:03 +0000</pubDate>
      <link>https://dev.to/planetscale/the-only-scalable-delete-in-postgres-is-drop-table-1596</link>
      <guid>https://dev.to/planetscale/the-only-scalable-delete-in-postgres-is-drop-table-1596</guid>
      <description>&lt;p&gt;&lt;em&gt;Written by Tom Pang&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Counterintuitively, large &lt;code&gt;DELETE&lt;/code&gt;s add work to the database.&lt;/p&gt;

&lt;p&gt;From experience we can plainly claim the following: the most scalable Postgres data-deletion strategies revolve around deleting entire tables.&lt;/p&gt;

&lt;p&gt;Individual row &lt;code&gt;DELETE&lt;/code&gt; is fine at a small scale. However, big batch &lt;code&gt;DELETE&lt;/code&gt; operations don't immediately free up physical disk space, add write and replication overhead, and are ultimately not good for large scale row cleanup.&lt;/p&gt;

&lt;p&gt;If your application needs to delete large amounts of data, even very rarely, we recommend moving towards schema designs that let you express that as a &lt;code&gt;DROP TABLE&lt;/code&gt; or a &lt;code&gt;TRUNCATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Let's study why this is by looking at how &lt;code&gt;DELETE&lt;/code&gt; works in Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deletes hurt
&lt;/h2&gt;

&lt;p&gt;When rows mutate, Postgres can maintain multiple versions of the same row, so that different transactions can see row values as of the time they were queried. This is Postgres' implementation of "Multi-Version Concurrency Control" (MVCC) and a core principle of its design.&lt;/p&gt;

&lt;p&gt;Postgres makes an intentional tradeoff here. It stores modified and deleted rows alongside current ones, relying on transaction IDs and visibility maps to skip over "dead tuples." Later on, a vacuum process comes along and says, "Hey, these bytes in this heap page are now free, you can overwrite them."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2oveglqdfjdt9q8a97s6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2oveglqdfjdt9q8a97s6.png" alt="Postgres stores modified and deleted rows alongside current ones" width="800" height="855"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Deletes also need to be fully replicated; they are still a work of writes, which means large-scale &lt;code&gt;DELETE&lt;/code&gt;s can impact other writers to your application and cause them to wait for the &lt;code&gt;DELETE&lt;/code&gt; replication to finish (under synchronous and semi-synchronous replication).&lt;/p&gt;

&lt;p&gt;It's worth noting here that &lt;code&gt;DELETE&lt;/code&gt; or even autovacuum doesn't typically return data to the operating system; they only say "the space in those pages can be written over." This is an intentional choice by Postgres. It optimizes for the case where &lt;code&gt;DELETE&lt;/code&gt; workloads are mixed with &lt;code&gt;INSERT&lt;/code&gt; ones, and releasing space to the operating system and then asking for it back is relatively expensive and should be avoided. &lt;code&gt;VACUUM FULL&lt;/code&gt; allows for this, but takes an expensive lock for a long time.&lt;/p&gt;

&lt;p&gt;Another related tradeoff Postgres makes is that index data is not touched at all when issuing a &lt;code&gt;DELETE&lt;/code&gt;; instead, readers reading the index have to resolve "is this tuple dead." There's also a best-effort optimization where an index scan that finds a dead row can mark the entry as dead itself.&lt;/p&gt;

&lt;p&gt;Overall, &lt;code&gt;DELETE&lt;/code&gt; is really "work added," not "work done." If you want more details on Postgres MVCC, see &lt;a href="https://planetscale.com/blog/keeping-a-postgres-queue-healthy" rel="noopener noreferrer"&gt;Keeping a Postgres queue healthy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're running a &lt;code&gt;DELETE&lt;/code&gt; over a large amount of data, you can imagine how it adds work to every read query and autovacuum. Be aware that using foreign keys and &lt;code&gt;CASCADE&lt;/code&gt; for deletions can cause a single row delete to delete gigabytes of data, resulting in the same set of problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drop DELETE for DROP
&lt;/h2&gt;

&lt;p&gt;In contrast, &lt;code&gt;DROP TABLE&lt;/code&gt; and &lt;code&gt;TRUNCATE&lt;/code&gt; require a heavyweight &lt;code&gt;AccessExclusiveLock&lt;/code&gt; on the table, but are loosely independent of data size. At the physical layer they remove files from the operating system directly, plus sweep the Postgres buffer cache to remove pages related to the table.&lt;/p&gt;

&lt;p&gt;That sweep can be less trivial on databases with large shared buffers, but it is only a metadata sweep. Postgres keeps a small fixed-size header (a &lt;code&gt;BufferDesc&lt;/code&gt;, padded to 64 bytes) for every 8KB buffer, and dropping a table scans those headers, not the pages themselves. At 64 bytes per 8KB page, that's 1/128th of the cache size: with 128GB of shared buffers, you are sweeping only ~1GB of memory, sequentially, which is very fast on modern hardware.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DROP TABLE&lt;/code&gt; and &lt;code&gt;TRUNCATE&lt;/code&gt; scale much better than &lt;code&gt;DELETE&lt;/code&gt;. They produce zero dead tuples, zero vacuum debt, zero work for readers. They immediately free up space for the operating system.&lt;/p&gt;

&lt;h2&gt;
  
  
  A performant one-off delete
&lt;/h2&gt;

&lt;p&gt;One common case where folks need to delete large amounts of data is "my table is full of junk due to a bug." We encountered this recently in an internal observability tool. A bug caused the tool to write millions of rows that we wanted to delete from the database. The bad rows had an old &lt;code&gt;updated_at&lt;/code&gt; timestamp; anything with a recent one was designed to be kept. There were only a few hundred thousand rows to keep; most of the data was junk.&lt;/p&gt;

&lt;p&gt;For this case, especially because "lock the database for minutes" was not an issue at all, we performed some surgery, leaning on Postgres' transactional DDL:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;BEGIN&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Explicit &lt;code&gt;LOCK TABLE ... IN ACCESS EXCLUSIVE MODE&lt;/code&gt; on the table in question; this prevents other transactions from reading or writing the table, so we get consistent data.&lt;/li&gt;
&lt;li&gt;Create a temporary table to hold just the kept data, like so:
&lt;/li&gt;
&lt;/ol&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;TEMP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;temp_keep_big_table&lt;/span&gt; &lt;span class="k"&gt;AS&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;big_table&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-04-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;code&gt;TRUNCATE big_table;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INSERT INTO big_table SELECT * FROM temp_keep_big_table;&lt;/code&gt;. In our example, this took a handful of minutes to process on a very small instance with hundreds of thousands of rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1lkl7g09zaeaejfr4yjx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1lkl7g09zaeaejfr4yjx.png" alt=" " width="799" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This worked very well for a one-off; the only data written to the Write Ahead Log (WAL) are the reinserted rows in the &lt;code&gt;big_table&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If holding an &lt;code&gt;AccessExclusiveLock&lt;/code&gt; on the table for minutes during &lt;code&gt;TRUNCATE&lt;/code&gt; is unacceptable, use a trigger-based approach instead: mirror writes to a new table, then swap with an atomic rename.&lt;/p&gt;

&lt;p&gt;You should also know that this more advanced maneuver is roughly what the Postgres extension &lt;code&gt;pg_squeeze&lt;/code&gt; (a more modern version of &lt;code&gt;pg_repack&lt;/code&gt;) does. &lt;code&gt;pg_squeeze&lt;/code&gt; is for optimizing tables that already have significant bloat. This blog post is really about preventing bloat in the first place. By structuring your schema to avoid large bulk &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;pg_squeeze&lt;/code&gt; becomes less necessary.&lt;/p&gt;

&lt;p&gt;In cases where the data to keep is much larger than the data to discard, but the data to discard is still substantial, the typical approach is to perform many isolated batched deletes in a loop, e.g., 10,000 rows at a time. This keeps transactions short, avoids lock pileups, and lets you pace things so that autovacuum keeps up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres partitions for ongoing deletes
&lt;/h2&gt;

&lt;p&gt;Since version 10, Postgres has had great partitioning support. A "parent" table can have "child" tables, and queries can be automatically routed to them. Postgres supports a variety of partitioning schemes; one that is extremely useful is date-based partitioning, but many others are available.&lt;/p&gt;

&lt;p&gt;Partitioning can transform a workload that does "lots of &lt;code&gt;DELETE&lt;/code&gt;" into a workload that does "occasional &lt;code&gt;DROP TABLE&lt;/code&gt;." For example, if you have historical data that needs to be aged out, you can have a child partition per day, and a periodic process that deletes older child partitions (or use the &lt;code&gt;pg_partman&lt;/code&gt; extension).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbyrlamdre4tc6uknkt48.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbyrlamdre4tc6uknkt48.png" alt=" " width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can go further still. Partitioning in Postgres is recursive, so you could partition the top level by &lt;code&gt;LIST&lt;/code&gt; (e.g., a "visible" rows partition), then partition the "not visible" child table by &lt;code&gt;RANGE&lt;/code&gt; to age out old data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go forth and DROP
&lt;/h2&gt;

&lt;p&gt;Structuring your schema and application so that large-scale &lt;code&gt;DELETE&lt;/code&gt; becomes &lt;code&gt;DROP&lt;/code&gt; or &lt;code&gt;TRUNCATE&lt;/code&gt; can dramatically improve your database. It helps reduce read query latency in some cases, mitigates replication lag spikes, and overall improves database health.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>planetscale</category>
      <category>database</category>
      <category>data</category>
    </item>
    <item>
      <title>On benchmarking</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Tue, 16 Jun 2026 15:56:24 +0000</pubDate>
      <link>https://dev.to/planetscale/on-benchmarking-fb8</link>
      <guid>https://dev.to/planetscale/on-benchmarking-fb8</guid>
      <description>&lt;p&gt;&lt;em&gt;Written by Ben Dicken&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Benchmarking is hard. There are many ways to do it wrong and few to do it right.&lt;/p&gt;

&lt;p&gt;But zooming out from any single system or harness, there are broad principles that should be applied to all benchmarking. Using these correctly makes it difficult to produce biased results.&lt;/p&gt;

&lt;p&gt;Am I the world's best benchmarker? Certainly not. I invented the &lt;a href="https://x.com/BenjDicken/status/1861072804239847914" rel="noopener noreferrer"&gt;language balls&lt;/a&gt;, after all. But correctness and precision are important parts of PlanetScale's culture. We've spent considerable time learning the art of benchmarking, and are here to share best-practices.&lt;/p&gt;

&lt;p&gt;Here, we're focusing primarily on benchmarking &lt;em&gt;databases&lt;/em&gt;, but these principles apply to many domains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client-server architecture
&lt;/h2&gt;

&lt;p&gt;Databases typically operate in a client-server model. The database server is started, accepts connections from clients, executes queries, and returns results.&lt;/p&gt;

&lt;p&gt;To benchmark, we need a client that establishes the connections, generates queries, and takes measurements. Since both sides consume resources and we want to give the &lt;em&gt;database&lt;/em&gt; its full share of the host server, it's common to set up a distinct server for benchmark execution.&lt;/p&gt;

&lt;p&gt;As usual, &lt;em&gt;there's a catch&lt;/em&gt;. This introduces latency between the two machines.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fajg6jcaj3rmn0in2ri2i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fajg6jcaj3rmn0in2ri2i.png" alt="latency between two machines" width="800" height="762"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How much this skews the results of the benchmark depends quite a bit on how "far apart" the benchmark server and database server are (network latency) and how long the queries / transactions take on the database (execution latency).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd11h359iokr2kvu3zrs9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd11h359iokr2kvu3zrs9.png" alt="network latency" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's consider a scenario where each query takes ~10ms to execute on the database. If the network round-trip time is 2.5 milliseconds, then we can execute approximately 80 queries per second over a single connection. On the other hand, what if the round-trip is 15 milliseconds? We've now cut our single-threaded QPS capability in ~half, resulting in 40 QPS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakmd2h7pihagzx64dumh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakmd2h7pihagzx64dumh.png" alt="cutting our single-threaded QPS capability in ~half, resulting in 40 QPS" width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same database. Same benchmark client. The only difference is the speed at which bytes can go over the wire between the two.&lt;/p&gt;

&lt;p&gt;This latency variation will always have an impact on latency measurements.&lt;/p&gt;

&lt;p&gt;It &lt;em&gt;can&lt;/em&gt; also impact throughput. We often don't run benchmarks on a single connection. We'll do 10, 50, or 100 simultaneous connections to best utilize the parallelism of the machine and database. But if we have a fixed connection count, and are not making it dynamic to account for round-trip latency, we can end up allowing the elevated latency to hurt throughput.&lt;/p&gt;

&lt;p&gt;Finally, you should double-check that the client server is not a bottleneck. While benchmarking, ensure that CPU and network utilization are well under their capacity. We want to be straining the database server, not the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing resources
&lt;/h2&gt;

&lt;p&gt;It's easy to make one database look better than another with an imbalance of resources. Postgres running on a 16-core server will almost always perform better than on an 8-core server.&lt;/p&gt;

&lt;p&gt;An important prerequisite to proper benchmarking is setting up the compute, storage, and networking resources to allow for a fair fight.&lt;/p&gt;

&lt;p&gt;This isn't as easy as it sounds, especially when we're talking about running things in the hyperscaler clouds like AWS and GCP. For example, the Geekbench results for an AWS &lt;a href="https://browser.geekbench.com/v6/cpu/2119560" rel="noopener noreferrer"&gt;r7g.2xlarge&lt;/a&gt; are ~15% lower than the results for an &lt;a href="https://browser.geekbench.com/v6/cpu/11335856" rel="noopener noreferrer"&gt;r8g.2xlarge&lt;/a&gt;. Both have 8 vCPUs and 64 GB RAM. But move one generation newer, and there's a ~15% CPU improvement.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fchpc5cx918dafxvjbvep.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fchpc5cx918dafxvjbvep.png" alt="CPU improvement" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You might then be tempted to just use the same instance for everything, but this breaks down too. The availability of instance types varies over time, region, and database provider. In some cases, it's not possible to match.&lt;/p&gt;

&lt;p&gt;In an ideal world, we'd run everything on the exact &lt;em&gt;same&lt;/em&gt; instance. In reality, we sometimes have to settle for matching CPUs and RAM as best we can, and living with the differences. However, you must give this your best effort. Purposefully choosing to benchmark your product on 2025-gen CPU and then comparing to a competitor's product on a 2022 CPU, when the alternate was readily available, is intentionally misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workload
&lt;/h2&gt;

&lt;p&gt;Even once we know that our infrastructure is set up sanely, there's a lot to consider for the workload we run.&lt;/p&gt;

&lt;p&gt;The easiest way to think about this is in terms of traffic ratios.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many queries are hitting RAM vs disk?&lt;/li&gt;
&lt;li&gt;What % of the data is hot (frequently queried) vs cold (rarely queried)?&lt;/li&gt;
&lt;li&gt;What's the ratio of reads to writes?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these impact performance, especially when combined with the variations of underlying hardware.&lt;/p&gt;

&lt;p&gt;Queries executed on a relational database often require some amount of I/O work. Writing data must always be persisted to disk. &lt;em&gt;Reading&lt;/em&gt; data can come from the in-memory cache, or disk on cache misses.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F60qcsv1ys93tsiagw7zr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F60qcsv1ys93tsiagw7zr.png" alt="queries executed on a relational database" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some databases operate on local SSDs, while others use network-attached storage like AWS EBS or Google Persistent Disk. Some even take a hybrid approach. Either way, the percent of read traffic hitting RAM vs disk impacts performance due to I/O wait times.&lt;/p&gt;

&lt;p&gt;Consider a benchmark like &lt;a href="https://github.com/akopytov/sysbench/blob/master/src/lua/oltp_read_only.lua" rel="noopener noreferrer"&gt;sysbench OLTP read-only&lt;/a&gt;. This is a simple, read-only benchmark that runs a handful of select query patterns repeatedly. As benchmarks often do, the data size is configurable in the preparation phase. If we run this benchmark on a server with 64 GB of RAM and a 32 GB data size, the entire data set will fit in RAM after warming. The same benchmark run with a 320 GB data size will generate significant I/O and inevitably run slower.&lt;/p&gt;

&lt;p&gt;This is related to, but not the same as, data distribution.&lt;/p&gt;

&lt;p&gt;Even for a fixed data size, access patterns can vary widely. The simplest examples are uniform and Zipfian.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlrogav7q5p0txo01li7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlrogav7q5p0txo01li7.png" alt="Fixed data size access patterns" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A &lt;em&gt;uniform&lt;/em&gt; access pattern gives every row the same chance of being queried on each request. If we have 100 rows, each has a 1% chance of being read for each operation.&lt;/p&gt;

&lt;p&gt;A &lt;em&gt;Zipfian&lt;/em&gt; access pattern is skewed: the k-th most popular key is accessed roughly proportional to 1/k. A small number of hot rows receive a large share of requests, while most rows are accessed rarely.&lt;/p&gt;

&lt;p&gt;These are only simple models. Real workloads often have messier shapes: recently inserted rows might be hotter than old rows, one tenant might dominate traffic, or a small working set might receive most reads for a period of time.&lt;/p&gt;

&lt;p&gt;Which pattern the benchmark operates with significantly impacts performance, because it in turn impacts how frequently we need to access disk vs RAM and the amount of cache churn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closed and open loop
&lt;/h2&gt;

&lt;p&gt;There are two types of benchmark workload shapes: open and closed loops.&lt;/p&gt;

&lt;p&gt;In a closed-loop benchmark, the client sends requests and then waits for a response before sending the next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# wait for response
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;send_bench_request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# then send next
&lt;/span&gt;    &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We may do this in parallel across many connections, but each individual connection sends a controlled sequence of queries. A closed loop can also hide a failure mode called coordinated omission: when the database stalls, the client stops issuing new requests too, so the benchmark only records the stalled request and omits the work that would have queued behind it. This is especially misleading for tail latency, where the missing queued requests are exactly the ones that would have made p95/p99 look worse (more on latency and percentiles soon).&lt;/p&gt;

&lt;p&gt;Open loop on the other hand has a fixed pace of sending requests, regardless of how quickly the database responds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# fire and forget
&lt;/span&gt;    &lt;span class="nf"&gt;send_bench_request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# fixed pace
&lt;/span&gt;    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can be fixed throughout the entire benchmark duration, or vary in a controlled way:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ia7lcuf1cfysbpzroyr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ia7lcuf1cfysbpzroyr.png" alt="benchmark duration" width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open-loop benchmarks tend to be more realistic. In production systems, database load is applied at the rate that the clients demand, regardless of how well the database is keeping up.&lt;/p&gt;

&lt;p&gt;Closed-loop benchmarks are more commonly seen in academic and performance comparisons, as they offer a more controlled environment for comparing things like QPS across a fixed amount of concurrency.&lt;/p&gt;

&lt;p&gt;Both are beneficial, but they are useful for different things. Important to decide up front what the purpose of a benchmark is, then choose the type accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to measure?
&lt;/h2&gt;

&lt;p&gt;Broadly, there are two things we like to measure when benchmarking: &lt;em&gt;throughput&lt;/em&gt; and &lt;em&gt;latency&lt;/em&gt;. Any good database benchmark will report on both of these things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Throughput
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Throughput&lt;/em&gt; is the amount of work completed in a slice of time. In databases, the most common measures are Queries Per Second (QPS) or Transactions Per Second (TPS). For many popular benchmarks like &lt;a href="https://www.tpc.org/tpcc/" rel="noopener noreferrer"&gt;TPC-C&lt;/a&gt; and &lt;a href="https://www.tpc.org/tpch/" rel="noopener noreferrer"&gt;TPC-H&lt;/a&gt;, TPS &amp;lt; QPS because there are typically multiple queries within single transactions. Either works fine as a measure.&lt;/p&gt;

&lt;p&gt;To measure throughput, choose a workload, a period of time to run it for (say, 5 minutes / 300 seconds), and then execute with TPS / QPS sampling. As a benchmark runs, samples are taken of how many queries or transactions complete each second. We then display this as a graph, showing every collected data point:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnfnb8b1l5qyz87uzyn7m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnfnb8b1l5qyz87uzyn7m.png" alt="query and transactions complete per secon" width="799" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A more compact way of displaying this is via a bar chart with error bars.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fie05ka35tww8e1q5pm6s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fie05ka35tww8e1q5pm6s.png" alt="bar chart with error bars" width="800" height="883"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This communicates similar information in a more compact way, but it's ideal to show a full line graph, as that also better visualizes inconsistencies or spikiness of performance throughout a benchmark run. More on this later.&lt;/p&gt;

&lt;p&gt;Error bars are only one way to summarize variance. &lt;a href="https://en.wikipedia.org/wiki/Coefficient_of_variation" rel="noopener noreferrer"&gt;Coefficient of variation&lt;/a&gt;, &lt;a href="https://en.wikipedia.org/wiki/Interquartile_range" rel="noopener noreferrer"&gt;interquartile range&lt;/a&gt;, and &lt;a href="https://en.wikipedia.org/wiki/Histogram" rel="noopener noreferrer"&gt;histograms&lt;/a&gt; are different lenses on the same samples, each helping show whether a benchmark was stable, noisy, or hiding outliers. It's helpful to include these or provide the data so readers can compute them themselves.&lt;/p&gt;

&lt;p&gt;Throughput only tells half the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Latency&lt;/em&gt; is the amount of time it takes to complete an operation, query, or transaction. We can look at individual latencies ("How long did this particular &lt;code&gt;SELECT * FROM...&lt;/code&gt; take?"), but more often we assess latencies in aggregate.&lt;/p&gt;

&lt;p&gt;The standard language for communicating about latencies in distributed systems is with &lt;em&gt;percentiles&lt;/em&gt; over some span of time (1 second, 1 minute, etc.). For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;p50 - The median latency. During this time period, half of the requests executed faster than this, the other half slower.&lt;/li&gt;
&lt;li&gt;p90 - The 90th percentile. During this time period, 9 out of 10 requests executed faster, 1 out of 10 slower.&lt;/li&gt;
&lt;li&gt;p99 - The 99th percentile. During this time period, 99 out of 100 requests executed faster, 1 out of 100 slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We can measure any latency percentile we want, but these are the most common, along with p95 and p99.9. When benchmarking, we typically measure one or more of these in a series of small windows over the entire benchmark period. Say, sample p50, p90, and p99 once per second over a 5-minute (300-second) execution. Then, we plot the results.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9e5v12h5celxzup34fm6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9e5v12h5celxzup34fm6.png" alt="results" width="799" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In some cases, the line graphs are overkill. As with throughput, the visual can be compressed using a bar chart showing the median (or mean), with error bars.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flz2281vf07gud16o9xsr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flz2281vf07gud16o9xsr.png" alt="mean latency with error bars" width="800" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We now have a way of communicating both &lt;em&gt;how much work&lt;/em&gt; we accomplished and &lt;em&gt;how quickly&lt;/em&gt; each unit of work was completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warmup
&lt;/h2&gt;

&lt;p&gt;We've now settled the prep work and know &lt;em&gt;what&lt;/em&gt; we should be measuring. Now let's get tactical. How do we ensure that we are fair when running the benchmark? There's a lot to consider for the executions themselves.&lt;/p&gt;

&lt;p&gt;A big one is cache warmup. If we've recently booted up our database, the various caches are not full of pages (&lt;code&gt;buffer_cache&lt;/code&gt; in Postgres, &lt;code&gt;buffer_pool&lt;/code&gt; in MySQL). These require time and query load to warm, during which time latency and throughput will slowly be brought up to full potential.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wyrot0kfjian0arxx32.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wyrot0kfjian0arxx32.png" alt="latency and throughput will slowly be brought up to full potential" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We typically run databases without measurement for a few minutes to ensure all caches are &lt;em&gt;warmed&lt;/em&gt; before starting benchmark measurement. This ensures non-full caches and other startup costs don't impact the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;Even when warm, there are a number of configuration options that impact performance over long stretches of time. Though there are many, a good example of this is &lt;code&gt;checkpoint_timeout&lt;/code&gt; in Postgres.&lt;/p&gt;

&lt;p&gt;This and &lt;code&gt;max_wal_size&lt;/code&gt; determine how frequently we need to flush table / index changes to disk (I/O checkpointing). If we set these to low / aggressive values, we may trigger it once every minute, causing regular performance dips. If we set it lax to only trigger once every ten minutes, we may not even notice it in the results of a 5-minute benchmark execution.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7q7150ao09rmscpxvfdh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7q7150ao09rmscpxvfdh.png" alt="benchmark execution" width="799" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can end up with graphs like this in these cases. But run for another 10 minutes, and we'd likely see a large performance dip on the green line.&lt;/p&gt;

&lt;p&gt;Background jobs, I/O checkpointing, autovacuum, and other work can impact the throughput, skewing the benchmark results.&lt;/p&gt;

&lt;p&gt;It's important to consider the impact database configurations have on performance. An identical benchmark on the same hardware can perform very differently with different tunings. DBMSs give us these tunings so we can trade off things like performance, durability, data size, and resource consumption on a case-by-case basis. It's generally best to either (a) ensure all configuration options are aligned or (b) for pre-tuned situations (like most database-as-a-service providers) leave things at the pre-tuned defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  (In)consistency
&lt;/h2&gt;

&lt;p&gt;Another important consideration, especially in the cloud, is (in)consistency. Even with the same benchmark instance and same client machine, latency and throughput can vary from run to run. This can be due to contention on the network or noisy neighbors that are co-occupying the same hardware you are running on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv03t7b2bfkzzg2o8plbj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv03t7b2bfkzzg2o8plbj.png" alt=" " width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's advisable to do multiple runs to measure consistency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apples to apples to oranges
&lt;/h2&gt;

&lt;p&gt;The best benchmarks are the ones that compare apples-to-apples. In other words, ones that create data-driven comparisons between products that have the same or very similar characteristics and feature sets.&lt;/p&gt;

&lt;p&gt;Examples of this are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Comparing 4 different Postgres configurations to determine workload suitability&lt;/li&gt;
&lt;li&gt;Comparing 3 different cloud MySQL platforms to determine which is most performant&lt;/li&gt;
&lt;li&gt;Comparing MySQL and Postgres on an identical workload (different databases, but same stated purpose)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;People sometimes draw comparisons between vastly different database engines, resulting in wild claims. Things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analytics queries run 100x faster on Apache Pinot than Postgres&lt;/li&gt;
&lt;li&gt;Achieve 100x higher QPS on a purpose-built realtime database compared to a Postgres relational database&lt;/li&gt;
&lt;li&gt;SQLite latency is 80% lower than MySQL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are comparing databases that were distinctly optimized for different purposes. It's easy to make one look better than the other, especially when cherry-picking the workload.&lt;/p&gt;

&lt;p&gt;Don't do this. Ensure comparisons are between comparable technologies and workloads that fit the DBMS's stated purpose. The one exception may be as an internal test to determine which technology, amongst ones with vastly different goals, is best-suited for a system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Document everything
&lt;/h2&gt;

&lt;p&gt;Good benchmarks should be reproducible. Document the client and target setups as exhaustively as possible: hardware (or cloud instance type), OS, software versions, build flags, configurations, benchmark tool, exact command line, etc. After looking at the results of a benchmark, an engineer should be able to reproduce the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark crimes
&lt;/h2&gt;

&lt;p&gt;As you can see, there's a lot to good benchmarking. Missing any one of these steps leads to bias. Some of the most common mistakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reporting only averages, without percentiles, variance, or the full time-series&lt;/li&gt;
&lt;li&gt;Leaving out hardware, instance type, etc.&lt;/li&gt;
&lt;li&gt;Measuring before the system reaches steady state&lt;/li&gt;
&lt;li&gt;Reporting a percentage difference without the surrounding variance&lt;/li&gt;
&lt;li&gt;Forgetting to check whether the benchmark client is the bottleneck&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is easy to miss!&lt;/p&gt;

&lt;p&gt;If the client machine has maxed out on CPU or network connections, the graph may look like the database has plateaued. But all you've really measured is the limit of the load generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go forth and benchmark
&lt;/h2&gt;

&lt;p&gt;You now have an elementary understanding of database benchmarking.&lt;/p&gt;

&lt;p&gt;When presenting results, don't stop at the numbers. If two runs differ meaningfully, offer a hypothesis for why: hardware, configuration, workload shape, cache behavior, network latency, or something else. The reader should not have to invent the causal story themselves.&lt;/p&gt;

&lt;p&gt;Apply all these to your next round of benchmarks, and you're less likely to veer off-course.&lt;/p&gt;

</description>
      <category>planetscale</category>
      <category>database</category>
      <category>postgres</category>
      <category>mysql</category>
    </item>
    <item>
      <title>Approaches to tenancy in Postgres</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 18 May 2026 18:05:36 +0000</pubDate>
      <link>https://dev.to/planetscale/approaches-to-tenancy-in-postgres-2m83</link>
      <guid>https://dev.to/planetscale/approaches-to-tenancy-in-postgres-2m83</guid>
      <description>&lt;p&gt;&lt;em&gt;By Simeon Griggs&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Multi-tenancy is a term used across various kinds of technical infrastructure, including application hosting, compute, databases, and more.&lt;/p&gt;

&lt;p&gt;For example, you may purchase cloud services from a provider, but your account is one of many that draws from a common pool of resources. Your account is one "tenant" in a multi-tenant infrastructure.&lt;/p&gt;

&lt;p&gt;In this article, we're focusing on using a single Postgres database cluster to serve an application with many tenants—you are our customer, and your customers are tenants in that cluster.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: PlanetScale databases are, by default, multi-tenant within our infrastructure. Single-tenant resources are available on Enterprise. See the &lt;a href="https://pscale.link/HJ5Odxe" rel="noopener noreferrer"&gt;deployment options documentation&lt;/a&gt; for more details.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Given the many approaches to multi-tenancy within a Postgres database, it is worth clarifying the recommended best practices and the data models you should avoid. These recommendations are informed by years of seeing multi-tenant applications, both good and bad, succeed and fail at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Definitions
&lt;/h2&gt;

&lt;p&gt;The term "database" is overloaded and can refer to different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Database Cluster&lt;/strong&gt; refers to the entire database server instance – the running Postgres process, its storage and any replicas.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Logical Database&lt;/strong&gt; is an isolated namespace within a database cluster that contains its own schemas, tables, and data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you generate credentials to connect to a database, you're connecting to the database cluster. The queries you perform will target a single logical database within it. On PlanetScale Postgres, the default logical database name is &lt;code&gt;postgres&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In short: one &lt;em&gt;database cluster&lt;/em&gt; can contain many &lt;em&gt;logical databases&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;When modeling data in a relational database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Tenant&lt;/strong&gt; refers to a single entity that accesses their own subset of data in your application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-tenancy&lt;/strong&gt; refers to giving each tenant their own isolated schema, logical database, or database cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenancy&lt;/strong&gt; refers to using a consistent schema (set of tables and relationships) for all of the users of your application within a single database cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three approaches to tenant isolation
&lt;/h2&gt;

&lt;p&gt;There are three common approaches to separating tenant data within a single database cluster:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://planetscale.com/blog/approaches-to-tenancy-in-postgres#row-level-isolation" rel="noopener noreferrer"&gt;Row-level isolation&lt;/a&gt; where each tenant's data is isolated by a column value such as &lt;code&gt;tenant_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://planetscale.com/blog/approaches-to-tenancy-in-postgres#schema-per-tenant" rel="noopener noreferrer"&gt;Schema-per-tenant&lt;/a&gt; where each tenant has its own schema and tables&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://planetscale.com/blog/approaches-to-tenancy-in-postgres#database-per-tenant" rel="noopener noreferrer"&gt;Database-per-tenant&lt;/a&gt; where each tenant has its own logical database, schema, and tables&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F02kroc5bm2tu1s7ooivb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F02kroc5bm2tu1s7ooivb.png" alt="three common approaches to separating tenant data within a single database cluster: row-level isolation, schema-per-tenant, and database-per-tenant" width="800" height="196"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Of the three approaches, &lt;strong&gt;row-level isolation&lt;/strong&gt; is the most common and is our recommended approach.&lt;/p&gt;

&lt;p&gt;Row-level isolation is also the only true method of "multi-tenancy" in a relational database. Schema-per-tenant and database-per-tenant within the same database cluster do not share tables, but they do share resources.&lt;/p&gt;

&lt;p&gt;Finally, you may already be running a database using one schema-per-tenant. You may be able to migrate to a recommended approach to improve the performance of your application and workloads. See &lt;a href="https://planetscale.com/blog/approaches-to-tenancy-in-postgres#migrating-to-row-level-multi-tenancy" rel="noopener noreferrer"&gt;Migrating to row-level multi-tenancy&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Good examples for multi-tenancy
&lt;/h3&gt;

&lt;p&gt;Good examples of multi-tenancy include SaaS applications that need to isolate data for each customer but have so many customers that it would be impractical to assign each customer to an individual database cluster. Or multi-national applications that need to isolate data for each country, market, or region.&lt;/p&gt;

&lt;p&gt;These are good use cases for multi-tenancy because only the &lt;strong&gt;data&lt;/strong&gt; is different between tenants. The schema, tables, relationships, application code and access patterns are uniform across all tenants.&lt;/p&gt;

&lt;p&gt;With any multi-tenancy approach, your goal should be for data belonging to each tenant to be consumed by the same applications, with care to ensure that one tenant cannot query another tenant's data nor that their behavior in your application could jeopardize the experience of another tenant.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: These recommendations assume all tenants share the same schema. If tenants genuinely need different schema structures, schema-per-tenant or database-per-tenant is the better fit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Row-level isolation
&lt;/h2&gt;

&lt;p&gt;Recommended. This is the most common, general-purpose method for combining tenants in a single database.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All data is stored in a single database cluster&lt;/li&gt;
&lt;li&gt;All tenants share the same schema and tables&lt;/li&gt;
&lt;li&gt;Each tenant's data is isolated with a column such as &lt;code&gt;tenant_id&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With row-level isolation, each tenant shares the same &lt;strong&gt;schema&lt;/strong&gt; and &lt;strong&gt;tables&lt;/strong&gt;, but has its own &lt;strong&gt;data&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the simplest model conceptually and the most scalable approach to multi-tenancy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fww43lzt3nwkdd3ybhof0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fww43lzt3nwkdd3ybhof0.png" alt="Row-level isolation" width="800" height="392"&gt;&lt;/a&gt;&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- tenant_id should lead most indexes&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_tenant_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Insert data for different tenants into the same table&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Hans'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Every query must filter by tenant&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;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on the size of your tables, row-level isolation can easily scale to many thousands of tenants. Migrations and schema changes need only be applied to a single table to update all tenants. Querying across tenants is simple and efficient.&lt;/p&gt;

&lt;h3&gt;
  
  
  Modeling tenants
&lt;/h3&gt;

&lt;p&gt;In most multi-tenant applications, tenants have metadata beyond just an ID — a name, a region, etc. A dedicated &lt;code&gt;tenants&lt;/code&gt; table gives you a place to store this and lets the &lt;code&gt;tenant_id&lt;/code&gt; column across your schema remain a compact, performant &lt;code&gt;BIGINT&lt;/code&gt; foreign key.&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;-- 'uk', 'de'&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;               &lt;span class="c1"&gt;-- 'United Kingdom', 'Germany'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using a &lt;code&gt;BIGINT&lt;/code&gt; for &lt;code&gt;tenant_id&lt;/code&gt; is preferred over text-based identifiers. A &lt;code&gt;BIGINT&lt;/code&gt; is faster to compare than a string and is a stable identifier that won't need to change if a tenant rebrands or a region code is restructured.&lt;/p&gt;

&lt;p&gt;The column name &lt;code&gt;tenant_id&lt;/code&gt; is a common one, but not a required naming convention. For example, a social media application may use the column &lt;code&gt;user_id&lt;/code&gt; for the same purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enforcing tenant filtering
&lt;/h3&gt;

&lt;p&gt;The inherent risk of row-level isolation is that every query must include &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt;. Rather than relying on each query to add this manually, use ORM global scopes, middleware, or a shared data access layer to inject the tenant filter automatically.&lt;/p&gt;

&lt;p&gt;Postgres also offers Row-Level Security (RLS) as an optional, additional layer of defense. RLS automatically appends a filter to every query on a table based on a session variable. In the example below, RLS ensures that queries are scoped to the current tenant without relying on the application to include the filter.&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="c1"&gt;-- Create a non-superuser role for the application&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'secret'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Enable RLS and define the policy&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- At runtime, your app sets the tenant context per request&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- SET LOCAL ensures the setting is scoped to this transaction&lt;/span&gt;
&lt;span class="c1"&gt;-- which is important when using connection pooling.&lt;/span&gt;
&lt;span class="c1"&gt;-- Only returns orders for tenant_id = 1&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;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We generally don't recommend relying on RLS. It shifts security logic into the database, where policy misconfiguration, silent failures, and connection pooling interactions are difficult to debug. Keep tenant isolation enforced in your application code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partitioning
&lt;/h3&gt;

&lt;p&gt;With all data stored in a single table, as your database scales and your tenant count grows, row-level isolation can be further optimized by partitioning the table. The &lt;code&gt;tenant_id&lt;/code&gt; column, which is used to partition the data, is an ideal partition key.&lt;/p&gt;

&lt;p&gt;Partitioning is a Postgres feature that splits a single logical table into multiple sub-tables based on a column value. Your application queries don't need to target a specific partition, as Postgres will automatically route the query to the correct one.&lt;/p&gt;

&lt;p&gt;In practice, you would only partition tables that grow large enough to benefit from it. A &lt;code&gt;messages&lt;/code&gt; table with billions of rows is a strong candidate for partitioning by tenant, but not a small reference table like &lt;code&gt;office_locations&lt;/code&gt; with only thousands of rows.&lt;/p&gt;

&lt;p&gt;Note that Postgres requires the partition key to be part of the primary key on partitioned tables.&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="c1"&gt;-- Create a partitioned table&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;LIST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Create a partition for each tenant&lt;/span&gt;
&lt;span class="c1"&gt;-- All rows with tenant_id=1 (UK) go into 'orders_tenant_1'&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_tenant_1&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&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;-- All rows with tenant_id=2 (DE) go into 'orders_tenant_2'&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_tenant_2&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Your application doesn't know or care about partitions&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Postgres automatically routes this to orders_tenant_1&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;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_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;-- Postgres only scans orders_tenant_1 (partition pruning)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Partitioning can greatly improve performance and scalability by reducing the amount of data that needs to be scanned and the size of indexes. Internal processes such as vacuuming and index maintenance are also performed on a per-partition basis.&lt;/p&gt;

&lt;p&gt;This adds to operational overhead, as you will need to create a new partition for each tenant.&lt;/p&gt;

&lt;p&gt;Row-level isolation with partitioning offers some of the benefits of database-per-tenant multi-tenancy with lower operational overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenant data lifecycle
&lt;/h3&gt;

&lt;p&gt;With partitioning, onboarding each new tenant requires creating a new partition.&lt;/p&gt;

&lt;p&gt;Partitioning simplifies offboarding tenants: you can drop the partition, and all data for that tenant is deleted.&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="c1"&gt;-- Wrap in a transaction in case the DROP fails&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;DETACH&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="n"&gt;orders_tenant_1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_tenant_1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without partitioning, a new tenant's data can be inserted into a table with no schema changes or migrations.&lt;/p&gt;

&lt;p&gt;However, removing tenants requires doing table-level delete operations, which can generate a significant number of dead tuples and increase vacuum pressure.&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;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Schema-per-tenant
&lt;/h2&gt;

&lt;p&gt;Generally not recommended. Schema-per-tenant has a few benefits but does not work well at scale.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All data is stored in a single database cluster&lt;/li&gt;
&lt;li&gt;Each tenant has its own schema and tables&lt;/li&gt;
&lt;li&gt;Each tenant's schema and data are isolated by the schema name as a prefix to the table name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The appeal of this approach is greater isolation, since your queries do not need to filter on a specified &lt;code&gt;tenant_id&lt;/code&gt; column. Instead, your application can reuse the same queries but with a different &lt;code&gt;search_path&lt;/code&gt; to target the correct tenant's data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxnfvorc340t39ll845ye.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxnfvorc340t39ll845ye.png" alt="schema-per-tenant" width="800" height="334"&gt;&lt;/a&gt;&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="c1"&gt;-- Create the schemas&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;de&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Each gets identical tables&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;de&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- At runtime, your app sets the search path per request&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;uk&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;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- returns uk.orders data&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;de&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;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- now returns de.orders data&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are performance benefits to using a schema-per-tenant. With each table containing fewer rows, indexes are smaller and more likely to fit in the buffer cache. One tenant's update/delete churn will not increase another tenant's bloat or vacuum workload.&lt;/p&gt;

&lt;p&gt;However, the operational overhead of maintaining a schema-per-tenant outweighs the performance benefits. It increases schema migration complexity because they need to be applied to each tenant's schema. Should you need to query across tenants, complex cross-schema joins will be required.&lt;/p&gt;

&lt;p&gt;While this approach works, it likely won't scale beyond a few hundred tenants. Every table, index, constraint, and sequence across all schemas lives in shared system catalogs. With hundreds of schemas, each containing even a modest number of tables and their indexes, these catalogs grow into millions of rows. This slows the query planner as it consults the catalog on every query. Migrations slow down as the catalog size increases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety concerns of &lt;code&gt;SET search_path&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;There is no database-level enforcement of preventing access to the wrong schema. Schema-per-tenant &lt;em&gt;feels like&lt;/em&gt; greater separation of data, but it does not meaningfully impact data isolation from a security perspective. You may also need to create a separate database user and set up precise schema-level permissions for better security.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenant data lifecycle
&lt;/h3&gt;

&lt;p&gt;Onboarding new tenants requires creating a new schema for the tenant and performing a migration.&lt;/p&gt;

&lt;p&gt;Removing tenants from a schema-per-tenant configuration may be one of the few operational advantages of this approach to multi-tenancy, as it is a single, simple operation.&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;DROP&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;uk&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Database-per-tenant
&lt;/h2&gt;

&lt;p&gt;Generally not recommended. Database-per-tenant has a few benefits but is at odds with the connection model of Postgres.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All data is stored in a single database cluster&lt;/li&gt;
&lt;li&gt;Each tenant has its own logical database, schema, and tables&lt;/li&gt;
&lt;li&gt;Each tenant's data is isolated by the logical database name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Within a PlanetScale Postgres database, you have the option to run &lt;code&gt;CREATE DATABASE&lt;/code&gt; to create many logical databases within a single database cluster.&lt;/p&gt;

&lt;p&gt;The appeal of using logical databases per tenant is increased isolation: you do not need to filter by a column or modify &lt;code&gt;search_path&lt;/code&gt;; instead, you can modify the connection string to connect to the correct database. This makes working with the data and schema of an individual tenant much simpler.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F99roj8bu8xss1vedd3ym.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F99roj8bu8xss1vedd3ym.png" alt="Database-per-tenant" width="800" height="332"&gt;&lt;/a&gt;&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="c1"&gt;-- Create separate databases&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;uk_store&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;de_store&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Connect to the UK database and create tables there&lt;/span&gt;
&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="n"&gt;uk_store&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Connect to the German database and do the same&lt;/span&gt;
&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="n"&gt;de_store&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are notable performance benefits to using a database-per-tenant. With each table in each database containing fewer rows, indexes are smaller and more likely to fit in the buffer cache. One tenant's update/delete churn will not increase another tenant's bloat or vacuum workload.&lt;/p&gt;

&lt;p&gt;Database-per-tenant is better for performance than a schema-per-tenant, as each database contains its own catalog of tables, indexes, constraints, and sequences.&lt;/p&gt;

&lt;p&gt;However, these performance benefits are still outweighed by the drawbacks of increased operational complexity. Critically, connection pooling becomes a problem immediately, as PgBouncer pools are calculated per-database and will quickly exceed your &lt;code&gt;max_connections&lt;/code&gt; limit. Connection limits are the primary issue with database-per-tenant multi-tenancy.&lt;/p&gt;

&lt;p&gt;Additionally, each CREATE DATABASE copies Postgres's template database, consuming roughly 8 MB. Unlike schema-per-tenant, where all schemas share a single set of system catalogs, every logical database carries its own, multiplying storage and catalog maintenance overhead with each new tenant.&lt;/p&gt;

&lt;p&gt;While all the isolation and performance benefits of a database-per-tenant are compelling, it conflicts with Postgres's connection model.&lt;/p&gt;

&lt;p&gt;Additionally, if you need to query across tenants, there is no way to do so in Postgres. You would need to use an external data warehouse or a custom application layer to join the data together.&lt;/p&gt;

&lt;p&gt;While this approach works, it likely won't scale beyond a few hundred tenants.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security considerations
&lt;/h3&gt;

&lt;p&gt;Of all the multi-tenancy approaches, the database-per-tenant approach is the most isolated from a security perspective. Each tenant has its own logical database, schema, and tables. Each tenant's data can be accessed only by a user with privileges to that database and schema.&lt;/p&gt;

&lt;p&gt;Even so, the limitations on connectivity and the operational complexity of this model make it difficult to recommend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenant data lifecycle
&lt;/h3&gt;

&lt;p&gt;Every new tenant requires a new logical database to be created and a migration to set up its tables.&lt;/p&gt;

&lt;p&gt;Removing tenants from a database-per-tenant configuration may be one of the few operational advantages of this approach to multi-tenancy, as it is a single, simple operation with no side effects.&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;DROP&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;uk_store&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Protecting tenants from each other
&lt;/h2&gt;

&lt;p&gt;In all three approaches to multi-tenancy, tenants must be protected from one another, both in terms of data access and resource contention.&lt;/p&gt;

&lt;p&gt;Our recommended approach, row-level isolation, is the most exposed because tables and indexes are shared. Care must be taken here to keep things safely isolated. Schema- and database-per-tenant approaches are more isolated at the relation level, but all three compete for CPU, memory, disk I/O, and connections.&lt;/p&gt;

&lt;p&gt;One tenant running an expensive query degrades performance for all other tenants, commonly referred to as a "noisy neighbor" problem. Within your database, you can add some protection by setting &lt;code&gt;statement_timeout&lt;/code&gt; and &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; appropriately. Your application should also be aware of potential rate limits, which could allow one tenant to disrupt another tenant's experience.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://planetscale.com/docs/postgres/monitoring/query-insights" rel="noopener noreferrer"&gt;PlanetScale Query Insights&lt;/a&gt; can help you identify and troubleshoot performance issues within your database, which you can debug manually or with an Agent using the &lt;a href="https://planetscale.com/docs/connect/mcp-server" rel="noopener noreferrer"&gt;PlanetScale MCP server&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating to row-level multi-tenancy
&lt;/h2&gt;

&lt;p&gt;Should your application already be configured for schema, database, or some other kind of multi-tenancy, you may be able to migrate to row-level multi-tenancy by adding a &lt;code&gt;tenant_id&lt;/code&gt; column to your tables and updating your application to filter by this column.&lt;/p&gt;

&lt;p&gt;If you are not yet on PlanetScale, we have successfully migrated large, multi-tenant workloads that were experiencing operational, performance, or scaling issues. We offer hands-on assistance on a case-by-case basis.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pscale.link/1xw5Dl0" rel="noopener noreferrer"&gt;Reach out&lt;/a&gt; to discuss your current situation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Other examples for multi-tenancy
&lt;/h3&gt;

&lt;p&gt;For needs that are less than mission critical, such as internal applications and side projects, you may diverge from the recommendations in this post. For example, you might like to run distinct applications from a single database cluster, as it seems cheaper or operationally advantageous.&lt;/p&gt;

&lt;p&gt;If your multiple "tenants" are actually different applications with unique data structures running from a single database, we simply ask you to exercise caution.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you can't behave, be careful.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>planetscale</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>RLS sounds great until it isn't</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 11 May 2026 16:06:32 +0000</pubDate>
      <link>https://dev.to/planetscale/rls-sounds-great-until-it-isnt-4d5p</link>
      <guid>https://dev.to/planetscale/rls-sounds-great-until-it-isnt-4d5p</guid>
      <description>&lt;p&gt;&lt;em&gt;By Josh Brown&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When you leave your house, go to sleep, or go do work in the yard, you lock your door. Maybe you have a gate or fence you lock too. Without these, anyone can waltz into your house and snoop around.&lt;/p&gt;

&lt;p&gt;Row Level Security (RLS) can be attractive to developers for numerous reasons, but the foot-guns and gotchas in RLS often outweigh the benefits. You probably want to keep your doors locked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Friends and family: Managing access
&lt;/h2&gt;

&lt;p&gt;RLS for Postgres lets administrators define security policies in their database, instead of the application layer. Let's imagine your house is your database, and the rows, tables, and data are like the things inside.&lt;/p&gt;

&lt;p&gt;When your friends or family come over, you give them keys to every drawer they are allowed to have access to. Maybe everyone gets access to the silverware, but only the family can access your laundry room.&lt;/p&gt;

&lt;p&gt;This is similar to how policies work in RLS. The rules for who gets which keys are your policies. If a user passes a policy rule (has the key) then they are allowed to access the data. At a very small scale, this can seem like a great idea. Anyone can access your database however they want and your policies ensure they aren't seeing things they shouldn't.&lt;/p&gt;

&lt;p&gt;Testing and scaling these policies as your database grows becomes near impossible. For every new feature in your application, you must ensure your RLS policies are protecting the correct rows. Remembering to add these policies can be cumbersome, especially when they need to be manually synced to your codebase.&lt;/p&gt;

&lt;p&gt;RLS fundamentally exists to protect your data. If you mess up even a single policy however, your data becomes exposed. Managing access in the same location your code lives is much easier than remembering to write a new policy every time a new table, column, or feature is added to your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The party: Managing connections
&lt;/h2&gt;

&lt;p&gt;Postgres uses a process-per-connection architecture. Each new user connecting to your database directly with their role is like a new person coming into your house. At first it's fine, but once you have 100 people it gets crowded pretty quick.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pscale.link/rlC77qY" rel="noopener noreferrer"&gt;PgBouncer&lt;/a&gt; is a connection pooler that reuses a small number of direct connections to your database while letting many clients connect to it. When using PgBouncer with RLS, you lose the upstream identity of the client.&lt;/p&gt;

&lt;p&gt;The traditional way of solving this is using local variables instead of roles to define RLS policies. You define a policy that reads from a session-local variable instead of checking the Postgres 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;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;user_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then wrap every transaction in your application to set that variable:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1234'&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;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires a lot of extra application code to manage all the different local variables attached to each and every transaction (1). If &lt;code&gt;SET LOCAL&lt;/code&gt; is omitted, &lt;code&gt;current_setting()&lt;/code&gt; returns an empty string or throws an error depending on how your policy is written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Annoying neighbor: Attack Surface
&lt;/h2&gt;

&lt;p&gt;You go out to get your mail and you find your neighbor standing over your mailbox trying to open it over and over. You try to tell them that one is yours and to let you in, but they are having none of it. Now you have to sit and wait until they get bored and figure out they don't have the right key.&lt;/p&gt;

&lt;p&gt;RLS acts like an extra &lt;code&gt;WHERE&lt;/code&gt; clause appended to your queries. Unless the user lacks read permission on a table, their queries will still run even if no data is returned. On complex joins or queries lacking indexes, this can hurt database performance.&lt;/p&gt;

&lt;p&gt;If a malicious user starts retrying a query over and over, RLS will make sure they don't see any data, but cannot stop them from running the query itself. Relying on RLS to completely protect your tables burns valuable CPU cycles and can potentially starve your other, honest users.&lt;/p&gt;

&lt;p&gt;Any user of your application, particularly in situations where you do not have sufficient rate limiting in place, can DDoS your database simply by hitting an API endpoint. This is preventable by checking authentication to see if a user is allowed to run a query, without relying on RLS to manage your security for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  A large keyring: Performance Implications
&lt;/h2&gt;

&lt;p&gt;Every time your friend goes to get a Diet Coke, they need to find the fridge key on their very large key chain. This wastes valuable time sifting through all the different keys and trying each one, so instead they mark the key so it's easier to find next time they go to the fridge.&lt;/p&gt;

&lt;p&gt;RLS policies are generally executed per row (2), meaning any function or complex logic will run for each row scanned. This can be solved by wrapping functions into subqueries. Setting up a simple benchmark, we can see the difference between RLS, RLS cached, and with RLS disabled. If you want to try it yourself, you can use &lt;a href="https://github.com/planetscale/rls-latency-benchmark" rel="noopener noreferrer"&gt;this benchmark repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Filbnobtbpyvcmgd7nrzy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Filbnobtbpyvcmgd7nrzy.png" alt="PostgreSQL RLS benchmark" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For this benchmark, we tested 5 different setups. Two different functions that are called from two different policies, and one without RLS at all.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RLS with a &lt;code&gt;VOLATILE&lt;/code&gt; function&lt;/li&gt;
&lt;li&gt;RLS with a &lt;code&gt;STABLE&lt;/code&gt; function&lt;/li&gt;
&lt;li&gt;RLS with a &lt;code&gt;VOLATILE&lt;/code&gt; function + cache&lt;/li&gt;
&lt;li&gt;RLS with a &lt;code&gt;STABLE&lt;/code&gt; function + cache&lt;/li&gt;
&lt;li&gt;No RLS&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A volatile function is defined with the keyword &lt;code&gt;VOLATILE&lt;/code&gt; that tells Postgres the function may modify data or return different values upon successive calls. This is the default mode for a new function in Postgres.&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;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;get_current_role&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;SQL&lt;/span&gt;
&lt;span class="k"&gt;VOLATILE&lt;/span&gt;
&lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other option is to use &lt;code&gt;STABLE&lt;/code&gt; in our function definition. Stable functions cannot modify data, and are expected to return the same value for successive calls within the same transaction. When using RLS however, Postgres does not cache the value when evaluating the policy on each row during queries. In order to successfully cache the result across each policy evaluation, we need to trick Postgres.&lt;/p&gt;

&lt;p&gt;When we wrap the function call in a &lt;code&gt;SELECT&lt;/code&gt;, Postgres creates an &lt;code&gt;InitPlan&lt;/code&gt; query node type. By default, anything after the &lt;code&gt;USING&lt;/code&gt; keyword is executed as a &lt;code&gt;SubPlan&lt;/code&gt; type, where Postgres expects that the outcome can change row to row. This is desired as that is what we are checking; for every row, should the user be allowed to fetch it.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;InitPlan&lt;/code&gt; is only run once per execution of the outer plan, and cached for reuse in later rows of the evaluation. Using &lt;code&gt;EXPLAIN&lt;/code&gt;, we can see how the different policy definitions change the estimated cost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- RLS without subquery: no InitPlan, high cost
CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.tenant_id')::bigint AND get_current_role() = 'admin');
EXPLAIN:
    Aggregate  (cost=34828.68..34828.69 rows=1 width=40)
      -&amp;gt;  Index Scan using orders_tenant_id_idx on orders  (cost=0.43..34826.20 rows=495 width=6)
            Index Cond: (tenant_id = (current_setting('app.tenant_id'::text))::bigint)
            Filter: (get_current_role() = 'admin'::text)

-- RLS with subquery: Initplan caches result, lower cost
CREATE POLICY tenant_isolation ON orders USING  (tenant_id = current_setting('app.tenant_id')::bigint AND (SELECT get_current_role()) = 'admin');
EXPLAIN:
    Aggregate  (cost=10095.69..10095.70 rows=1 width=40)
      InitPlan 1
        -&amp;gt;  Result  (cost=0.00..0.26 rows=1 width=32)
      -&amp;gt;  Index Scan using orders_tenant_id_idx on orders  (cost=0.43..10092.95 rows=495 width=6)
            Index Cond: (tenant_id = (current_setting('app.tenant_id'::text))::bigint)
            Filter: ((InitPlan 1).col1 = 'admin'::text)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cost=&lt;/code&gt; in the explain rows is Postgres' guess at how expensive a query will be to run, in arbitrary units. The first number is the estimated startup cost; or how expensive it is to do the sorting and filtering of the query before returning rows to the user. The second number is the estimated total cost, including fetching all the rows. The &lt;code&gt;rows=&lt;/code&gt; and &lt;code&gt;width=&lt;/code&gt; are how many expected rows the query will return, and the width of those rows respectively.&lt;/p&gt;

&lt;p&gt;When Postgres doesn't think it can cache the inner query, the cost is over 3x higher than if it would have been able to. In reality, the actual latency difference is much larger than 3x as seen in the chart above.&lt;/p&gt;

&lt;p&gt;When Postgres doesn't cache expensive functions in your policy definitions, RLS becomes expensive overhead. RLS can be just as fast as if you weren't using it at all in some scenarios. The issue is that RLS becomes yet another layer of code that needs to continuously optimized, where small mistakes can cause large performance hits.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's your house: Permission ownership
&lt;/h2&gt;

&lt;p&gt;It's your house, you obviously have the keys to everything, but what if you weren't supposed to?&lt;/p&gt;

&lt;p&gt;Every Postgres table has an owner. Normally you'd control table and row access on a per-Postgres-role basis, however when you connect to Postgres as the owning role of a table, none of its RLS policies apply. You must explicitly opt in:&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;users&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even this may not be sufficient if you are connected with the Postgres superuser role. Any roles that contain the &lt;code&gt;SUPERUSER&lt;/code&gt; attribute will always bypass RLS. This is easy to miss and easy to test incorrectly. Your policy tests might pass under a non-owner role while production traffic runs as the owner.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making a ham sandwich: Stricter patterns
&lt;/h3&gt;

&lt;p&gt;Let's say your friend Andy wanted to make a ham sandwich. He had access to the fridge and utensils, but not your grocery list. When he made his sandwich, he used up all the mustard, and now you need to go get more. When using RLS, Andy's query can't touch our grocery list. We have to update that separately.&lt;/p&gt;

&lt;p&gt;Without RLS this is easy. When using RLS, doing this type of query can add a lot of complexity. Getting the utensils, making the sandwich, and updating the grocery list might not share the same permissions. While rows in one table may be accessible to a user, updating rows in another may not be. Since we own the grocery list, we don't want anyone touching it except in well defined scenarios.&lt;/p&gt;

&lt;p&gt;One way to solve this is by using multiple roles and multiple transactions, but this becomes overly cumbersome on our application layer. A better solution would be to add a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function in our database that gives roles access to modify or view data in a well defined way:&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;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;use_ingredients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ingredients&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="c1"&gt;-- Runs as the function owner, bypassing Andy's RLS policies&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;grocery_list&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ingredients&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SECURITY DEFINER&lt;/code&gt; causes the function to run as its owner's role, bypassing RLS entirely for that operation. Now you're back to managing security on both RLS and your application layer, ensuring only specific parameters are allowed to pass to this function.&lt;/p&gt;

&lt;p&gt;Keeping database functions in version control also becomes difficult. Some migration tools include SQL functions and policies, but are another part of your schema migrations that can cause headaches down the road.&lt;/p&gt;

&lt;p&gt;Your application layer also needs to stay in sync with every function it calls in your database. Changing function definitions, names, or return values may require a new database migration, or delicate surgery to ensure a stable update.&lt;/p&gt;

&lt;h2&gt;
  
  
  End of the day
&lt;/h2&gt;

&lt;p&gt;Once we have managed locking everything under a different key inside your house, who has what keys, who is allowed in, and who is delegating access for who, we find our application code has almost as much logic as if it didn't have RLS at all.&lt;/p&gt;

&lt;p&gt;RLS policies themselves are stored in &lt;code&gt;pg_policies&lt;/code&gt; inside your database, not in your source code. Most standard migration tools don't track policy changes alongside schema changes. Policy migrations become a separate, manual process, and they drift. A schema change that adds a column or renames a table can silently break a policy that no one realizes is outdated until something breaks in our application, impacting users.&lt;/p&gt;

&lt;p&gt;Each query to the database will already need some sort of modifier in your application code to add local variables for user identification when using PgBouncer. Misconfigured local variables could be just as damaging as if RLS wasn't there to begin with.&lt;/p&gt;

&lt;p&gt;We still need to check early on if a user has permission to run a query, or else we risk allowing users to degrade our database performance with spam. If we are already checking permissions at the application layer, the benefits of RLS become harder to observe.&lt;/p&gt;

&lt;p&gt;Optimizing queries also becomes much harder. Queries are artificially restricted to what they are allowed to see, and need bespoke functions and permissions to get access. This causes our management of source code and database logic to become even harder to manage, between policies, functions, and the mappings between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to do it right
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://pscale.link/7uQCLEy" rel="noopener noreferrer"&gt;PlanetScale&lt;/a&gt;, we typically recommend against relying on Postgres RLS. There may be occasional useful scenarios, but when implementing RLS correctly at scale, the benefits quickly turn into cons with a higher overhead not only to performance, but also developer experience and complexity.&lt;/p&gt;

&lt;p&gt;Application-layer authorization like middleware, ORM-level scoping, or a dedicated permissions table keeps your logic visible, testable, and co-located with the code that uses it.&lt;/p&gt;

&lt;p&gt;Your database is more like a warehouse. Don't treat it like your house.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footnotes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Note that PgBouncer &lt;code&gt;pool_mode&lt;/code&gt; must be in either &lt;code&gt;session&lt;/code&gt; or &lt;code&gt;transaction&lt;/code&gt;. &lt;code&gt;statement&lt;/code&gt; mode won't work with &lt;code&gt;SET LOCAL&lt;/code&gt; at all.&lt;/li&gt;
&lt;li&gt;The Postgres query planner can sometimes determine that a policy is safe to cache across evaluations on its own. Doing this properly can be a tricky process. Even in our benchmark example, functions that are marked as stable still need to be wrapped in a subquery in order for Postgres to properly cache the result. Each policy is different, and determining the proper optimizations for each one is another layer of complexity in your codebase.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>postgres</category>
      <category>planetscale</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>High Memory Usage in Postgres is Good, Actually</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 04 May 2026 15:56:50 +0000</pubDate>
      <link>https://dev.to/planetscale/high-memory-usage-in-postgres-is-good-actually-1i49</link>
      <guid>https://dev.to/planetscale/high-memory-usage-in-postgres-is-good-actually-1i49</guid>
      <description>&lt;p&gt;&lt;em&gt;By Simeon Griggs&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Houseplants often die from over-watering, not neglect. It is easy to project human needs onto them: "If I am thirsty, they must be thirsty too." But many indoor plants actually benefit from drying out between waterings.&lt;/p&gt;

&lt;p&gt;Similarly, your empathy can lead to misinterpreting signals from your database. You don't like feeling overwhelmed, so you don't want your database overwhelmed either.&lt;/p&gt;

&lt;p&gt;But not all usage is created equal, and memory in computers can be uniquely complex to understand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frkunawlmnt166g5k0mfd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frkunawlmnt166g5k0mfd.png" alt="PlanetScale Metal dashboard in dark mode" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A look at your PlanetScale dashboard might show memory usage sitting at 80%. That &lt;em&gt;looks&lt;/em&gt; bad, but it could actually be representative of a healthy system.&lt;/p&gt;

&lt;p&gt;To be clear, consistently high CPU usage is a problem. For as long as CPU stays high, queries wait longer, the slowest queries get slower, and you have less headroom for spikes.&lt;/p&gt;

&lt;p&gt;Memory is different. The percentage shown in the cluster diagram on your PlanetScale dashboard is measuring the entire node your database runs on, not just Postgres. When most RAM is in use, it usually means the system is keeping data close to the CPU so it does not have to read from disk as often. Unlike sustained high CPU, high memory usage by itself does not mean performance is degraded or that you are at immediate risk of "running out" of memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Postgres wants your memory
&lt;/h2&gt;

&lt;p&gt;Reading from disk is slower than reading from RAM, even with &lt;a href="https://planetscale.com/docs/metal" rel="noopener noreferrer"&gt;PlanetScale Metal&lt;/a&gt;'s locally attached NVMe drives. Postgres is designed to take advantage of that gap by caching as much data in memory as it can.&lt;/p&gt;

&lt;p&gt;There are two layers of caching at work, and both consume RAM.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;shared_buffers&lt;/code&gt; is Postgres' own buffer pool. When a query needs data, Postgres first checks this pool for the relevant pages, the fixed-size (8 KB by default) chunks of table and index data it works with, before reading from disk. The more of your working data that fits here, the fewer disk reads Postgres needs to perform.&lt;/p&gt;

&lt;p&gt;This parameter can be configured in the &lt;a href="https://planetscale.com/docs/postgres/cluster-configuration/parameters" rel="noopener noreferrer"&gt;cluster configuration&lt;/a&gt; page of the PlanetScale dashboard. The default value should be sufficient for most workloads, and modifying it should not be your first step in troubleshooting memory usage.&lt;/p&gt;

&lt;p&gt;The OS page cache is the second caching layer. Even when Postgres does go to disk, the operating system keeps a copy of the data it reads in RAM so the next access is faster. This is not a Postgres feature — it is standard Linux behavior. Postgres was designed with this in mind, and its own documentation notes that the operating system's cache is expected to handle data beyond what fits in &lt;code&gt;shared_buffers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Between these two layers, a healthy Postgres server will use most of the available RAM. That is the goal, not a side effect. For context, reading a page from RAM is roughly 1,000 times faster than reading it from even a fast NVMe drive. A database that keeps frequently accessed data in memory avoids that penalty on every query.&lt;/p&gt;

&lt;p&gt;When caching is working well, the vast majority of page reads are served from memory without touching disk. If that ratio drops — because the working dataset has outgrown available memory, for example — queries slow down as Postgres waits on disk more often.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;See our documentation on &lt;a href="https://planetscale.com/docs/postgres/monitoring/metrics#interpreting-metrics" rel="noopener noreferrer"&gt;"Normal operating ranges"&lt;/a&gt; to sense-check what values you should be seeing in Cluster Metrics for CPU, memory, and more.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Memory usage compared to CPU usage
&lt;/h2&gt;

&lt;p&gt;At a glance, CPU and memory usage numbers look comparable because they share a 0–100% scale, but they describe very different behavior.&lt;/p&gt;

&lt;p&gt;CPU is work. Sustained high CPU means the database is spending time on work it cannot skip. When CPU is saturated, queries arrive faster than they can be processed. They queue, latency climbs, and connection timeouts can cascade into application-level failures. There is no "good" kind of sustained high CPU usage.&lt;/p&gt;

&lt;p&gt;Memory is &lt;em&gt;workspace&lt;/em&gt;. Postgres and the OS use spare RAM to avoid expensive disk reads. Higher use improves performance ... most of the time.&lt;/p&gt;

&lt;p&gt;"Most of the time" because memory usage gets a little complicated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of memory usage
&lt;/h2&gt;

&lt;p&gt;The single “memory usage” percentage number combines two different behaviors.&lt;/p&gt;

&lt;p&gt;To explore that number in more detail, within the &lt;a href="https://planetscale.com/docs/postgres/monitoring/metrics" rel="noopener noreferrer"&gt;Cluster Metrics&lt;/a&gt; page of the PlanetScale dashboard, memory is shown as a stacked chart over time with four different categories: active cache, inactive cache, RSS, and memory mapped. These four categories can be grouped into two separate but equally important use-cases: cache and process memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6tpz95vlpm63oevvo905.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6tpz95vlpm63oevvo905.png" alt="PlanetScale Metal metrics memory" width="800" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Cache (active, inactive, and memory mapped)
&lt;/h3&gt;

&lt;p&gt;Much of what looks like “used” memory on a healthy database host is cache: file data the operating system keeps in RAM after reads so the next access is cheap. You may see this referred to as "page cache" in other dashboards.&lt;/p&gt;

&lt;p&gt;Active cache is data the OS recently touched and wants to keep around. Inactive cache hasn't been accessed lately. Memory-mapped pages are cached pages that are backed by real files on disk.&lt;/p&gt;

&lt;p&gt;All three of these cache types are reclaimable by the operating system and can be dropped when something else needs RAM.&lt;/p&gt;

&lt;p&gt;If total memory is high because cache is high, good! Frequently accessed data stays near the CPU for faster access.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Process memory (RSS)
&lt;/h3&gt;

&lt;p&gt;Separately, Postgres holds memory for processes that are actually using it. You will see this referred to as RSS (Resident Set Size) in the PlanetScale dashboard.&lt;/p&gt;

&lt;p&gt;This memory is not reclaimable by the operating system and is what increases &lt;a href="https://planetscale.com/docs/postgres/troubleshooting/out-of-memory" rel="noopener noreferrer"&gt;out of memory (OOM) risk&lt;/a&gt;. High memory usage through high RSS leads to restarts and degraded behavior.&lt;/p&gt;

&lt;p&gt;If total memory is high because RSS is high, that is referred to as memory pressure and is a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Resident Set Size?
&lt;/h3&gt;

&lt;p&gt;Roughly, RSS is the amount of private memory allocated to a process such as stack, heap, catalog/relcache caches, query execution memory like sorts and hash tables.&lt;/p&gt;

&lt;p&gt;Given Postgres' process-per-connection architecture, each process requires some baseline amount of memory. Not every process will consume the same amount of memory.&lt;/p&gt;

&lt;p&gt;Further, some memory use is shared across processes. So calculating RSS use is not as simple as adding up the memory usage of every process.&lt;/p&gt;

&lt;p&gt;RSS increases for a number of reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres may grant multiple &lt;code&gt;work_mem&lt;/code&gt; allocations within a single query; see below for more details.&lt;/li&gt;
&lt;li&gt;Catalog bloat can spike RSS usage, common in multi-tenant schemas using a table-per-tenant pattern.&lt;/li&gt;
&lt;li&gt;The operating system's memory allocator may not return memory efficiently.&lt;/li&gt;
&lt;li&gt;Misbehaving or misconfigured extensions can increase RSS usage.&lt;/li&gt;
&lt;li&gt;Cached plans and prepared statements accumulate per-session memory that is not released until the session ends or the statement is explicitly deallocated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdg9w6yvtctdz7jxf2vuq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdg9w6yvtctdz7jxf2vuq.png" alt=" raw `work_mem` endraw " width="800" height="72"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The &lt;code&gt;work_mem&lt;/code&gt; parameter's default value is set relative to the amount of memory in your database cluster. It can be modified in the &lt;a href="https://planetscale.com/docs/postgres/cluster-configuration/parameters" rel="noopener noreferrer"&gt;cluster configuration&lt;/a&gt; page of the PlanetScale dashboard.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tuning &lt;code&gt;work_mem&lt;/code&gt; might seem like an obvious lever — decrease it to reduce RSS, or increase it to prevent operations from spilling to disk. But the allocation is per-sort/hash-node, per-query, per-backend.&lt;/p&gt;

&lt;p&gt;A single complex query can allocate &lt;code&gt;work_mem&lt;/code&gt; multiple times, and that multiplies across every active connection. Setting it too low forces more disk I/O; setting it too high globally can cause total memory usage to spike unpredictably under load. Neither direction is a safe default change without first understanding your workload's concurrency and query complexity.&lt;/p&gt;

&lt;p&gt;Efficient connection pooling can be the best way to reduce RSS usage. Fewer active connections result in fewer copies of all that per-process overhead.&lt;/p&gt;

&lt;p&gt;PgBouncer on PlanetScale runs in transaction mode, where connections are returned to the pool after each transaction completes. See our blog post on &lt;a href="https://planetscale.com/blog/scaling-postgres-connections-with-pgbouncer" rel="noopener noreferrer"&gt;Scaling Postgres connections with PgBouncer&lt;/a&gt; for more details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Investigating memory usage while debugging performance
&lt;/h2&gt;

&lt;p&gt;If you're experiencing degraded performance, the challenge is figuring out what drove the RSS growth.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fey48soi01uxygy9qtouz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fey48soi01uxygy9qtouz.png" alt="OOM event metrics" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Query Insights helps you investigate query performance through CPU time, I/O, and latency, but it does not show per-query memory. You may see OOM markers and slow-query signals, but not query-specific RSS usage.&lt;/p&gt;

&lt;p&gt;RSS is a per-process metric, not a per-query metric. That means you cannot read “RSS per query” directly from &lt;code&gt;EXPLAIN&lt;/code&gt; or Query Insights. Instead, you may need to gather multiple signals and triangulate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use Cluster Metrics to identify when RSS rises.&lt;/li&gt;
&lt;li&gt;In Query Insights for that same window, look for expensive patterns (high runtime, CPU, I/O, rows/blocks read) and OOM-adjacent activity.&lt;/li&gt;
&lt;li&gt;Re-run suspect queries with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS, MEMORY)&lt;/code&gt; to inspect operator-level memory usage.&lt;/li&gt;
&lt;li&gt;Check connection counts in the same window, because many concurrent connection processes can increase RSS even when a single query is moderate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://planetscale.com/docs/postgres/troubleshooting/out-of-memory" rel="noopener noreferrer"&gt;out of memory&lt;/a&gt; documentation has more details on the likely causes of, and how to prevent, OOM events.&lt;/p&gt;

&lt;h2&gt;
  
  
  In summary
&lt;/h2&gt;

&lt;p&gt;A lot of cached data in memory is a good thing. Ideally, your "hot dataset" fits in the page cache of your database cluster to maintain fast performance. Too little cached data can lead to increased CPU usage and degraded performance.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High memory usage is not automatically bad.&lt;/strong&gt; If your high memory usage is due to cache, you typically have a healthy, performant database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory pressure is bad.&lt;/strong&gt; Rising RSS toward limits, OOM kills, unexplained restarts, and tail latency spiking together with heavy disk I/O when the working set is tight on RAM are the signals to act on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sustained high CPU is a problem.&lt;/strong&gt; It means you are out of headroom. Tune the workload (see &lt;a href="https://planetscale.com/docs/postgres/monitoring/query-insights" rel="noopener noreferrer"&gt;Query Insights&lt;/a&gt;) or upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the dashboard shows a high “% memory used,” do not panic. Investigate the types of memory being used and check for OOM events before taking action.&lt;/p&gt;

</description>
      <category>planetscale</category>
      <category>postgres</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stripe Projects Partnership: Provision PlanetScale Postgres and MySQL Databases From the Stripe CLI</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 27 Apr 2026 17:55:54 +0000</pubDate>
      <link>https://dev.to/planetscale/stripe-projects-partnership-provision-planetscale-postgres-and-mysql-databases-from-the-stripe-cli-2380</link>
      <guid>https://dev.to/planetscale/stripe-projects-partnership-provision-planetscale-postgres-and-mysql-databases-from-the-stripe-cli-2380</guid>
      <description>&lt;p&gt;&lt;em&gt;By Elom Gomez&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Did you hear the news? PlanetScale is participating as a co-design and launch partner for Stripe Projects, a new developer preview from Stripe that centralizes dev tool provisioning and billing in one place.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/qU4lHe-2iRQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Stripe Projects?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.stripe.com/projects" rel="noopener noreferrer"&gt;Stripe Projects&lt;/a&gt; is a new way for developers and coding agents to discover, provision, and pay for developer tools all from the Stripe CLI. Instead of jumping between dashboards, entering payment info, and copying credentials across services, everything lives in one centralized workflow.&lt;/p&gt;

&lt;p&gt;This fragmented developer workflow has always existed, but AI agents have made the gap much more obvious. The ecosystem has been missing a standard way for provisioning and credential handoff to work reliably across providers. And we're excited to partner with Stripe to close this gap.&lt;/p&gt;

&lt;p&gt;With PlanetScale as a launch partner, you can now spin up and pay for fully managed MySQL or Postgres databases directly from your terminal in seconds.&lt;/p&gt;

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

&lt;p&gt;Stripe Projects is currently in developer preview. You can request early access &lt;a href="https://projects.dev/" rel="noopener noreferrer"&gt;here&lt;/a&gt;. Once you're in, follow these instructions to spin up a PlanetScale Postgres or MySQL database:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the &lt;a href="https://docs.stripe.com/stripe-cli" rel="noopener noreferrer"&gt;Stripe CLI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Install the Projects plugin: &lt;code&gt;stripe plugin install projects&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Initialize Stripe Projects in your app &lt;code&gt;stripe projects init&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add a PlanetScale database: &lt;code&gt;stripe projects add planetscale/postgresql&lt;/code&gt; or &lt;code&gt;stripe projects add planetscale/mysql&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Go through the prompts to create your database: database name, cluster size, region, and number of replicas&lt;/li&gt;
&lt;li&gt;Within seconds, your PlanetScale Postgres or MySQL database is provisioned without you ever leaving the terminal&lt;/li&gt;
&lt;li&gt;Sync your database credentials to your &lt;code&gt;.env&lt;/code&gt; file: &lt;code&gt;stripe projects env --sync&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Resources and feedback
&lt;/h2&gt;

&lt;p&gt;You can start using PlanetScale with Stripe Projects in the &lt;a href="https://marketplace.stripe.com/apps/planetscale" rel="noopener noreferrer"&gt;Stripe Marketplace&lt;/a&gt;. Or, head to the &lt;a href="https://docs.stripe.com/projects" rel="noopener noreferrer"&gt;Stripe Projects documentation&lt;/a&gt; to learn more.&lt;/p&gt;

&lt;p&gt;We'd love to hear how you're using PlanetScale with Stripe Projects. &lt;a href="https://pscale.link/community" rel="noopener noreferrer"&gt;Join our Discord&lt;/a&gt; to let us know or &lt;a href="https://x.com/PlanetScale" rel="noopener noreferrer"&gt;reach out to us on X&lt;/a&gt;!&lt;/p&gt;

</description>
      <category>planetscale</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Enhanced Tagging in Postgres Query Insights</title>
      <dc:creator>Meg528</dc:creator>
      <pubDate>Mon, 20 Apr 2026 16:14:33 +0000</pubDate>
      <link>https://dev.to/planetscale/enhanced-tagging-in-postgres-query-insights-5ae0</link>
      <guid>https://dev.to/planetscale/enhanced-tagging-in-postgres-query-insights-5ae0</guid>
      <description>&lt;p&gt;&lt;em&gt;By Rafer Hazen&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;As part of our &lt;a href="https://planetscale.com/blog/introducing-database-traffic-control" rel="noopener noreferrer"&gt;Traffic Control launch&lt;/a&gt;, we made enhancements to the Insights query tagging feature for Postgres databases. Insights has supported query tags for some time, but they were previously only attached as metadata on individual notable query logs. With this release, tags are now present in aggregated query data, which enables powerful new capabilities. It's now possible to view the complete distribution of tags assigned to a query pattern, search queries by tag, and see a per-tag breakdown of database-level statistics. This blog post gives an overview of the feature, and digs into the details of how we implemented it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding Tags
&lt;/h2&gt;

&lt;p&gt;Query tags are string key-value pairs that are included in query SQL using specially formatted &lt;a href="https://google.github.io/sqlcommenter/" rel="noopener noreferrer"&gt;SQL comments&lt;/a&gt;. For example, the following query has the &lt;code&gt;controller&lt;/code&gt; and &lt;code&gt;action&lt;/code&gt; tags attached.&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;users&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="cm"&gt;/* controller='users',
     action='show' */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typically tags are specified at the application level and applied automatically to all queries issued by the database framework you're using. Common examples are &lt;code&gt;controller&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;job&lt;/code&gt;, or &lt;code&gt;source_location&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In addition to tags set by the database client, Insights automatically adds the following tags to all queries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;application_name&lt;/code&gt; - set by the Postgres driver&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;username&lt;/code&gt; - the Postgres user executing the query&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;remote_address&lt;/code&gt; - the remote IP address&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Feature Overview
&lt;/h2&gt;

&lt;p&gt;This feature introduces three new surfaces where tag information can be seen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query Pattern Tags
&lt;/h3&gt;

&lt;p&gt;To see the set of tags associated with a given query pattern, click on a query pattern from the main Insights dashboard. This page lists the tags that have been submitted with a given query pattern over a particular time range, as well as the percentage of queries that included each tag value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fswdsrgvbse5z3spgi7jy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fswdsrgvbse5z3spgi7jy.png" alt="Query details" width="800" height="226"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Database Tags
&lt;/h3&gt;

&lt;p&gt;To see aggregate statistics for your entire database broken down by tag, go to the Tags section in the Insights sidebar and select the tag or set of tags that you want to view.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp2bvhvqcc59vya6fvlw9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp2bvhvqcc59vya6fvlw9.png" alt="Tags page" width="800" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Query Filter
&lt;/h3&gt;

&lt;p&gt;To see a list of query patterns that have a given tag value, go to the Insights dashboard and search for a particular tag with &lt;code&gt;tag:MY_TAG:MY_VALUE&lt;/code&gt;. The returned query patterns and statistics are filtered to only queries with the specified tag pair.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft4au1eocq0m6wq309pum.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft4au1eocq0m6wq309pum.png" alt="Query filter" width="800" height="121"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;

&lt;p&gt;To understand how tagging works in Insights, it helps to understand the underlying data sources that power Insights. Query performance data is observed by the Insights Postgres extension, emitted to Kafka and written to ClickHouse. The extension publishes to two separate Kafka topics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Individual queries - any query reading more than 10,000 rows, taking longer than 1 second, or resulting in an error. One message is sent per qualifying query. This powers the &lt;a href="https://planetscale.com/docs/postgres/monitoring/query-insights#notable-queries" rel="noopener noreferrer"&gt;Notable queries&lt;/a&gt; feature.&lt;/li&gt;
&lt;li&gt;Aggregate summaries - &lt;a href="https://planetscale.com/docs/postgres/monitoring/query-insights#available-query-statistics" rel="noopener noreferrer"&gt;statistics&lt;/a&gt; like total query count, rows read, and cumulative query time. One message is sent for every &lt;a href="https://planetscale.com/blog/query-performance-analysis-with-insights#query-patterns" rel="noopener noreferrer"&gt;query pattern&lt;/a&gt; every 15 seconds. This powers the majority of Insights including the query table, anomalies, and all query-related graphs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prior to this release, tag data was only attached to the individual query data stream. This adds important information to notable queries, but because the data wasn't present in the aggregate summaries, it wasn't possible to filter or group aggregate data by tag. Insights couldn't answer important questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What queries has this user executed?&lt;/li&gt;
&lt;li&gt;What percentage of my total query run time is coming from this controller?&lt;/li&gt;
&lt;li&gt;Which background jobs are executing this query?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our goal with this release was to associate all query data with the relevant tags to make it possible to answer this class of questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sending Tags
&lt;/h3&gt;

&lt;p&gt;To explore the various approaches for implementing tags, let's use the following query executions as an example:&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;users&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="cm"&gt;/*controller='users'*/&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;users&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;2&lt;/span&gt; &lt;span class="cm"&gt;/*controller='sessions'*/&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;users&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;3&lt;/span&gt; &lt;span class="cm"&gt;/*controller='sessions'*/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since each of these queries has the same fingerprint (query with all literal values removed), without tags we would only need to send a single summary message. To include tags, we have several options. The first would be to continue sending only a single query summary event with a count of how many times each tag was observed. This would produce a summary message like the following (other stats fields are omitted):&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;sql:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"select * from users where id = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;query_count:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;total_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"100ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;tags:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"controller=users"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"controller=sessions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;This message tells us the given query was executed three times - twice from the sessions controller and once from the users controller - and had a cumulative execution time of 100ms.&lt;/p&gt;

&lt;p&gt;At first glance, including tags in this manner is an attractive option. It's simple to implement - we just accumulate tags along with the other aggregate stats - and it doesn't increase the number of events that need to be emitted and stored. It has a serious shortcoming, however: it's not possible to attribute aggregated stats to any individual tag. For example, it's not possible to know the total time of queries emitted from just the users controller, because we can't tell what portion of the 100ms was associated with &lt;code&gt;controller=users&lt;/code&gt;. The summary data for one tag is permanently combined with the data from all tags.&lt;/p&gt;

&lt;p&gt;To overcome this limitation, we can instead emit a separate aggregate summary message for each set of unique tags. In our example this would mean we emit two separate messages to the insights pipeline:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;sql:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"select * from users where id = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;query_count:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;total_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;tags:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"controller"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;sql:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"select * from users where id = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;query_count:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;total_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"80ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;tags:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"controller"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sessions"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;This approach makes it possible to fully disambiguate aggregated statistics based on the attached tags. We can tell that the users controller was responsible for exactly 20ms of total execution time and the sessions controller was responsible for exactly 80ms.&lt;/p&gt;

&lt;p&gt;This comes at a cost though: we have to emit a separate message for each unique tag combination. This can be problematic for high-cardinality tags (tags with a large number of distinct values). Consider a customer that has set a &lt;code&gt;request_id&lt;/code&gt; tag on all of the queries issued from their web tier. Where we previously would be able to collapse 500 user-lookup queries into a single summary message, we now have to send 500 messages because they each have a unique &lt;code&gt;request_id&lt;/code&gt;. In the worst case, this means that the summary data stream must send one summary message &lt;em&gt;per query execution&lt;/em&gt;, and we've lost all of the scalability advantages of aggregating query statistics. For large clusters executing millions of queries per second, this would be prohibitively expensive to process and store, and would consume considerable resources on the database host where telemetry data is emitted.&lt;/p&gt;

&lt;p&gt;To prevent this from overwhelming the pipeline, we implemented several strategies to dynamically reduce the cardinality of tags and therefore decrease the number of messages that must be handled by the Insights pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cardinality Reduction
&lt;/h2&gt;

&lt;p&gt;The core idea is simple: when a tag (or set of tags) would result in sending too much telemetry data, we collapse that tag by replacing specific values (like &lt;code&gt;request_id="a"&lt;/code&gt; and &lt;code&gt;request_id="b"&lt;/code&gt;) with a value that indicates it has been removed: &lt;code&gt;request_id=*&lt;/code&gt;. This lets us more aggressively merge aggregates and reduce the total number of messages sent, while ensuring that we're capturing 100% of the summary data.&lt;/p&gt;

&lt;p&gt;We employed two separate approaches for tag collapsing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-tag Limits
&lt;/h3&gt;

&lt;p&gt;This mechanism tracks the number of unique values seen for each tag key, scoped per query pattern. If that count exceeds a predefined limit (currently 20), we proactively collapse that key for all queries for the next hour. This catches inherently high-cardinality tags like &lt;code&gt;request_id&lt;/code&gt; or &lt;code&gt;user_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An important part of this approach is that cardinality is monitored &lt;em&gt;per query pattern&lt;/em&gt; and not globally. Consider the &lt;code&gt;source_location&lt;/code&gt; tag that contains the file and line number showing where the query was initiated in the client app. Overall this tag is high-cardinality, because each query pattern likely has its own unique value for &lt;code&gt;source_location&lt;/code&gt;, but it is highly correlated with the query pattern so it doesn't actually result in additional messages being sent to the pipeline - we are already sending a separate query summary message for each query pattern. Monitoring cardinality per-pattern allows high-cardinality tags that are highly correlated with query pattern to pass through without being collapsed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-interval Limits
&lt;/h3&gt;

&lt;p&gt;Within each 15-second interval, we track all aggregates keyed by their unique set of tag key-value pairs. Because we must emit a message for each unique &lt;em&gt;combination&lt;/em&gt; of tags, even individually low-cardinality tags could produce an unacceptably large number of &lt;em&gt;combinations&lt;/em&gt; of tags. For example, if a query pattern has 6 tag keys that each have 10 distinct values, there could be 10^6 individual tag combinations. To prevent an explosion in the number of messages that must be tracked, we perform dynamic cardinality reduction on a per-interval basis for any individual query pattern that has more than a fixed number of tag combinations.&lt;/p&gt;

&lt;p&gt;To reduce the combined cardinality of a given set of aggregates, we find the highest cardinality tag and collapse it (replace all values with a single value). We successively perform this operation until the number of aggregates is beneath the fixed threshold (currently set to 50 in production).&lt;/p&gt;

&lt;p&gt;To illustrate this operation, consider five executions of the same query pattern.&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;users&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="o"&gt;?&lt;/span&gt; &lt;span class="cm"&gt;/*controller='users',    host='app-1'*/&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;users&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="o"&gt;?&lt;/span&gt; &lt;span class="cm"&gt;/*controller='users',    host='app-2'*/&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;users&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="o"&gt;?&lt;/span&gt; &lt;span class="cm"&gt;/*controller='sessions', host='app-3'*/&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;users&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="o"&gt;?&lt;/span&gt; &lt;span class="cm"&gt;/*controller='sessions', host='app-4'*/&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;users&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="o"&gt;?&lt;/span&gt; &lt;span class="cm"&gt;/*controller='sessions', host='app-1'*/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without any limits, this produces five separate aggregate messages. To reduce the aggregate message count, we identify that the host tag has the highest cardinality (4 unique values) and replace all of its values with a placeholder and merge the remaining results. This yields only two combinations that must be emitted to the pipeline, one for each of the two unique &lt;code&gt;controller&lt;/code&gt; tag values.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracking Tag Collapsing
&lt;/h3&gt;

&lt;p&gt;When a tag must be collapsed due to either of the cardinality limitation mechanisms, we record the fact that the key has been collapsed in the emitted aggregate message. This allows us to detect when collapsing has occurred and display a message noting the percentage of tag values where the value is unknown.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;Query tagging is a powerful feature. Being able to slice your Insights data by arbitrary tags gives you a much clearer picture of your database performance. We're excited for you to try it.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>planetscale</category>
      <category>webdev</category>
      <category>database</category>
    </item>
  </channel>
</rss>
