<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <!-- Source: https://planetscale.com/blog/feed.atom -->
  <title>Blog — PlanetScale</title>
  <subtitle>Posts about the PlanetScale platform, MySQL, PostgreSQL, databases, and more.</subtitle>
  <link href="https://siftrss.com/f/y6MV6p117oA"/>
  <link rel="alternate" type="text/html" hreflang="en" href="https://planetscale.com/blog"/>
  <link rel="self" type="application/atom+xml" href="https://siftrss.com/f/y6MV6p117oA"/>
  <id>https://siftrss.com/f/y6MV6p117oA</id>
  <updated>2026-10-14T00:00:00.000Z</updated>
  <entry>
    <title>PlanetScale is now available on Azure</title>
    <link href="https://planetscale.com/blog/planetscale-is-now-on-azure"/>
    <id>https://planetscale.com/blog/planetscale-is-now-on-azure</id>
    <published>2026-10-13T00:00:00.000Z</published>
    <updated>2026-10-13T00:00:00.000Z</updated>
    <author>
      <name>Joe Miller</name>
    </author>
    <author>
      <name>Matt Layher</name>
    </author>
    <category term="product"/>
    <category term="company"/>
    <content type="html"><![CDATA[<p>PlanetScale is now available on Microsoft Azure. Starting today, you can create PlanetScale Postgres and Neki databases in select Azure regions, alongside the AWS and Google Cloud regions we already support. Vitess databases are not currently available on Azure.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/create-database-azure-BMkK-98L.png?auto=compress%2Cformat"><img alt="Creating a PlanetScale Postgres database in the Azure centralus region" src="https://planetscale-images.imgix.net/assets/create-database-azure-BMkK-98L.png?auto=compress%2Cformat" width="2922" height="1634" loading="lazy"></picture></p><h2 id="why-azure"><a href="https://planetscale.com/blog/planetscale-is-now-on-azure#why-azure">Why Azure?</a></h2><p>Many teams run their apps on Azure, and we&#x27;ve received consistent requests, especially over the past year, to bring PlanetScale to where they&#x27;re building. Now those teams can use PlanetScale alongside all their other Azure compute and infrastructure.</p><h2 id="whats-included"><a href="https://planetscale.com/blog/planetscale-is-now-on-azure#whats-included">What&#x27;s included?</a></h2><p>Databases on Azure get the same PlanetScale features you use on our other clouds: high availability with a primary and replicas spread across 3 availability zones, Query Insights, schema recommendations, automated backups, and more. That includes Metal clusters with locally attached NVMe storage, the fastest way to run databases  in the cloud.</p><h2 id="supported-azure-regions"><a href="https://planetscale.com/blog/planetscale-is-now-on-azure#supported-azure-regions">Supported Azure regions</a></h2><p>Azure support is currently available in the following regions:</p><ul><li><code>centralus</code> (Iowa, USA)</li><li><code>eastus2</code> (Virginia, USA)</li><li><code>westeurope</code> (Netherlands)</li></ul><p>If you&#x27;d like to see PlanetScale in other Azure regions, <a href="https://ps-regions.io/">let us know</a>. See the <a href="https://planetscale.com/docs/plans/regions">regions documentation</a> for the full list across all cloud providers.</p><h2 id="what-it-took"><a href="https://planetscale.com/blog/planetscale-is-now-on-azure#what-it-took">What it took</a></h2><p>Bringing PlanetScale to a new cloud took significant time, particularly in testing and ensuring it behaves just as seamlessly on Azure as it does on AWS and GCP. Every cloud has its quirks, and Azure is no exception.</p><p>Kubernetes pod networking is a good example. PlanetScale runs databases on Kubernetes, and every database pod needs its own IP address on the network. We wanted those addresses to come from the Azure virtual network directly, with native routing and no overlay for maximum performance and low latency.</p><p>The simple approach is to request one IP from Azure per pod, which causes two problems. Azure caps a virtual network at 65,536 private IP addresses, which would limit the whole network to about 65,000 pods. It also puts the Kubernetes API server in the path of every pod start, which slows things down under load.</p><p>We solved both by handing out addresses in blocks. Each node gets blocks of 16 IPs (<code>/28</code> prefixes) on its network interface, and Azure counts each block as a single address toward the cap. That increases our overall IP capacity by 16x, to over a million pod IPs per network. A background service on each node tracks its blocks, and a lightweight CNI plugin assigns pod IPs from them locally, so the API server stays out of the way. When a node gets down to 8 free addresses, it grabs another block, up to 256 blocks (4,096 pod IPs) per node.</p><p>This sounds like a simple solution, but it took a surprising amount of custom work to get just right.</p><p>Azure had a few additional networking challenges along the way. <code>/28</code> is the only block size it offers, so the design had to work around that. Older versions of the network interface API don&#x27;t return prefixes at all, and the block&#x27;s range comes back in a field normally used for a single address. There&#x27;s also a chicken-and-egg problem: system pods need IPs before the Kubernetes operator that manages the blocks is running. To handle that, the operator has a bootstrap mode that runs on the host network.</p><p>In scale testing, one cluster ran over 6,700 pods across more than 100 node pools, with the busiest nodes holding 15 blocks (240 IPs) each.</p><p>This is just one of many pieces we had to carefully construct to make PlanetScale on Azure a seamless experience for our customers. Storage, load balancing, identity, and backups all needed the same level of care. All of that work culminates in today&#x27;s launch.</p><h2 id="getting-started"><a href="https://planetscale.com/blog/planetscale-is-now-on-azure#getting-started">Getting started</a></h2><p>To create a database on Azure, choose Azure as the cloud provider when you create a new database, then select a region. Existing databases can be migrated to Azure by contacting <a href="https://support.planetscale.com/">support</a>.</p><p>If you have questions or want help planning a move, <a href="https://planetscale.com/contact">get in touch</a>.</p>]]></content>
    <summary><![CDATA[PlanetScale Postgres and Neki databases can now be deployed on Microsoft Azure]]></summary>
  </entry>
  <entry>
    <title>What today's software owes to Bronze Age clocks</title>
    <link href="https://planetscale.com/blog/leaky-buckets-in-software-explained"/>
    <id>https://planetscale.com/blog/leaky-buckets-in-software-explained</id>
    <published>2026-10-07T00:00:00.000Z</published>
    <updated>2026-10-07T00:00:00.000Z</updated>
    <author>
      <name>Simeon Griggs</name>
    </author>
    <category term="engineering"/>
    <category term="postgres"/>
    <content type="html"><![CDATA[<p>Here&#x27;s a deceptively simple question: should this function execute?</p><p>Sometimes, the answer is obvious. Is the user logged in? Does their role have the required permissions?</p><p>Sometimes it&#x27;s less obvious. Are there sufficient resources available? Is this work a higher priority than everything else?</p><p>There are many names for the mechanisms that block or allow actions in software. Most commonly they are referred to as <strong>admission control</strong>. Perhaps you&#x27;ll be familiar with a <strong>leaky bucket</strong> implementation.</p><h2 id="a-brief-history-of-leaky-buckets"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#a-brief-history-of-leaky-buckets">A brief history of leaky buckets</a></h2><p>Engineering applications for leaky buckets predate computers. From around 1600 BC the Babylonians, Egyptians, and Persians all used vessels with a small hole in the bottom as primitive clocks, measuring elapsed time by the volume of the container as it drained at a constant rate.</p><iframe src="https://planetscale.com/blog/leaky-buckets-in-software-explained/iframe#water-clock" title="Animated: a simplified isometric model of Ctesibius&#x27; water clock" loading="lazy"></iframe><p>By around 200 BC the Greeks had advanced leaky buckets into accurate clocks. A floating regulator kept water flowing steadily from one bucket into a second, where a rising float moved a pointer to show the hour. A siphon emptied the second bucket once it was full. Most notably, Ctesibius added gears to regularly advance each &quot;tick&quot; of the clock, turning the display which indicated the current season.</p><p>A mechanical movement driven by a leaky bucket was born, and it became the gold standard of timekeeping for centuries.</p><p>Water-based clocks were used as a form of admission control. In Athenian courts, the length of time a speaker had to plead their case was measured by the time water took to drain from a vessel. The amount of water poured into the vessel was determined by the severity of the case.</p><p>Finally, in the computer age, Jonathan Turner is said to have used the term &quot;leaky bucket&quot; first in 1986, writing &quot;New directions in communications&quot; for IEEE Comms Magazine. The piece describes how a system can police the pace at which packets are sent over broadband connections. (If you haven&#x27;t read it, spoiler: it&#x27;s not much slower than your connection at home today.)</p><h2 id="leaky-buckets-in-software"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#leaky-buckets-in-software">Leaky buckets in software</a></h2><p>There are two kinds of leaky buckets in software, to implement a <strong>meter</strong> or a <strong>queue</strong>.</p><p>In both, each unit of &quot;work&quot; is assigned a value which is checked against the current volume of a &quot;bucket.&quot; If the bucket lacks capacity for the size of new work, that work is rejected. Otherwise the work is allowed and its value adds to the bucket&#x27;s current volume. The bucket drains (or leaks, if you will), so over time, new work can&#x27;t be admitted faster than the bucket drains. The drain rate is based on time elapsed, not on the completion of work.</p><p>When used as a <strong>meter</strong>, the bucket defines if and how much work is permitted to <em>immediately proceed</em>. As a <strong>queue</strong>, the bucket defines the volume of work that can <em>wait to proceed</em>, but work only begins as it drains from the bucket.</p><p>In this post we&#x27;ll focus on leaky buckets as a meter.</p><p>Typically, leaky buckets are implemented in software to keep systems healthy. A sort of resource management to ensure the system is not overwhelmed.</p><p>It&#x27;s not hard to imagine this being useful in a database. Imagine being able to block or allow queries, before they execute, based on available resources or concurrency limits. One can dream!</p><iframe src="https://planetscale.com/blog/leaky-buckets-in-software-explained/iframe" title="Interactive: a leaky bucket with adjustable volume and drain rate" loading="lazy"></iframe><p><em>The leaky bucket can be tweaked by modifying its volume and the rate at which it drains.</em></p><p>At its core a leaky bucket prevents work, but it also forces us to reason about the total volume of work that results in a block, how quickly more capacity becomes available, and how to handle the expected failure state when work is rejected.</p><h3 id="leaky-buckets-in-api-design"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#leaky-buckets-in-api-design">Leaky buckets in API design</a></h3><p>Shopify&#x27;s GraphQL Admin API implements rate limiting with a <a href="https://shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits">documented leaky bucket</a>. Each app on each store gets a bucket with a fixed size and restore rate.</p><p>Each request costs a different amount based on what the query asks for, not how many requests are made. Before a query runs, the bucket must have room for its estimated cost. Once it completes, the bucket is refunded or charged any difference between the estimated and actual cost. Apps can burst faster than the restore rate until the bucket has no room left, at which point requests are throttled.</p><p>Responses include the current state of the bucket, so apps can slow down before they&#x27;re throttled and treat throttling as an expected outcome, not a surprise.</p><h3 id="classification-as-admission-control"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#classification-as-admission-control">Classification as admission control</a></h3><p>Now let&#x27;s consider the AI age. Jev, a &quot;System One&quot; model (perhaps better named a &quot;<a href="https://x.com/Mappletons/status/2101560333441610133">Decision Model</a>&quot;), recently launched, making classification through inference cheap and fast. It could classify &quot;should this work proceed or not&quot; based on a predefined set of options and return a response in hundreds of milliseconds.</p><div class="mb-3 border p-3 border-blue-600 dark:border-blue-500"><p><span class="bg-blue-600 px-sm text-white dark:bg-blue-500 dark:text-black">Note</span></p><p>Fun full-circle fact: In 1907, the French scientist Lapicque modeled neurons as a charge that accumulates, leaks away, and fires at a threshold, later called the &quot;leaky integrate-and-fire&quot; model. So something like a leaky bucket has long been used to describe <strong>neurons</strong>, making it part of the origin story of <strong>neural networks</strong>, and so of Jev.</p></div><p>Whether our decision gate is <em>mechanical</em> (leaky bucket) or requires <em>inference</em> (Jev), the requirement is the same: answering &quot;should this proceed?&quot;</p><h3 id="low-cost-throttling"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#low-cost-throttling">Low cost throttling</a></h3><p>One of the great benefits of leaky buckets in software is how cheap they are. What we&#x27;ve described as a slowly draining bucket isn&#x27;t a long-running process that is using CPU to &quot;drain&quot;.</p><p>Simply put, a bucket is a stateful object that holds two values:</p><ol><li><strong>how full</strong> it was the last time anyone looked</li><li><strong>when</strong> that was</li></ol><p>From those values, you can work out how full the bucket is now:</p><div class="code-block"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>level_now = max(0, level_then - drain_rate × (now - then))</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>Any work rejected or admitted by the bucket updates its current state. In this way, buckets are lazily drained (as in, only when checked) and empty buckets hold no values.</p><h2 id="admission-controls-and-databases"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#admission-controls-and-databases">Admission controls and databases</a></h2><p>Just like hardware, operating systems, applications, and ancient Greeks, databases benefit from leaky buckets.</p><p>However, open-source databases like Postgres and MySQL don&#x27;t have this functionality baked in.</p><p>One of the most common failure modes for databases in production is the inability to deliberately reject work they cannot complete. Blast a billion queries at your typical relational database, and it&#x27;ll try its best to execute them. Success isn&#x27;t likely. Some hardware, configuration, or connection limitation will unwittingly play the role of an admission control system and start blocking work in some unhandled, undesirable way.</p><p>Some proprietary databases have a form of resource management so that workloads can be throttled (but not rejected). For example, in 1999 the Oracle Database Resource Manager added mapping rules to sessions for users, services, clients, and more, which could then be used to apply limits on things like resource use and concurrency.</p><p>In 2008, SQL Server added the SQL Server Resource Governor, which added classifier functions to sessions that can then apply rules to queries in that session.</p><p>Postgres has some configuration options to control expensive queries that have <em>already</em> consumed resources, such as <code>statement_timeout</code>, but nothing to prevent expensive work before it happens. And so in 2026, PlanetScale brought decision gates to Postgres with Database Traffic Control®.</p><h2 id="database-traffic-control"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#database-traffic-control">Database Traffic Control</a></h2><p>The health of your Postgres database is laid bare at the mercy of your applications. An unexpected connection storm or <a href="https://planetscale.com/blog/when-the-postgres-query-planner-goes-rogue">failure of the query planner</a> can ask more of your database than it can reasonably be expected to handle.</p><p>To protect your Postgres database from your application (and at times, from itself), we built Traffic Control, admission control for Postgres. In it, resource budgets determine how many resources or how much concurrency one or many queries can consume.</p><p>Each budget admits or blocks queries that match a specified set of tags. Those tags can be specified as comments in the query, or they can refer to the user or application that sent it. If the resource budget determines the query should not proceed, it will log a warning or block it, depending on your resource budget configuration.</p><p>Creating a resource budget opts you into an expected failure state, so it forces you to reason about the behavior your application should exhibit when queries are blocked. For this reason, we recommend creating a new budget in warning mode first, monitoring how much traffic is affected, preparing your application for blocked queries, and then updating the mode.</p><h3 id="admission-controls-but-for-traffic"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#admission-controls-but-for-traffic">Admission controls but for traffic</a></h3><p>You configure <a href="https://planetscale.com/docs/postgres/traffic-control/concepts#resource-budget-limits">three major settings</a> on a resource budget.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/server-share-burst-limit-DGMkwJno.png?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/server-share-burst-limit-darkmode-DqJFlt7P.png?auto=compress%2Cformat"><img alt="Server share and burst limit settings on a resource budget" src="https://planetscale-images.imgix.net/assets/server-share-burst-limit-DGMkwJno.png?auto=compress%2Cformat" width="1244" height="569" loading="lazy"></picture></p><p><strong>Server share and burst limit</strong>, the token* bucket. Server share determines how fast the bucket replenishes with tokens, expressed as a percentage of server resources. The burst limit sets the maximum capacity of the bucket, expressed in whole-server seconds.</p><p>Because a database doesn&#x27;t know the cost of a query before execution, Traffic Control estimates its cost based on the query plan and historical behavior of queries with that shape. If the budget has sufficient capacity the query proceeds. After execution, the cost of the query is calculated and any difference is either refunded or charged to the token bucket.</p><p>Why is the refill rate measured in server capacity instead of seconds? Because not all time spent executing a query is equal. A query that took 8 cores a single second is different from a query that took one core a single second. Consider different server sizes as well: a 25% server share means a quarter of your server&#x27;s capacity drains back every second, whether that server has 2 cores or 64.</p><div class="mb-3 border p-3 border-blue-600 dark:border-blue-500"><p><span class="bg-blue-600 px-sm text-white dark:bg-blue-500 dark:text-black">Note</span></p><p>*Hold on, <strong>token</strong> bucket?</p><p>A <em>token</em> bucket works the same as a <em>leaky</em> bucket, but with a reversed metaphor. The bucket starts filled with &quot;tokens&quot; and each piece of work is assigned a &quot;cost.&quot; Work is only admitted if there are sufficient tokens remaining for that work. Tokens refill over time. It&#x27;s a bit easier to reason about the remaining capacity of a resource budget in terms of a bucket&#x27;s remaining tokens than its current fill level.</p><p>Naming things, right?</p></div><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/per-query-limit-ZNVEaYkv.png?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/per-query-limit-darkmode-BXdR_omg.png?auto=compress%2Cformat"><img alt="Per-query limit setting on a resource budget" src="https://planetscale-images.imgix.net/assets/per-query-limit-ZNVEaYkv.png?auto=compress%2Cformat" width="1244" height="307" loading="lazy"></picture></p><p><strong>Per-query limit</strong> sets an upper limit on the maximum capacity a single query can consume. This makes an admission-control decision directly on a single query instead of measuring it against available capacity in a bucket.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/max-concurrent-workers-DrvFI6m-.png?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/max-concurrent-workers-darkmode-8SARE59j.png?auto=compress%2Cformat"><img alt="Maximum concurrent workers setting on a resource budget" src="https://planetscale-images.imgix.net/assets/max-concurrent-workers-DrvFI6m-.png?auto=compress%2Cformat" width="1244" height="341" loading="lazy"></picture></p><p><strong>Maximum concurrent workers</strong> similarly controls the resources a single query can consume, but measures it by available workers instead of execution time.</p><p>A query may be targeted by multiple resource budgets and must satisfy all of them to proceed.</p><p>For specific scenarios where resource budgets help, along with how to tag queries in your codebase, see <a href="https://planetscale.com/blog/patterns-for-postgres-traffic-control">Patterns for Postgres Traffic Control</a>.</p><h2 id="summary"><a href="https://planetscale.com/blog/leaky-buckets-in-software-explained#summary">Summary</a></h2><p>Admission control forces you to decide what should happen when work can&#x27;t proceed, and leaky buckets have been a cheap way to make that decision for a very long time. Traffic Control makes that decision for Postgres before an expensive query runs, not after.</p><p>Get started with <a href="https://planetscale.com/docs/postgres/traffic-control">Database Traffic Control</a></p>]]></content>
    <summary><![CDATA[Admission controls in software determine what does or doesn't happen next, and the most common implementation has ancient roots.]]></summary>
  </entry>
  <entry>
    <title>Building a faster, lazier router for Postgres</title>
    <link href="https://planetscale.com/blog/designing-neki-for-performance"/>
    <id>https://planetscale.com/blog/designing-neki-for-performance</id>
    <published>2026-10-01T00:00:00.000Z</published>
    <updated>2026-10-01T00:00:00.000Z</updated>
    <author>
      <name>Dirkjan Bussink</name>
    </author>
    <category term="engineering"/>
    <category term="neki"/>
    <content type="html"><![CDATA[<p>Nothing is as fast as not doing anything. That sounds obvious, but it is also surprisingly easy to forget when building software.</p><p><a href="https://planetscale.com/blog/announcing-neki">Neki</a> adds a layer between a PostgreSQL client and PostgreSQL. We need that layer to route queries to shards and to make <a href="https://planetscale.com/blog/making-768-servers-look-like-1">many PostgreSQL servers look like one</a>. The downside is that every query now has another network hop and has to pass through the <a href="https://planetscale.com/blog/what-is-a-neki-router">router</a>.</p><p>We can&#x27;t make that hop disappear, so a lot of the performance work in Neki comes down to what we can avoid doing while the query passes through it. If the router doesn&#x27;t need to understand a result, why decode it? If it only needs one column, why decode the other ten columns in the same row?</p><p>The key part here is that we can design this to be lazy and composable. Walking the PostgreSQL messages in a response does not mean we also need to construct rows. Finding the rows does not mean we need to decode their columns either. Even if the router needs one value, the rest of the row can stay as protocol bytes.</p><h2 id="why-not-decode-everything-up-front"><a href="https://planetscale.com/blog/designing-neki-for-performance#why-not-decode-everything-up-front">Why not decode everything up front?</a></h2><p>The usual way to build a layer like this is to parse whatever comes over the wire into your own types as soon as it arrives. Every row becomes a list of values, and the rest of the code only ever deals with those. It&#x27;s a sensible default because it gives the rest of the system one consistent thing to work with.</p><p>Vitess, the MySQL sharding system we maintain, works this way, so it&#x27;s a useful comparison. Vitess sends query results between its components using protobuf. Leaving out the fields that aren&#x27;t important here, <a href="https://github.com/vitessio/vitess/blob/main/proto/query.proto"><code>QueryResult</code> and <code>Row</code></a> look like this:</p><div class="code-block" data-language="json"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">message QueryResult </span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">{</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  repeated</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> Field</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> fields</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 1;</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  uint64</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> rows_affected</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 2;</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  uint64</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> insert_id</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 3;</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  repeated</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> Row</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> rows</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 4;</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  string</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> info</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 6;</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  string</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> session_state_changes</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 7;</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  bool</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> insert_id_changed</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 8;</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">message Row </span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">{</span></span>
<span class="line"><span style="--shiki-light:#818181;--shiki-dark:#A1A1A1">  // -1 means NULL.</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  repeated</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> sint64</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> lengths</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 1;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#818181;--shiki-dark:#A1A1A1">  // All non-NULL values concatenated together.</span></span>
<span class="line"><span style="--shiki-light:#C11027;--shiki-dark:#FF909F">  bytes</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> values</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> =</span><span style="--shiki-light:#C11027;--shiki-dark:#FF909F"> 2;</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span></code></pre></div></div><p>This is a pretty compact protobuf representation. <code>lengths</code> tells us where the values are in the shared <code>values</code> byte string, so the protobuf doesn&#x27;t need a separate allocation for every value. The query engine can&#x27;t use the protobuf rows directly though. At the RPC boundary, <a href="https://github.com/vitessio/vitess/blob/main/go/sqltypes/proto3.go"><code>proto3ToRows</code></a> converts all of them:</p><div class="code-block" data-language="go"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">func</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> proto3ToRows</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">fields</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> []</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">*querypb</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Field</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> rows</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> []</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">*querypb</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Row</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">)</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> [][]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Value</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> {</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">    result</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> :=</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> make</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">([][]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Value</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> len</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">rows</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">))</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">    for</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> i</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> r</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> :=</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> range</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> rows</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> {</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">        result</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">i</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> MakeRowTrusted</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">fields</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> r</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">)</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    }</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">    return</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> result</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span></code></pre></div></div><p>And <a href="https://github.com/vitessio/vitess/blob/main/go/sqltypes/result.go"><code>MakeRowTrusted</code></a> walks every column in every row:</p><div class="code-block" data-language="go"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">func</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> MakeRowTrusted</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">fields</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> []</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">*querypb</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Field</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> row</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> *querypb</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Row</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">)</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> []</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Value</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> {</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">    sqlRow</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> :=</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> make</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">([]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">Value</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> len</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">row</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Lengths</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">))</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">    var</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> offset</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> int64</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">    for</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> i</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> length</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> :=</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> range</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> row</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Lengths</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> {</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">        if</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> length</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> &#x3C;</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082"> 0</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> {</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">            continue</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">        }</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">        sqlRow</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">i</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> MakeTrusted</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">            fields</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">i</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">].</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Type</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">            row</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Values</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">offset</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">offset</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">+</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">length</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">],</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">        )</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">        offset</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> +=</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> length</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    }</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">    return</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> sqlRow</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span></code></pre></div></div><p><code>MakeTrusted</code> doesn&#x27;t turn every integer into a Go integer. A <code>Value</code> can still point at the bytes in the protobuf row. What we do get is a slice for every row and a <code>Value</code> for every non-NULL column before the query engine can do anything with the result. Going back across an RPC boundary does the reverse. <a href="https://github.com/vitessio/vitess/blob/main/go/sqltypes/proto3.go"><code>RowToProto3Inplace</code></a> first walks all the values to build the lengths and then walks them again to concatenate their bytes.</p><p>The protobuf result sits between separate Vitess components, while the query engine works with <code>[][]Value</code>. Converting at the boundary gives the engine one consistent type to work with. The downside is that every result gets converted, even when the router never looks at a value.</p><p>We wanted Neki to perform better, and therefore do less work in such cases.</p><h2 id="keeping-the-postgresql-response"><a href="https://planetscale.com/blog/designing-neki-for-performance#keeping-the-postgresql-response">Keeping the PostgreSQL response</a></h2><p>Neki also uses protobuf between the router and the <a href="https://planetscale.com/blog/the-architecture-of-neki#sidecar">sidecar</a> that sits in front of each PostgreSQL instance, but the result protobuf looks very different. Our <code>ExecuteResponse</code> is mostly a byte string:</p><div class="code-block" data-language="protobuf"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>message ExecuteResponse {</span></span>
<span class="line"><span>  bytes raw = 1;</span></span>
<span class="line"><span>  int64 conn_id = 2;</span></span>
<span class="line"><span>  ...</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span></span></span></code></pre></div></div><p><code>raw</code> contains PostgreSQL response messages. A <code>DataRow</code> in there is already a valid PostgreSQL <code>DataRow</code> that we can send to the client. Of course we still have to decode the <code>ExecuteResponse</code> protobuf to get the <code>raw</code> byte slice. That still leaves us with one byte slice. What we avoid is a protobuf object for every row, another slice of lengths for that row and a value for every column.</p><p>That doesn&#x27;t mean the router ignores the response. <code>NewQueryResponse</code> walks the five-byte message headers, validates the lengths, handles <code>CommandComplete</code> and finds the region containing the <code>DataRow</code> and <code>NoticeResponse</code> messages. This is stored as one byte slice in <code>DataRows</code>. We only open it further if the query needs us to.</p><p>For the examples here, we&#x27;ll use an <code>orders</code> table sharded by <code>account_id</code>:</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">CREATE</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> TABLE</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> orders</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> (</span></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">    id uuid </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">NOT NULL</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">,</span></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">    account_id </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">bigint</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> NOT NULL</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">,</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">    status</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> text</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> NOT NULL</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">,</span></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">    created_at </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">timestamptz</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> NOT NULL</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">,</span></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">    total </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">numeric</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> NOT NULL</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">,</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">    PRIMARY KEY</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> (account_id, id)</span></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">);</span></span>
<span class="line"></span></code></pre></div></div><h2 id="a-result-we-dont-need-to-decode"><a href="https://planetscale.com/blog/designing-neki-for-performance#a-result-we-dont-need-to-decode">A result we don&#x27;t need to decode</a></h2><p>The simplest example is a point lookup:</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> id, </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">status</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">, total</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">WHERE</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> account_id </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">=</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> $</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082">1</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">  AND</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> id </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">=</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> $</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082">2</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span></code></pre></div></div><p>Because <code>account_id</code> is the sharding key, Neki needs the value in <code>$1</code> to pick the shard. It doesn&#x27;t need to understand <code>id</code>, <code>status</code> or <code>total</code> when they come back. Those values only need to make it from PostgreSQL to the client. We can use the same approach on the way into the router too.</p><h3 id="bind-parameters-are-rows-going-the-other-way"><a href="https://planetscale.com/blog/designing-neki-for-performance#bind-parameters-are-rows-going-the-other-way">Bind parameters are rows going the other way</a></h3><p>With PostgreSQL&#x27;s extended query protocol, <code>$1</code> and <code>$2</code> arrive in a <a href="https://www.postgresql.org/docs/current/protocol-message-formats.html"><code>Bind</code> message</a>. Each parameter is length-prefixed as a column in a <code>DataRow</code>:</p><div class="code-block" data-language="text"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>NULL:     Int32(-1)</span></span>
<span class="line"><span>non-NULL: Int32(length), followed by value bytes</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>The difference is where the format comes from. <code>Bind</code> includes format codes for its parameters, while the formats for <code>DataRow</code> columns come from <code>RowDescription</code>. The <a href="https://www.postgresql.org/docs/current/protocol-message-formats.html">PostgreSQL protocol message documentation</a> has all the details if you want to know all the fun little bits about the protocol.</p><p>Neki keeps the original <code>Bind</code> message and a cache for the parameters it has decoded. For our point lookup, the router scans the parameter frames, decodes the bigint in <code>$1</code> and caches it. We don&#x27;t need <code>$2</code> for routing. When the query is sent to the shard, Neki writes the original parameter frames directly as long as the client encoding hasn&#x27;t changed.</p><p>Keeping the original frames also helps when we rewrite a query. A rewritten query might use only some parameters, put them in a different order or use the same one multiple times. A mapping like <code>[1, 0, 1]</code> copies the parameter frames into a new <code>Bind</code>. We can use the same approach when adding parameters to the existing list.</p><p><a href="https://en.wikipedia.org/wiki/Nested_loop_join">Nested loop joins</a> have a similar case. A column from a row on the left becomes a parameter for a query on the right. Neki copies the complete length-prefixed column frame from the <code>DataRow</code> into the new <code>Bind</code>. We only turn the join key into a typed value if we also need it for routing or evaluation.</p><h3 id="text-stays-text-and-binary-stays-binary"><a href="https://planetscale.com/blog/designing-neki-for-performance#text-stays-text-and-binary-stays-binary">Text stays text and binary stays binary</a></h3><p>There is one more wrinkle: PostgreSQL values can be sent as text or binary. The client uses <code>Bind</code> to tell PostgreSQL which formats it used for the parameters and wants for the result columns. Instead of up-front conversions like Vitess would require, Neki keeps values in the original formats.</p><p>When a query selects <code>id</code> and <code>account_id</code> but orders by <code>created_at</code> without selecting it, <code>id</code> and <code>account_id</code> use the formats requested by the client. The extra <code>created_at</code> column uses binary because only Neki needs it. The client will never see those column values.</p><p>If Neki needs to decode a value, <code>RowDescription</code> tells it whether to use the text or binary decoder. If it doesn&#x27;t, the bytes stay untouched. When Neki produces a new value, it uses the format requested by the client.</p><p>Conversion is still needed if <code>client_encoding</code> changes between <code>Bind</code> and <code>Execute</code>. Affected text parameters are recoded before they are sent to PostgreSQL. The other parameters stay as they are.</p><p>Now let&#x27;s go back to the result from the point lookup. <code>DataRows</code> can hold one raw byte slice, a slice of rows that point into those bytes or a slice of pointers used to select and reorder those rows. It only creates the next representation when a query needs it.</p><p>In this case, Neki writes the raw bytes straight to the client.</p><p>No <code>DataRow</code> structs and no columns! Just the PostgreSQL messages that we already had in the correct Postgres wire format. This also works for cross-shard queries. In the query below, <code>status</code> isn&#x27;t the sharding key so it will have to get passed along to every shard to collect results:</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> id, account_id, </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">status</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">WHERE</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> status</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> 'pending'</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span></code></pre></div></div><p>There is no global order and no sums / averages to calculate across the results. This means we can optimize. After sending the scatter queries, Neki streams response chunks from each shard, combines the separate <code>CommandComplete</code> messages with <code>Collapse</code>, and <code>DataRow</code>s can be passed along unmodified. It&#x27;s a &quot;complex&quot; distributed query, but there is nothing in the rows we need to decode.</p><h2 id="adding-limit"><a href="https://planetscale.com/blog/designing-neki-for-performance#adding-limit">Adding <code>LIMIT</code></a></h2><p>Now let&#x27;s add a limit:</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> id, account_id, </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">status</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">WHERE</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> status</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> 'pending'</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">LIMIT</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082"> 100</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span></code></pre></div></div><p>Now we need to know when we&#x27;ve sent 100 rows, which means the response cannot be an opaque byte slice. <code>Limit</code> calls <code>DataRows.Len()</code>, and on the first call we walk the raw response and count the <code>DataRow</code> messages so the row slice can be allocated once. In a second pass, we create the <code>DataRow</code> wrappers which all point into the original response buffer.</p><p>Once we have the row boundaries, <code>Limit</code> builds a pointer slice for the requested window:</p><div class="code-block" data-language="go"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">ptrs</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> :=</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> make</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">([]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">*runtime</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">DataRow</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> n</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">)</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">for</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> i</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> :=</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> range</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> n</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> {</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">    ptrs</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">i</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> response</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Rows</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB">Row</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">int</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">startIdx</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">)</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> +</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> int</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">i</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">))</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">response</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Rows</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> runtime</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB">NewDataRowsFromPtrsWithNotices</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">    ptrs</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">    notices</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">)</span></span>
<span class="line"></span></code></pre></div></div><p><code>ptrs</code> is the third representation in <code>DataRows</code>. It lets us take a subset of the rows or put them in a different order without moving row messages.</p><p>For this query Neki knows where every row starts and ends. What it <em>doesn&#x27;t</em> know is the value of a single <code>id</code>, <code>account_id</code> or <code>status</code> column. Counting rows doesn&#x27;t require that... but what if it did?</p><h2 id="sorting-means-decoding-the-sort-key"><a href="https://planetscale.com/blog/designing-neki-for-performance#sorting-means-decoding-the-sort-key">Sorting means decoding the sort key</a></h2><p>For a pending orders page, we&#x27;d probably want a stable sorting:</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> id, account_id, created_at</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">WHERE</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> status</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> 'pending'</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">ORDER BY</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> created_at </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">DESC</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">LIMIT</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082"> 100</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span></code></pre></div></div><p>Each shard sorts its subset of results (within Postgres), but the router has to merge those distinct result sets into a global order. Now we finally have to decode a column. First we need the rows, the same as for <code>LIMIT</code>, and then we need to find <code>created_at</code> inside each row.</p><p>At the protocol level, a <code>DataRow</code> keeps the complete PostgreSQL message and a view of the bytes containing its columns. The column bytes start after the five-byte message header and the two-byte column count. Each column has a four-byte length followed by the value bytes. A length of <code>-1</code> means <code>NULL</code>.</p><p>To get to the third column, we have to read the lengths of the first two columns and skip their payloads. Reading the lengths of these columns doesn&#x27;t decode the values. We now know where <code>id</code> and <code>account_id</code> are, but the bytes still have no meaning to the router. With the offsets, we can jump to the correct bytes to grab <code>created_at</code> for each row.</p><p>These timestamps are cached because a row will normally be compared more than once while merging results. At this point all rows have been exposed and we&#x27;ve looked at their column framing. The actual values look like this though:</p><div class="code-block" data-language="text"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>id          frame located, payload not decoded</span></span>
<span class="line"><span>account_id  frame located, payload not decoded</span></span>
<span class="line"><span>created_at  frame located, payload decoded and cached</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>The merge produces another <code>DataRows</code> with pointers to the original rows, now in the right order. Sorting rows doesn&#x27;t require copying the rows.</p><iframe src="https://planetscale.com/blog/designing-neki-for-performance/rows-as-pointers/iframe" title="Sorting DataRows by rearranging pointers while the response buffer stays unchanged" loading="lazy"></iframe><h2 id="dropping-a-column-without-decoding"><a href="https://planetscale.com/blog/designing-neki-for-performance#dropping-a-column-without-decoding">Dropping a column without decoding</a></h2><p>A small change can have a big impact on what work we can and cannot skip. How about we don&#x27;t select <code>created_at</code>:</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> id, account_id</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">WHERE</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> status</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> 'pending'</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">ORDER BY</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> created_at </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">DESC</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">LIMIT</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082"> 100</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span></code></pre></div></div><p>Ordering by a column without returning it is a very normal query, but it does give the router one extra problem. The shards have to return <code>created_at</code> because Neki needs it for the merge. Before we send the rows to the client, that extra column has to be removed.</p><p>Recall from the last section that by the end we reached the point where <code>created_at</code> had been decoded, but <code>id</code> and <code>account_id</code> were still only protocol frames. For this one, there&#x27;s more to do.</p><p>The naive implementation would read <code>id</code> and <code>account_id</code> as values, build a new row and serialize them again. That&#x27;s a lot of work to remove a few bytes from the end of a message.</p><p>Instead, <code>SimpleProjection</code> stores the projection as runs of adjacent input columns. Each run contains the first and last column, so <code>{0,2}</code> is one run covering columns 0, 1 and 2. This lets <code>ProjectInto</code> copy all three column frames as one byte range. Skipping, reordering or duplicating columns creates more runs, stored in output order:</p><div class="code-block" data-language="text"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>stored runs          byte ranges copied</span></span>
<span class="line"><span>[{0,2}]              columns 0–2 in one copy</span></span>
<span class="line"><span>[{0,2}, {4,6}]       columns 0–2, then 4–6 in two copies</span></span>
<span class="line"><span>[{3,3}, {0,2}]       column 3, then 0–2 in two copies</span></span>
<span class="line"><span>[{0,0}, {0,0}]       column 0 twice in two copies</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>Our query needs only one run, <code>{0,1}</code>. At execution time we find the byte range containing both length-prefixed columns, write a new <code>DataRow</code> header with a column count of two and append both columns at once.</p><p>The byte-backed projection does one append per run. <code>Values</code> is the row&#x27;s view of the column bytes, and <code>offsets</code> contains the frame boundaries found while scanning it:</p><div class="code-block" data-language="go"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">for</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> _</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> r</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> :=</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> range</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600"> runs</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> {</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">    dst</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> =</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> append</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">(</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">        dst</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span></span>
<span class="line"><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">        dr</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Values</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">offsets</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">r</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">Start</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">]:</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">offsets</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">[</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">r</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">.</span><span style="--shiki-light:#A78103;--shiki-dark:#F2B600">End</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">+</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082">1</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">]]</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">...</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    )</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span></code></pre></div></div><iframe src="https://planetscale.com/blog/designing-neki-for-performance/data-row-projection/iframe" title="Projecting a PostgreSQL DataRow by copying complete column frames into a shared output buffer" loading="lazy"></iframe><p>This isn&#x27;t zero-copy because we are creating a different PostgreSQL message. The new header and the columns have to go somewhere. We do copy the exact <code>id</code> and <code>account_id</code> frames though, still saving a good amount of work through careful optimization.</p><p>Normally we&#x27;re projecting a complete response batch, not one random row. Neki appends all the new rows to one shared wire buffer. The finished rows are then bounded views into that buffer, so there isn&#x27;t a separate backing buffer for every output row.</p><p>The slightly funny part is that we decoded <code>created_at</code>, which the client didn&#x27;t ask for, and didn&#x27;t decode <code>id</code> or <code>account_id</code>, which it did ask for. That&#x27;s exactly the work this query required. In Neki, we take every opportunity to skip unnecessary work.</p><h2 id="when-we-really-need-values"><a href="https://planetscale.com/blog/designing-neki-for-performance#when-we-really-need-values">When we really need values</a></h2><p>Of course some queries do require the router to understand and produce values. A cross-shard aggregate is a good example:</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> status</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">, </span><span style="--shiki-light:#0B6EC5;--shiki-dark:#73C7F9">count</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">(</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">*</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">)</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">GROUP BY</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> status</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span></code></pre></div></div><p>The same <code>status</code> can exist on many shards, so we can&#x27;t rely on PostgreSQL to do the full <code>GROUP BY</code>. The Neki router must handle it.</p><p>We need the group key and the partial counts as values. There isn&#x27;t a useful byte-level trick for grouping equal values and adding the counts. The final count is also a new value produced by the router, so there are no original PostgreSQL bytes to copy.</p><p>For a row like this, we cannot get away with just passing along bytes from Postgres to clients.</p><p>When a results row from this query is sent to the client, Neki serializes those values into a new PostgreSQL <code>DataRow</code> in the format the client requested. This query MUST go through every layer:</p><div class="code-block" data-language="text"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>response messages</span></span>
<span class="line"><span>    -> result batches</span></span>
<span class="line"><span>        -> rows</span></span>
<span class="line"><span>            -> column frames</span></span>
<span class="line"><span>                -> typed values</span></span>
<span class="line"><span>                    -> newly computed values</span></span>
<span class="line"><span></span></span></code></pre></div></div><h2 id="bytes-and-values-at-the-same-time"><a href="https://planetscale.com/blog/designing-neki-for-performance#bytes-and-values-at-the-same-time">Bytes and values at the same time</a></h2><p>Neki doesn&#x27;t have one internal row representation. A row received from PostgreSQL starts wire-backed, while a row produced by an aggregate can be completely value-backed. The sorted rows from a few sections ago are a mix of the two: they keep the original message and have a cached <code>created_at</code> value.</p><div class="code-block" data-language="text"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>Original: complete PostgreSQL DataRow</span></span>
<span class="line"><span></span></span>
<span class="line"><span>values[0]: no decoded value</span></span>
<span class="line"><span>values[1]: no decoded value</span></span>
<span class="line"><span>values[2]: decoded created_at</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>The original message is still available for forwarding or projection, while the cache avoids decoding <code>created_at</code> again every time the row is compared.</p><p>Cached values survive projection too. If a projection keeps or moves a decoded column, Neki copies the original frame and remaps the cached value to its new position. We took the performance hit in decoding the timestamp because the query needed it, but we want to make sure we don&#x27;t decode or serialize it again.</p><p>For PostgreSQL records this can also be necessary for correctness. A record can have a transient <code>typmod</code> describing it, which isn&#x27;t always recoverable from the wire bytes. Raw projection won&#x27;t allocate a cache in the case where none of the projected columns have decoded values.</p><h2 id="using-this-trick-elsewhere"><a href="https://planetscale.com/blog/designing-neki-for-performance#using-this-trick-elsewhere">Using this trick elsewhere</a></h2><p>This way of working with protocol frames isn&#x27;t limited to sorting and projection. For joins, we decode the keys needed to match rows. The output can still be assembled from contiguous runs of columns from the left and right rows. We avoid doing the extra work of producing values whenever we can!</p><p>When we need to spill rows to disk, we also keep things in the self-framing PostgreSQL <code>DataRow</code> format. We can write and read from disk with minimal additional decoding overhead.</p><p>Keeping bind parameters in their original length-prefixed representation is not just about performance either. Parsing and serializing a value isn&#x27;t guaranteed to give us identical bytes. A floating point NaN payload, for example, can survive PostgreSQL&#x27;s receive function but change when it is sent again.</p><p>PostgreSQL extension types are another useful case. The router might have no idea how to evaluate a value from an extension, but it can still pass the protocol bytes through.</p><h2 id="keeping-the-layers-composable"><a href="https://planetscale.com/blog/designing-neki-for-performance#keeping-the-layers-composable">Keeping the layers composable</a></h2><p>The nice part of this model is that each representation builds on the previous one. <code>DataRows</code> can start with raw response bytes, expose those bytes as a row slice and then add a pointer slice for a subset or a different order. An individual row can keep its original PostgreSQL message while also caching the values that have been decoded. Going one layer deeper for one part of a query doesn&#x27;t force all columns / values to do so.</p><p>Of course that doesn&#x27;t make the complexity disappear. Byte-level projection still has to calculate the right offsets, write a new message header and copy the right column frames in the right order. The key thing for Neki&#x27;s performance though, is that it&#x27;s all contained in the row projection and batch builder.</p><p>It also gives us a small surface we can test directly. The projection tests construct PostgreSQL messages byte by byte and cover reordering, <code>NULL</code>, empty strings, empty <code>bytea</code>, duplicate columns and the protocol-backed path.</p><p>There are a few invariants we have to maintain for that containment to work. Shared response buffers have to remain alive as long as a row still points into them. We use three-index slices because an append through one row must not overwrite the next PostgreSQL message in the same buffer. Two consumers replaying the same buffered row need their own row wrapper and value cache, while the immutable protocol bytes can still be shared.</p><p>Memory accounting also has to know that a row can grow. It starts out retaining the wire bytes, but every string, numeric or record added to the cache uses more memory. And then it all has to work with <code>NULL</code>, text and binary formats, client encodings, arrays, domains, records and user-defined types. If moving the bytes around changes the result, we&#x27;ve built a very fast bug 😉.</p><h2 id="nothing-is-still-the-fastest"><a href="https://planetscale.com/blog/designing-neki-for-performance#nothing-is-still-the-fastest">Nothing is still the fastest</a></h2><p>Neki does a lot of real work. It routes queries and sometimes has to merge, compare, group or compute values across shards, but it doesn&#x27;t have to do all of that for every column in every query. A point lookup can keep the result as PostgreSQL messages. <code>LIMIT</code> needs row boundaries. Sorting needs the sort keys. Projection can move existing column frames around. An aggregate generates newly computed values because there is no earlier layer where it can stop.</p><p>For every query the router tries to do the least possible work. The best way to make software fast is to avoid work at all costs.</p>]]></content>
    <summary><![CDATA[Nothing is as fast as not doing anything. How Neki keeps great performance by only decoding the parts of a PostgreSQL result the router actually needs.]]></summary>
  </entry>
  <entry>
    <title>Handling hot shards</title>
    <link href="https://planetscale.com/blog/hot-shards-hotter-tenants"/>
    <id>https://planetscale.com/blog/hot-shards-hotter-tenants</id>
    <published>2026-09-28T00:00:00.000Z</published>
    <updated>2026-09-28T00:00:00.000Z</updated>
    <author>
      <name>Simeon Griggs</name>
    </author>
    <category term="engineering"/>
    <category term="vitess"/>
    <category term="neki"/>
    <category term="postgres"/>
    <content type="html"><![CDATA[<p>Since the launch of Neki we&#x27;ve talked a lot about sharding with basic examples to demonstrate how Neki splits a table&#x27;s rows evenly across shards. Neki&#x27;s router reads your <a href="https://planetscale.com/blog/what-is-a-data-topology">data topology</a> to handle the placement of writes and to find the correct shard for reads.</p><p>But life in production is never so simple.</p><p>Imagine your application&#x27;s Neki database is sharding on a <code>tenant_id</code> column. Makes sense. You want a nice even distribution of tenants across shards. But your app takes off, congratulations! Now, some tenants are hitting their shard at a greater size or volume than most. New product opportunities emerge that require cross-tenant queries.</p><p>Suddenly an even distribution of <em>tenants</em> is less useful than an even distribution of <em>data</em>.</p><p>You probably just have the wrong shard key, at least for some of your data. No problem, Neki gives you the tools to mitigate increased pressure on shards, and adapt your resharding strategy, without downtime.</p><h2 id="a-story-of-resharding-success"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#a-story-of-resharding-success">A story of resharding success</a></h2><p>While Neki is new, sharding isn&#x27;t. Neki is built by the maintainers of Vitess: a proven, scalable and flexible solution that has a great history of solving these exact problems.</p><p>Slack has published multiple articles (<a href="https://planetscale.com/blog/hot-shards-hotter-tenants#footnotes">1</a>, <a href="https://planetscale.com/blog/hot-shards-hotter-tenants#footnotes">2</a>) and talks (<a href="https://planetscale.com/blog/hot-shards-hotter-tenants#footnotes">3</a>) on how they had to modify their approach to sharding as product demand and requirements changed over time thanks to Slack&#x27;s rapid rise in popularity.</p><p>Their original tidy and <em>obvious</em> way to shard <strong>message</strong> and <strong>channel</strong> data was by the ID of the <strong>workspace</strong> to which they belonged. The logic of which <strong>workspace</strong> belonged to which <strong>shard</strong> was maintained in a cluster dedicated to sharding metadata.</p><p>The assumption was reasonable, that no single customer would ever outgrow the biggest database. But then a workspace of 10s of 1000s of users lands. Then 100s of 1000s. Vertical scaling and isolation bought time, but not enough.</p><p>Over time this became problematic.</p><iframe src="https://planetscale.com/blog/hot-shards-hotter-tenants/iframe#workspace-groups" title="Messages and channels grouped by workspace across two shards" loading="lazy"></iframe><p><em>With per-workspace sharding, a single hot tenant&#x27;s messages table quickly overwhelms the shard</em></p><p>As the product got more successful, some workspaces were far more demanding than others. Workspaces for large enterprises could contain over 100,000 users, which ballooned the initial payload the client application needed. Some <strong>workspaces&#x27; messages</strong> tables were getting too large for any one shard. Cross-workspace messaging became a requirement. A new enterprise grid feature needed to organize multiple workspaces under a single enterprise organization. All of this was being made complicated by the current &quot;shard by workspace&quot; strategy.</p><p>Their solution involved Vitess, a sharding solution for MySQL, to simplify resharding, starting with sharding <strong>messages</strong> by <strong>channel</strong> instead of <strong>workspace</strong>.</p><p>Not every table changed. Each table was explicitly sharded by the column that made the most sense for it: user ID, channel ID, <em>or</em> workspace ID. This created a simpler path to spreading out data load across shards and enabling cross-workspace communication.</p><iframe src="https://planetscale.com/blog/hot-shards-hotter-tenants/iframe#spread" title="Channels stay on two workspace shards. Messages from every workspace land on two message shards." loading="lazy"></iframe><p><em>Sharding the messages table by channel across shards smoothed out load and unlocked new product opportunities</em></p><h3 id="explicit-vs-automatic-sharding"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#explicit-vs-automatic-sharding">Explicit vs automatic sharding</a></h3><p>Among other benefits, shard allocation was no longer hidden in application logic and a separate metadata cluster. It was defined explicitly in Vitess and enforced by VTGate. Neki&#x27;s equivalent is the data topology: a declarative configuration that agents and humans can read to reason about where data is written and where it can be read from. You define the columns on which specific tables are sharded and the range of values each shard receives.</p><p>This is in contrast to automatic sharding solutions, where the database decides placement for you, which can result in unexpected or unpredictable placement of rows.</p><p>Agents love declarative configuration like a data topology because it&#x27;s foolproof to reason about exactly where data will land and why. Which tables are sharded, which key they&#x27;re sharded by, and which shards sharded rows are sharded to are all determined by the data topology.</p><p>At any time, you can add shards and change sharding strategies. After which, <a href="https://planetscale.com/docs/neki/data-migration#reshard-existing-tables">Reshard</a> copies existing rows to new shards as required and streams ongoing writes while the source keeps serving. When you switch traffic, Neki moves reads and writes to the new placement without taking the application offline.</p><h2 id="solving-resharding-with-neki"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#solving-resharding-with-neki">Solving resharding with Neki</a></h2><p>Should you suffer from the same success, and have already migrated to Neki, you&#x27;re in a great position as it gives you the tools to mitigate increased demand and/or adjust your sharding strategy with minimal effort and disruption.</p><p>Here&#x27;s three ways to handle increased demand and requirements. The first two buy you time, the last one is the best long-term solution.</p><h3 id="fix-1-scale-up"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#fix-1-scale-up">Fix 1: Scale up</a></h3><p>If increased demand has your databases hitting their limits, the easy answer is just &quot;add more resources.&quot; You could do that and stop here.</p><p>Each shard in a Neki database is a distinct Postgres cluster with its own resources. Configuration profiles can be distinct or shared across shards. You can temporarily solve your large tenant problem by assigning its shard a unique profile, vertically scaling it, and going about your business.</p><p>On Vitess, <a href="https://planetscale.com/docs/vitess/cluster-configuration/per-shard-sizing">per-shard sizing</a> does the same job by giving a hot shard a larger cluster size than the rest of its keyspace.</p><h3 id="fix-2-isolate-large-tenants"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#fix-2-isolate-large-tenants">Fix 2: Isolate large tenants</a></h3><p>Additionally, if your larger tenants are causing issues for their neighbours, you can isolate the range of tenants on any one shard.</p><p>Here&#x27;s a visual representation of distributing rows across shards. Note that <code>xxhash</code> doesn&#x27;t create a perfectly even balance of this small dataset, but would be relatively even over 1000s or more rows.</p><iframe src="https://planetscale.com/blog/hot-shards-hotter-tenants/iframe" title="Interactive: hash-range shards with a whale tenant isolated into its own key range" loading="lazy"></iframe><p>Since you&#x27;re in control, distribution can be as broad or fine grained as you like. In a simplified sharding example, an even distribution of hashed IDs across two shards would look like this:</p><div class="code-block" data-language="json"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">{</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">  "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">key_ranges</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> [</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    {</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">shard_uid</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "shard-a"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">end</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "80"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> },</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    {</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">shard_uid</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "shard-b"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">start</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "80"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> }</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">  ]</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span></code></pre></div></div><p>The start and end ranges in the code example above are only matching the first two characters of a hashed ID. You can go much finer and create a tighter range around the whale&#x27;s <code>tenant_id</code>.</p><p>Isolating the whale&#x27;s data will require resharding all tenants&#x27; data. Reshard copies rows onto new shards, so we cannot re-use the original source shards <code>shard-a</code> and <code>shard-b</code>. With three new shards created, the new layout below describes an updated placement to isolate the hot tenant&#x27;s data.</p><div class="code-block" data-language="json"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">{</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">  "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">key_ranges</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> [</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    {</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">shard_uid</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "shard-c"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">end</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "80a3f1"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> },</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    {</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">shard_uid</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "whale"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">start</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "80a3f1"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">end</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "80a3f2"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> },</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">    {</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">shard_uid</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "shard-d"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">,</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> "</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">start</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">:</span><span style="--shiki-light:#13862E;--shiki-dark:#75DB8C"> "80a3f2"</span><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1"> }</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">  ]</span></span>
<span class="line"><span style="--shiki-light:#616161;--shiki-dark:#C1C1C1">}</span></span>
<span class="line"></span></code></pre></div></div><p>However, you&#x27;ve now created an environment where one very large tenant can have one extremely large table. A table so large it too would benefit from being sharded. If CPU demands don&#x27;t get you, storage will. It may be time to make some structural sharding changes.</p><h3 id="fix-3-reshard-some-tables"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#fix-3-reshard-some-tables">Fix 3: Reshard some tables</a></h3><p>Vertical scaling and isolation of a tenant will only get you so far, but neither of the previous two fixes solves the root cause of your problem. Yesterday&#x27;s sharding strategy is unsuitable for today&#x27;s requirements.</p><p>Changing sharding strategy doesn&#x27;t mean sharding or resharding everything. Unless you&#x27;re Meta or Google you probably don&#x27;t need to shard your users table.</p><p>Investigate your application&#x27;s access patterns and query shapes to work out what other dimensions data can be sharded by. Look for tables which are regularly joined by a common column key, reshard so they are kept together.</p><p>Back to Slack&#x27;s example, messages were always queried by channel, never by workspace, and channel ID was already part of the messages table&#x27;s primary key. The right shard key had been there all along. Sharding messages by channel ID made fetching a channel and its messages a single-shard query, even when those messages were authored by users in different workspaces.</p><iframe src="https://planetscale.com/blog/hot-shards-hotter-tenants/iframe#by-channel" title="Before: messages, channels, and users share two workspace shards. After: messages spread onto two new shards, and channels and users stay." loading="lazy"></iframe><p>Cross-channel queries for messages, which would now be cross-shard queries, were limited to administrative or batch operations, not the critical path. This change cooled off hot spots and gave the team a lot more runway in terms of CPU and storage. Meanwhile, keeping the users table together was critical, as searching for &quot;all users in this workspace&quot; was a common access path.</p><p>While resharding isn&#x27;t something you&#x27;ll want to do often, it&#x27;s simpler once you are already within a sharded database. During resharding, Neki will continue to serve queries to existing data until the operation is complete.</p><h2 id="conclusion"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#conclusion">Conclusion</a></h2><p>Landing big customers and growing your product are good problems to have, but how much stress it causes you depends on the foundation you already have in place. With Neki as that foundation, you are choosing something you and your agents can easily understand, change, and adapt to, no matter which dimension your data grows by.</p><p>The longer you wait, the more tables outgrow their original shard key. What could have been one change becomes several at once. Don&#x27;t wait for a perfect future state. Shard the table that hurts today and address the rest as they need it.</p><p>Shard what hurts now.</p><h2 id="footnotes"><a href="https://planetscale.com/blog/hot-shards-hotter-tenants#footnotes">Footnotes</a></h2><ol><li><a href="https://slack.engineering/scaling-datastores-at-slack-with-vitess/">Scaling Datastores at Slack with Vitess</a></li><li><a href="https://slack.engineering/unified-grid-how-we-re-architected-slack-for-our-largest-customers/">Unified Grid: How We Re-Architected Slack for Our Largest Customers</a></li><li><a href="https://www.infoq.com/presentations/slack-scalability-2018/">Scaling Slack</a></li></ol>]]></content>
    <summary><![CDATA[What makes a good sharding strategy today might not work so well tomorrow. What happens when you have one tenant hitting your database orders of magnitude more than any other? Or product managers knocking down your door with new requirements? If you're already on Neki, fortunately these are issues we can solve.]]></summary>
  </entry>
  <entry>
    <title>The architecture of Neki</title>
    <link href="https://planetscale.com/blog/the-architecture-of-neki"/>
    <id>https://planetscale.com/blog/the-architecture-of-neki</id>
    <published>2026-09-18T00:00:00.000Z</published>
    <updated>2026-09-18T00:00:00.000Z</updated>
    <author>
      <name>Harshit Gangal</name>
    </author>
    <category term="engineering"/>
    <category term="neki"/>
    <content type="html"><![CDATA[<p>Meet Neki: sharding for Postgres. Neki allows applications to connect to massive, sharded databases over a single connection string. This post takes apart the architecture from the bottom up, one piece at a time, starting with what&#x27;s underneath all of it.</p><h2 id="real-postgres"><a href="https://planetscale.com/blog/the-architecture-of-neki#real-postgres">Real Postgres</a></h2><p>Neki is built as a sharding and scaling solution for real Postgres. It&#x27;s not a fork, nor a wire-compatible reimplementation, nor a MySQL sharding idea wearing a Postgres label. Neki uses ordinary PostgreSQL instances that store rows in Postgres data pages using MVCC, carry out transactions, and work as you would expect with <code>psql</code> and other Postgres drivers. Neki builds around those instances to let you shard them, scale them, and manage them as one database.</p><p>Let&#x27;s take a look:</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-cluster-architecture-DVbFbIVf.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-cluster-architecture-darkmode-DZFLB57F.svg?auto=compress%2Cformat"><img alt="The full Neki cluster: an application talking to the Router, two shards each with a primary and a replica running Sidecar, Postgres, and Replicator, PostgresManager controlling each Postgres instance, Admin and etcd forming the control plane, and an Operator provisioning every pod." src="https://planetscale-images.imgix.net/assets/neki-cluster-architecture-DVbFbIVf.svg?auto=compress%2Cformat" width="1504" height="1058" loading="lazy"></picture></p><h2 id="postgresmanager"><a href="https://planetscale.com/blog/the-architecture-of-neki#postgresmanager">PostgresManager</a></h2><p>Using vanilla Postgres means Neki needs a way to run and manage each instance. That includes starting and stopping Postgres, owning its data directory, and configuring replication so a new instance can join a shard. <strong>PostgresManager</strong> handles this coordination, running as the first process in the Postgres container and managing the <code>postgres</code> process directly.</p><h2 id="sidecar"><a href="https://planetscale.com/blog/the-architecture-of-neki#sidecar">Sidecar</a></h2><p>Postgres uses a separate backend process for each connection and limits how many can be open at once. Neki’s <strong>Sidecar</strong> sits in front of each instance and pools connections, letting many client connections share fewer Postgres backends.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-sidecar-highlight-LLGg_e2-.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-sidecar-highlight-darkmode-CSbhoWT-.svg?auto=compress%2Cformat"><img alt="A close-up of one Neki shard: the Router sends queries to a primary and replica, each with its own Sidecar highlighted in orange alongside Postgres, PostgresManager, and Replicator." src="https://planetscale-images.imgix.net/assets/neki-sidecar-highlight-LLGg_e2-.svg?auto=compress%2Cformat" width="704" height="555" loading="lazy"></picture></p><p>The Router, which is the component that accepts external client connections, communicates with the Postgres nodes via these Sidecars.</p><p>It also reports each Postgres instance&#x27;s health and whether it is a primary or replica, so the rest of the cluster knows whether it can receive write queries.</p><p>The pool doesn&#x27;t treat every connection the same way. The length of time a connection is checked out for use varies depending on what it&#x27;s being used for. A multi-statement transaction holds on to its connection until <code>commit</code> or <code>rollback</code>. A session-scoped advisory lock needs a connection of its own, because the lock has to outlive whatever transaction is open at the time and can&#x27;t share that connection. Everything else checks a connection out and hands it back the moment the statement finishes.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-sidecar-pooling-tiers-VAwYbEUp.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-sidecar-pooling-tiers-darkmode-D2UpeHY6.svg?auto=compress%2Cformat"><img alt="Three connection-pool lifetimes in the Sidecar: a shared connection for an autocommit statement, a dedicated connection for an open transaction, and a reserved connection for a session advisory lock." src="https://planetscale-images.imgix.net/assets/neki-sidecar-pooling-tiers-VAwYbEUp.svg?auto=compress%2Cformat" width="1504" height="454" loading="lazy"></picture></p><p>The Sidecar knows which of the three to use because the Router sends the necessary information with the query: autocommit, an open transaction, or a session that has to stay on one backend.</p><h2 id="shards"><a href="https://planetscale.com/blog/the-architecture-of-neki#shards">Shards</a></h2><p>Each Postgres instance gets its own Sidecar and PostgresManager pair. Real deployments need more than one instance: a primary and its replicas. Neki calls that group a <strong>shard</strong>, the unit it splits data across. It&#x27;s always advised to run a shard with a primary and 2+ replicas for high availability, as well as for additional read query capacity.</p><p>A shard is considered one Postgres cluster. Its replicas are physical copies of the primary, so they share a catalog and the same object identifiers.</p><p>Object Identifiers (OIDs) are how Postgres tracks objects internally, rather than by name. A client reads a column’s type OID off the wire to interpret its bytes and may cache that OID for later re-use. A custom type therefore needs to carry the same OID no matter which shard answers the query. Independent shards can assign that type different OIDs, so Neki designates one shard in the entire Neki cluster as the <strong>authoritative shard</strong>. This shard is the source of truth for translating custom type OIDs in responses from other shards to match. It ensures OIDs are consistent across the many shards of the Neki cluster.</p><p>The authoritative shard&#x27;s Sidecar also watches for schema changes and reports them to the Routers. This keeps the Routers&#x27; view of the schema current when a table is renamed or a column is dropped.</p><h2 id="admin"><a href="https://planetscale.com/blog/the-architecture-of-neki#admin">Admin</a></h2><p>In a distributed system, instances can fail independently while the rest of the system lives on. Neki is no different. A primary or replica can go down at any moment while its fellow instances on the shard are healthy. The <strong>Admin</strong>&#x27;s job is to detect failures, promote a replica, and maintain each shard’s durability policy.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-admin-highlight-utI4RFsa.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-admin-highlight-darkmode-DFbC_dyz.svg?auto=compress%2Cformat"><img alt="The same cluster diagram with Admin and the control plane highlighted and everything else faded." src="https://planetscale-images.imgix.net/assets/neki-admin-highlight-utI4RFsa.svg?auto=compress%2Cformat" width="1504" height="1058" loading="lazy"></picture></p><p>It health-checks every Sidecar, tracks replication lag for each replica, and decides when a shard needs a new primary. When a primary goes down, it coordinates an emergency failover, promoting a replica to take its place. It can also coordinate a planned switchover, which are needed for intentional node resizes and version upgrades. In both situations, Admin uses <code>pg_rewind</code> to bring diverged instances onto the new primary’s timeline, copying only the data that changed since the timelines diverged.</p><p>Each shard has a durability policy that determines when a commit is acknowledged:</p><ul><li><strong>Async:</strong> The primary acknowledges the commit without waiting for a replica.</li><li><strong>Sync:</strong> The primary waits for a replica to confirm the commit, protecting against the loss of a single node.</li><li><strong>Cross-zone sync:</strong> The primary waits for confirmation from a replica in another availability zone, protecting against the loss of the primary’s zone.</li></ul><p>Postgres enforces whichever one is configured, using its own synchronous replication machinery. The Admin keeps that configuration correct as replicas join or leave shards, or a failover moves the primary to a different zone.</p><p>Much of Admin’s work, however, doesn’t involve changing the primary. It repoints replicas to the correct replication source and corrects roles when Postgres and the topology disagree.</p><h2 id="operator"><a href="https://planetscale.com/blog/the-architecture-of-neki#operator">Operator</a></h2><p>Neki’s components need to be deployed, updated, and replaced when their machines fail. Neki is built Kubernetes-first, and the <strong>Operator</strong> manages this full lifecycle.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-operator-highlight-BSzSr8SQ.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-operator-highlight-darkmode-L_OKbSBD.svg?auto=compress%2Cformat"><img alt="The same cluster diagram with the Operator highlighted and everything else faded." src="https://planetscale-images.imgix.net/assets/neki-operator-highlight-BSzSr8SQ.svg?auto=compress%2Cformat" width="1504" height="1058" loading="lazy"></picture></p><p>The Operator models a cluster as a hierarchy. A cluster owns routers and shards, and each shard owns the pods running its Postgres instances and Sidecars. When the Neki cluster configuration changes, the Operator works out which pods need to be created, updated, or removed.</p><p>How it replaces an instance depends on whether that instance is still running. For a live instance, the Operator builds a replacement and confirms it has caught up before deleting the old one. If a node fails and loses its ephemeral storage, the Operator rebuilds the lost instance from scratch once its safety checks pass.</p><p>Admin and the Router handle the database side of those disruptions. Admin coordinates a switchover for planned primary replacements or a failover when a primary goes down. The Router can buffer queries that are safe to retry while a healthy primary becomes available.</p><h2 id="router"><a href="https://planetscale.com/blog/the-architecture-of-neki#router">Router</a></h2><p>We&#x27;ve talked a lot about how the Neki cluster operates and handles failure internally. What we&#x27;ve yet to dive into is how applications use the thing!</p><p>The <strong>Router</strong> is the entry point for clients connecting to a Neki cluster, presenting a single Postgres wire-protocol endpoint to connect to a (potentially) massive sharded database. Applications use Postgres drivers to send SQL and open transactions without managing connections to individual shards.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-router-highlight-Db72rv-H.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-router-highlight-darkmode-BBVEK1HZ.svg?auto=compress%2Cformat"><img alt="The same cluster diagram with the Router and etcd highlighted and everything else faded." src="https://planetscale-images.imgix.net/assets/neki-router-highlight-Db72rv-H.svg?auto=compress%2Cformat" width="1504" height="1058" loading="lazy"></picture></p><p>Authentication and role checks are done as if it were the Postgres instance itself, and the protocol&#x27;s own extended-query flow and prepared-statement lifecycle are all built into the Router.</p><p>Once a query arrives, the Router runs a Postgres-compatible parser against the authoritative shard&#x27;s catalog, plans it against the current sharding layout, and sends it to whichever Sidecar needs to run it over gRPC.</p><p>Not every query can run on a single shard. A join may need data from several shards or an aggregate may need to read from all of them. The <strong>Router</strong> coordinates that work as a <a href="https://planetscale.com/blog/what-is-a-neki-router">distributed query</a>.</p><p>Whenever possible, it leaves the work to the Postgres instances. If both sides of a join are on the same shard, the Router sends the join to that shard. When a join needs to run across shards, the Router executes it itself, choosing between nested-loop, hash, and merge joins based on cost estimations.</p><div class="mb-3 border p-3 border-blue-600 dark:border-blue-500"><p><span class="bg-blue-600 px-sm text-white dark:bg-blue-500 dark:text-black">Note</span></p><p>Read more about Routers, parsing, and sharded query planning in our other blog, <a href="https://planetscale.com/blog/the-lifecycle-of-a-sharded-postgres-query">The lifecycle of a sharded Postgres query</a>.</p></div><p>Earlier, we covered how Admin promotes a new primary during a switchover or failover. If that happens, the Router can buffer queries, giving the Admin time to complete the handover. For queries that can safely be retried after failing against a primary, the Router buffers the query and waits, for a fixed time, for a healthy primary. Once a healthy primary is available, the Router releases queued queries gradually.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-router-buffering-blWD4uWF.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-router-buffering-darkmode-Ck1ByFNK.svg?auto=compress%2Cformat"><img alt="A query hits an unavailable primary and gets buffered. Usually the primary recovers within the time and size limit and the queued query is released gradually, so the client sees nothing. Rarely, the limit is reached first and an error is returned to the client." src="https://planetscale-images.imgix.net/assets/neki-router-buffering-blWD4uWF.svg?auto=compress%2Cformat" width="1504" height="500" loading="lazy"></picture></p><h2 id="data-topology"><a href="https://planetscale.com/blog/the-architecture-of-neki#data-topology">Data Topology</a></h2><p>Router, Sidecars, and Admin all need a consistent picture of which shards exist, what key ranges they own, and which tables are sharded at all. If the Router&#x27;s copy is wrong, a query can land on the wrong shard. This is all specified with a <a href="https://planetscale.com/blog/what-is-a-data-topology">Data Topology</a>, and <strong>etcd</strong> holds the single, authoritative copy of it. When the Data Topology changes, the Router, Sidecars, and Admin pick up the updated configuration without a restart or manual synchronization.</p><p>The Data Topology defines <strong>shard groups</strong>, named sets of physical shards, each owning a range of routing keys. Each table belongs to a shard group. <strong>Shard indexes</strong> specify the columns or expressions and the strategy used to turn row values into routing keys. Those keys determine which shard receives each row.</p><h2 id="replicator"><a href="https://planetscale.com/blog/the-architecture-of-neki#replicator">Replicator</a></h2><p>As a database grows, its layout may need to change. Tables need to be imported, shards need to be split, and schemas need to change all while applications keep using the database.</p><p>Neki&#x27;s <strong>Replicator</strong> handles the data movement behind all such operations. It runs as a separate process colocated with a shard&#x27;s Sidecar and Postgres. It is responsible for copying existing rows to new destinations, and also keeping the data current by decoding changes from a Postgres logical replication stream and applying them as SQL.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-replicator-highlight-DMADfE-G.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-replicator-highlight-darkmode-BilIVgxG.svg?auto=compress%2Cformat"><img alt="A close-up of one Neki shard, with the separate Replicator beside each Postgres instance and its connection from Postgres highlighted in orange." src="https://planetscale-images.imgix.net/assets/neki-replicator-highlight-DMADfE-G.svg?auto=compress%2Cformat" width="704" height="385" loading="lazy"></picture></p><p>Three workflows use the Replicator:</p><ul><li><strong>MoveTables</strong> relocates a set of tables, including imports from an external Postgres instance</li><li><strong>Reshard</strong> redistributes data across shard key ranges, allowing a shard to be split when it outgrows its capacity</li><li><strong>OnlineDDL</strong> changes a table&#x27;s schema by building a shadow table alongside the original and keeping it current through the same change-data-capture pipeline MoveTables and Reshard use to relocate rows. A final rename swaps the new table into place. This supports changes such as repartitioning a table, alongside changes that would otherwise require a blocking operation.</li></ul><p>Once the data has been copied and the destination is caught up, the workflow switches from the original tables or shards to their replacements. This is the cutover. The Router uses the same buffering mechanism that handles primary changes for this step. It buffers queries during that switch and releases them afterward.</p><p>Together, these components let Neki scale Postgres horizontally while presenting a single database to applications.</p><h2 id="get-started"><a href="https://planetscale.com/blog/the-architecture-of-neki#get-started">Get started</a></h2><p>Neki is in Platform Preview right now.</p><p>Start a <a href="https://app.planetscale.com/new">Neki</a> cluster today: build on it from scratch, or import an existing Postgres database.</p>]]></content>
    <summary><![CDATA[Sharded Postgres, from the team behind Vitess]]></summary>
  </entry>
  <entry>
    <title>Introducing Neki</title>
    <link href="https://planetscale.com/blog/introducing-neki"/>
    <id>https://planetscale.com/blog/introducing-neki</id>
    <published>2026-09-10T12:00:00.000Z</published>
    <updated>2026-09-10T12:00:00.000Z</updated>
    <author>
      <name>Nick Van Wiggeren</name>
    </author>
    <category term="product"/>
    <category term="neki"/>
    <content type="html"><![CDATA[<p>Neki is now available in platform preview.</p><p><a href="https://neki.dev/">Neki</a> is built from lessons we’ve learned over eight years of running some of the largest sharded MySQL clusters in the world. Thousands of production workloads with millions of queries per second for companies where even a few seconds of downtime is a very public event. We know what it means to power the world’s biggest tier 0 workloads.</p><p>When we released PlanetScale Postgres a year and a half ago, we knew we needed to do more. In that time we’ve onboarded several thousands of customers on PlanetScale, some of them rivaling the size of our largest MySQL customers. Time and time again, we watched teams approach the ceiling of a single machine with Postgres. Metal bought them time, but with customers hitting the upper limit of what a single machine is capable of, we found there was no good option to hand them. Enter Neki.</p><h2 id="what-is-neki"><a href="https://planetscale.com/blog/introducing-neki#what-is-neki">What is Neki?</a></h2><p>Neki is sharded Postgres from PlanetScale. It lets you scale a Postgres database across many machines while keeping real Postgres on every shard.</p><p>Your application connects to a Neki router over the standard Postgres wire protocol, so your existing drivers, ORMs, and connection string keep working. Each shard is a full Postgres cluster with one primary and at least two replicas across 3 availability zones. There is no custom storage engine, so extensions, SQL support, and performance behave the way Postgres does.</p><p>You choose the shard key and control how tables are grouped and distributed through a JSON data topology. Schema changes, version upgrades, failovers, imports, and resharding all run as built-in fully online workflows. You also get the PlanetScale features you already rely on, including Insights, schema recommendations, branching, and MCP.</p><p>You don&#x27;t have to shard on day one. Run Neki as a single primary with replicas, and when you outgrow one machine, resharding is a workflow you run against the cluster you already have.</p><h2 id="why-neki"><a href="https://planetscale.com/blog/introducing-neki#why-neki">Why Neki?</a></h2><p>You already know the problems that come with fast-growing Postgres databases: tables too large to vacuum or index without affecting traffic, backups taking hours, connection limits, maintenance windows for schema changes, transaction wraparound and so much more.</p><p>You can move to a bigger instance, but eventually you run out of big enough machines, and the problems don’t scale linearly as you add more cores and IOPS.</p><p>The existing answers each ask you to give something up. Application-level sharding pushes routing into your code. Postgres-”compatible” distributed databases hide the shard key from you, take away your extensions, and add complexity and latency which becomes difficult to handle and debug.</p><p>So we built Neki with a few principles, the biggest one being: stick to Postgres, don’t work around it, fake it, or turn away from it.</p><h2 id="how-does-neki-work"><a href="https://planetscale.com/blog/introducing-neki#how-does-neki-work">How does Neki work?</a></h2><p>We architected Neki from first principles for Postgres, with real Postgres on every shard. There are four moving parts.</p><h3 id="neki-routers"><a href="https://planetscale.com/blog/introducing-neki#neki-routers">Neki routers</a></h3><p>Your application first connects to a Neki router. The router speaks the Postgres wire protocol so your existing drivers and ORMs keep working with a single connection string. A router has a full Postgres query parser, a distributed query planner, query buffering and more. It parses your query, builds a plan that decides which shards should run it, sends the work out, and combines the results back into one stream. Routers can scale vertically and horizontally, so no single router becomes the bottleneck.</p><h3 id="sharding-and-shard-groups"><a href="https://planetscale.com/blog/introducing-neki#sharding-and-shard-groups">Sharding and shard groups</a></h3><p>Every shard in Neki is real Postgres with 1 primary and at least 2 replicas, spread across availability zones. There is no modified storage engine. Extensions, SQL support, and performance behave the way Postgres behaves, because it <em>is</em> Postgres.</p><p>Shards are organized into shard groups, so different tables or workloads can live on different sets of shards. Each shard uses a configuration profile that defines its instance size, replica count, storage, Postgres parameters, and extensions, so you can size each group for its own traffic.</p><h3 id="connection-pooling"><a href="https://planetscale.com/blog/introducing-neki#connection-pooling">Connection pooling</a></h3><p>Sidecars run alongside every Postgres instance. This is the piece that makes Neki&#x27;s connection handling meaningfully better than just sticking PgBouncer in front of a database. Because Neki controls both ends of the connection, the router side and the Postgres side, it can size pools to what each instance can actually serve instead of estimating from outside the process.</p><h3 id="control-plane"><a href="https://planetscale.com/blog/introducing-neki#control-plane">Control plane</a></h3><p>The control plane tracks the health of every node, runs planned switchovers and unplanned failovers, and coordinates the workflows that reshard data, apply schema changes, and perform version upgrades.</p><h3 id="data-topology"><a href="https://planetscale.com/blog/introducing-neki#data-topology">Data topology</a></h3><p>Tying it together is the <a href="https://planetscale.com/blog/what-is-a-data-topology">data topology</a>, a JSON configuration that maps your logical tables onto physical shards. You define shard indexes, which specify the column Neki routes on and how that value gets hashed, and shard groups, which control how many shards a set of tables spreads across and which shards those are. Routers cache the topology and consult it on every plan.</p><h2 id="what-you-get-beyond-sharding"><a href="https://planetscale.com/blog/introducing-neki#what-you-get-beyond-sharding">What you get beyond sharding</a></h2><p>Everything you would normally schedule a maintenance window for runs as a built-in workflow in Neki. Workflows provision new target nodes, catch them up with replication, switch traffic with a <code>__neki</code> metafunction, and retire the old nodes. All through the same <code>psql</code> connection your application uses.</p><p>This online operations model covers schema changes, version upgrades, planned and unplanned failovers, imports, and resharding.</p><p>Neki also includes all of the features you’ve come to rely on with PlanetScale: Insights, schema recommendations, branching, MCP, and more.</p><p>You can also run Neki unsharded, as a single primary with replicas. You get the improved connection pooling, online DDL, zero downtime upgrades, and health monitoring before you need to shard. When you do, resharding is a workflow you run against the cluster you already have.</p><h2 id="what-is-a-platform-preview"><a href="https://planetscale.com/blog/introducing-neki#what-is-a-platform-preview">What is a platform preview?</a></h2><p>We wanted to get Neki into your hands as soon as possible. You should not run production workloads on Neki during the platform preview. The product is still changing, and some of those changes will be breaking.</p><p>If you have any feedback, questions, or face any issues during the platform preview, please let us know. Fill out a <a href="https://planetscale.com/contact">support ticket</a> or <a href="https://pscale.link/community">join our Discord</a></p><h2 id="try-neki-today"><a href="https://planetscale.com/blog/introducing-neki#try-neki-today">Try Neki today</a></h2><p>Sign in to PlanetScale, opt in to the platform preview, and create a Neki cluster. Read the <a href="https://planetscale.com/docs/neki">Neki docs</a> for more information about Neki&#x27;s architecture, how to shard, and more.</p><p>If you have a large Postgres cluster and are curious whether Neki is a good fit, <a href="https://planetscale.com/contact">get in touch</a>. We would love to do a private demo for your team, dig into your schema and query patterns, and give you real suggestions on how to shard.</p>]]></content>
    <summary><![CDATA[Neki, sharded Postgres by PlanetScale, is now available in platform preview.]]></summary>
  </entry>
  <entry>
    <title>How one connection kills a database</title>
    <link href="https://planetscale.com/blog/debugging-live-database-connections"/>
    <id>https://planetscale.com/blog/debugging-live-database-connections</id>
    <published>2026-08-31T00:00:00.000Z</published>
    <updated>2026-08-31T00:00:00.000Z</updated>
    <author>
      <name>Simeon Griggs</name>
    </author>
    <category term="product"/>
    <category term="postgres"/>
    <content type="html"><![CDATA[<p>Think you could land a DBA job at GitHub?</p><blockquote><p><em>You&#x27;ve just been paged. Every query is failing to execute. Find out why.</em></p></blockquote><p>Years ago at GitHub, this was a question <a href="https://x.com/samlambert">Sam Lambert</a> would ask in interviews.</p><p>The solution involved a downed database that started with a MySQL schema change needing an exclusive lock on a table, but it couldn&#x27;t get one. Another session had already touched that table and never committed. That open transaction blocked the schema change.</p><p>Once the schema change was waiting, new queries on the same table queued up behind it. Nothing was deadlocked. Nothing would time out on its own. The whole pile-up sat there until someone found the connection at the top of the chain and killed it.</p><p>(By the way, you&#x27;d avoid this MySQL lock on PlanetScale Vitess since <code>ALTER</code> would have been an online schema change, copying in the background and only taking a brief lock at cutover.)</p><p>This isn&#x27;t rare, and it isn&#x27;t exclusive to MySQL. You can reproduce the same pile-up on Postgres just as easily.</p><h2 id="want-to-see-a-stuck-database"><a href="https://planetscale.com/blog/debugging-live-database-connections#want-to-see-a-stuck-database">Want to see a stuck database?</a></h2><p>You won&#x27;t need elevated permissions. A normal application connection is enough.</p><p>All it takes is an unhandled exception and a poorly timed migration.</p><iframe src="https://planetscale.com/blog/debugging-live-database-connections/iframe#lock-queue" title="Interactive: a finished SELECT stays idle, a migration waits on it, and later queries queue behind the migration" loading="lazy"></iframe><p>Say you have a Postgres database with a hot table named <code>orders</code>.</p><p>On one <strong>connection</strong>, a <strong>transaction</strong> begins a <code>SELECT</code> <strong>query</strong>, but the app throws an exception.</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#818181;--shiki-dark:#A1A1A1">-- Connection A: Ordinary application user</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">BEGIN</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> *</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">WHERE</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> id </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">=</span><span style="--shiki-light:#D92038;--shiki-dark:#FF7082"> 123</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#818181;--shiki-dark:#A1A1A1">-- ...then the app throws an exception 💀</span></span>
<span class="line"></span></code></pre></div></div><p>The <code>SELECT</code> <strong>query</strong> finished, but Postgres keeps holding the <strong>connection</strong>, waiting for the <strong>transaction</strong> to end with <code>COMMIT</code> or <code>ROLLBACK</code>, which it never does because of the app&#x27;s thrown exception.</p><p>While Postgres waits on that connection, another connection attempts to run a migration.</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#818181;--shiki-dark:#A1A1A1">-- Connection B: Migration role waiting on lock</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">ALTER</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> TABLE</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">ADD</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> COLUMN foo </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">integer</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">;</span></span>
<span class="line"></span></code></pre></div></div><p>The <code>SELECT</code> query only needed a read lock (<code>ACCESS SHARE</code>). The migration needs a lock that blocks all other access (<code>ACCESS EXCLUSIVE</code>), so it waits.</p><p>Postgres will not let later queries jump the queue. Every new query on <code>orders</code> waits behind the already-stuck migration, which is itself stuck behind the initial <code>SELECT</code> connection that never closed.</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#818181;--shiki-dark:#A1A1A1">-- Connections C, D, E, etc: Waiting on the lock</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">SELECT</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> *</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">FROM</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> orders</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">WHERE</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> customer_id </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">=</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> ?;</span></span>
<span class="line"></span></code></pre></div></div><p>If the app doesn&#x27;t handle errors gracefully and depends on quick responses from <code>orders</code>, you&#x27;ve got downtime.</p><p>Not because of a hack, but because one unhandled exception prevented a <code>COMMIT</code>, kept a connection open, blocked a migration, and that migration blocked everything else.</p><p>By the way, this is preventable. It happens because the default for <code>idle_in_transaction_session_timeout</code> is disabled. Give it a value and Postgres times out transactions that haven&#x27;t closed.</p><h2 id="freeing-a-stuck-database"><a href="https://planetscale.com/blog/debugging-live-database-connections#freeing-a-stuck-database">Freeing a stuck database</a></h2><p>Debugging the state of connections in MySQL and Postgres can be clunky.</p><p>As covered in <a href="https://planetscale.com/blog/see-what-your-database-is-doing-right-now">See what your database is doing right now with Connections </a> it is possible to run a query on a loop in your terminal to see active connections. But it&#x27;s not an ideal interface.</p><p>It also depends on your database having spare connection slots. The worst-case scenario is your database is overwhelmed with connections that haven&#x27;t been closed, meaning even you can&#x27;t connect to debug and kill them.</p><p>Because this experience is so terrible, we&#x27;ve added the ability to view and kill connections in Postgres and MySQL databases on PlanetScale.</p><h2 id="connections-in-the-dashboard"><a href="https://planetscale.com/blog/debugging-live-database-connections#connections-in-the-dashboard">Connections in the dashboard</a></h2><p>PlanetScale&#x27;s connections tooling uses a reserved administrative connection so it can still show you a list of active connections and processes even when connections are exhausted and your application can&#x27;t connect.</p><p>In the PlanetScale dashboard, you can now click the Connections tab to view a live list of connections. You&#x27;ll see a bunch of useful columns like Process ID, State, and Duration, but most importantly, you will see a column for Blocked Queries.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/connections-pg-list-D9J8Ki6H.png?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/connections-pg-list-darkmode-DqWq7MBR.png?auto=compress%2Cformat"><img alt="The PlanetScale dashboard Connections list, with process ID, state, blocked queries, and query columns" src="https://planetscale-images.imgix.net/assets/connections-pg-list-D9J8Ki6H.png?auto=compress%2Cformat" width="2448" height="1346" loading="lazy"></picture></p><p>From here, it&#x27;s easy to see which connections are currently blocking others from proceeding. You can click into any connection to see more details, such as the query currently being performed by that connection.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/connections-pg-detail-B3YhOhsL.png?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/connections-pg-detail-darkmode-DDc7McTr.png?auto=compress%2Cformat"><img alt="The Connections detail panel for a selected process, with wait event, blocked queries, and a Kill process action" src="https://planetscale-images.imgix.net/assets/connections-pg-detail-B3YhOhsL.png?auto=compress%2Cformat" width="2448" height="1346" loading="lazy"></picture></p><p><strong>Kill process</strong> gives you three options, from least to most disruptive:</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/connections-pg-kill-BBc_B223.png?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/connections-pg-kill-darkmode-CCIR1PwN.png?auto=compress%2Cformat"><img alt="The Kill process modal, with Cancel query, Terminate transaction, and Terminate connection" src="https://planetscale-images.imgix.net/assets/connections-pg-kill-BBc_B223.png?auto=compress%2Cformat" width="2448" height="1346" loading="lazy"></picture></p><ul><li><strong>Cancel query</strong> stops the running statement and leaves the connection open, so the app can send another query</li><li><strong>Terminate transaction</strong> rolls back the open transaction and closes the connection, but only if that same transaction is still running</li><li><strong>Terminate connection</strong> drops the backend entirely, whether it is idle or mid-query</li></ul><p>Thinking back to the downtime example, the idle <code>SELECT</code> had already finished, so canceling the query will not release the lock. In that scenario you would need to terminate the transaction or the connection.</p><p>Over on Vitess, each process has two options: cancel the query or terminate the connection. In a sharded database, you can view the process list per keyspace and shard.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/connections-vitess-list-Bd1fOrzO.png?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/connections-vitess-list-darkmode-ahtFt-ov.png?auto=compress%2Cformat"><img alt="The PlanetScale dashboard Connections list on a Vitess database, scoped to a keyspace and shard" src="https://planetscale-images.imgix.net/assets/connections-vitess-list-Bd1fOrzO.png?auto=compress%2Cformat" width="2448" height="1346" loading="lazy"></picture></p><h2 id="connections-via-cli"><a href="https://planetscale.com/blog/debugging-live-database-connections#connections-via-cli">Connections via CLI</a></h2><p>If you&#x27;d rather work with a terminal UI, launch:</p><div class="code-block"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>pscale branch connections top &#x3C;database> &#x3C;branch></span></span>
<span class="line"><span></span></span></code></pre></div></div><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/live-connections-top-DLKSy3CO.png?auto=compress%2Cformat"><img alt="The pscale branch connections top view, with a stuck checkout transaction at the top" src="https://planetscale-images.imgix.net/assets/live-connections-top-DLKSy3CO.png?auto=compress%2Cformat" width="2333" height="1409" loading="lazy"></picture></p><p>This opens an interactive live view that refreshes about once every second. The most important connections are sorted toward the top. You can navigate through the view using keyboard shortcuts and open any connection to see more details.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/live-connections-blockers-Dc1L2c1b.png?auto=compress%2Cformat"><img alt="The blocker tree: one idle checkout-api transaction holding up the refund, payment, and cancel updates queued behind it" src="https://planetscale-images.imgix.net/assets/live-connections-blockers-Dc1L2c1b.png?auto=compress%2Cformat" width="2355" height="1418" loading="lazy"></picture></p><p>If your agents prefer the current state of connections in JSON, use this one-shot command instead of the live view:</p><div class="code-block"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>pscale branch connections show &#x3C;database> &#x3C;branch> --format json</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>That output includes the IDs needed to cancel a query or terminate a connection, which you can still require confirmation for. See the <a href="https://planetscale.com/docs/cli/connections">CLI reference</a> for the kill commands that go with it.</p><h2 id="migrate-today-stress-less-later"><a href="https://planetscale.com/blog/debugging-live-database-connections#migrate-today-stress-less-later">Migrate today, stress less later</a></h2><p>The correct answer to land your dream job today is &quot;Use PlanetScale.&quot;</p><p>Next time you&#x27;re paged because everything is erroring, <a href="https://planetscale.com/migrate">I hope you&#x27;ve already migrated over</a>. The interview answer hasn&#x27;t changed, but connection management on PlanetScale has made it easier than ever before.</p>]]></content>
    <summary><![CDATA[Unblock stuck connections with live connection management in the PlanetScale CLI and dashboard]]></summary>
  </entry>
  <entry>
    <title>Problems with large tables in Postgres</title>
    <link href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres"/>
    <id>https://planetscale.com/blog/dealing-with-large-tables-in-postgres</id>
    <published>2026-08-25T00:00:00.000Z</published>
    <updated>2026-08-25T00:00:00.000Z</updated>
    <author>
      <name>Simeon Griggs</name>
    </author>
    <category term="engineering"/>
    <category term="postgres"/>
    <content type="html"><![CDATA[<p>Tell me to stop when I name the largest table in your database: <code>logs</code>, <code>ledger</code>, <code>notifications</code>, <code>feed</code>, <code>events</code>, <code>chonk</code> ... admit it, I got it, right?</p><p>Product success leads to lots of data, lots of data leads to big tables, but big tables lead to predictable problems.</p><iframe src="https://planetscale.com/blog/dealing-with-large-tables-in-postgres/iframe#tables" title="Interactive: table sizes in an imagined database" loading="lazy"></iframe><p>A table can be large (many rows), wide (many columns), or &quot;fat&quot; (oversized values). Any of these can cause you problems.</p><h2 id="how-a-large-table-can-cause-an-outage"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#how-a-large-table-can-cause-an-outage">How a large table can cause an outage</a></h2><p>First, let me tell you a story.</p><p>A customer had two tables, A and B. Table B is large and unpartitioned. It has a foreign key to A with cascade delete turned on. A single delete on A has to delete every child row in B. That can be 100s or 1000s of rows in B for every one in A.</p><p>In this customer&#x27;s case, deletions from A ran in a background job and attempted to remove around 100,000 rows from A. The deletes took so long that they timed out. The job retried. Each attempt generated a pile of WAL (the write-ahead log Postgres uses to record every change), which saturated the network and CPU between the primary and its replicas. This caused the replicas to lag.</p><p>The application checked replica LSN (a replica&#x27;s position in that WAL stream) before sending a read to a secondary. The secondaries were not caught up, so every query went to the primary.</p><p>The primary was already busy writing WAL. It also had to respond to all of the reads. And so a simple cascade delete on a large table quickly became an outage.</p><h2 id="possible-fixes-for-a-large-table"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#possible-fixes-for-a-large-table">Possible fixes for a large table</a></h2><p>The problems covered in this post are common to all Postgres databases but are made worse by those containing large tables. You might reach for one of a few solutions, which we&#x27;ll examine throughout.</p><p><span class="bg-orange-100/80 text-orange-700 dark:bg-orange-800/70 dark:text-orange-200">Partitioning</span> splits a large table based on a key value. For example, if your large table stores rows with a specific date, such as when an event occurred, cutting that table into partitions of months or years creates more, smaller, and more manageable tables.</p><p>This can be useful for some large-table problems.</p><p><span class="bg-yellow-100/70 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-300">Vertical scaling</span> refers to adding more resources to make more space or brute-forcing your way out of performance bottlenecks such as increasing CPU, RAM, or buying larger and faster disks.</p><p>It&#x27;s almost always a band-aid solution that only hides the real problem.</p><p><span class="bg-blue-100/80 text-blue-700 dark:bg-blue-800/80 dark:text-blue-300">Sharding (horizontal scaling)</span> splits the large table, or the entire database, into separate database clusters connected by a router to act as one. Just as Vitess is sharded MySQL, <a href="https://planetscale.com/neki">Neki</a> is sharded Postgres.</p><p>With more, smaller, and isolated database clusters, almost all of your expected large table problems are resolved. More on that later in the post.</p><h2 id="slow-late-vacuum"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#slow-late-vacuum">Slow, late vacuum</a></h2><p>You may already know that deleted rows in Postgres aren&#x27;t automatically removed; they are <em>marked</em> for deletion. These marked rows hold space until the vacuum process reclaims it. Read <a href="https://planetscale.com/blog/postgresql-mvcc">Every UPDATE leaves a ghost</a> to learn more.</p><p>What you might not realize is that vacuum runs <strong>per table</strong>.</p><iframe src="https://planetscale.com/blog/dealing-with-large-tables-in-postgres/iframe#vacuum" title="Interactive: small tables vacuum many times while one large table is still running" loading="lazy"></iframe><p>Vacuuming a single large table can tie up a worker for a long time. Vacuum uses a ring buffer so it does not evict the rest of <code>shared_buffers</code> (Postgres&#x27;s shared page cache), but on a table larger than RAM it still competes with user queries for the same disks.</p><p>Large tables take longer to vacuum, and they also wait longer to start.</p><p>On Postgres 17, the default trigger is 20% of the table. A table with ~500 million rows needs ~100 million dead tuples before autovacuum begins. You can modify this value cluster-wide or per table by changing <code>autovacuum_vacuum_scale_factor</code>.</p><p>Postgres 18 added <code>autovacuum_vacuum_max_threshold</code> with a default value of 100,000,000. So a billion-row table triggered autovacuum at 100 million (still a high number) instead of 200 million. Below 500 million rows, the old scale factor was still in play.</p><p><a href="https://planetscale.com/blog/whats-new-in-postgres-19">Postgres 19 adds parallel autovacuum</a>, <code>off</code> by default. Extra workers can finish one large table faster, but that still doesn&#x27;t change when vacuum starts.</p><p>Autovacuum also runs <code>ANALYZE</code>, which has the same default-doesn&#x27;t-scale problem. It samples a fixed number of rows no matter how large the table is. So the planner&#x27;s estimates potentially get worse as the table grows. Raise the target with <code>ALTER TABLE ... SET STATISTICS</code> on the columns the planner actually needs to get right.</p><p>Vacuum on large tables is late to start, slow to finish, with more dead rows while it runs. Each cycle is worse than the last, even when vacuum reports success.</p><p><span class="bg-orange-100/80 text-orange-700 dark:bg-orange-800/70 dark:text-orange-200">Partitioning</span> can be a good first move to split your large table into more reasonable-size chunks, if it has a suitable key.</p><p>Partitioning also helps avoid autovacuum&#x27;s late start. Each partition is its own smaller heap, so the 20% trigger fires after far fewer dead tuples. Workers vacuum partitions in parallel and <code>ANALYZE</code> samples a smaller relation.</p><div class="table-overflow"><table><thead><tr><th>Shape</th><th>Rows the formula sees</th><th>Default trigger (50 + 0.2 × rows)</th></tr></thead><tbody><tr><td>One heap, 500 million rows</td><td>500,000,000</td><td>~100,000,050 dead tuples</td></tr><tr><td>Same data as 12 monthly partitions</td><td>~41.7 million each</td><td>~8.3 million dead tuples per partition</td></tr></tbody></table></div><p>Disks and <code>shared_buffers</code> are still shared with the rest of the cluster, so it&#x27;s not a perfect solution.</p><p><span class="bg-yellow-100/70 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-300">Vertical scaling</span> can help more workers finish sooner with faster disks. That does not change the trigger on one huge heap, and the table may still not fit in RAM.</p><p><span class="bg-blue-100/80 text-blue-700 dark:bg-blue-800/80 dark:text-blue-300">Sharding (horizontal scaling)</span> gives you the best of both worlds. Smaller tables on smaller databases, each with their own isolated resources.</p><p>Sharding also solves foundational parts of Postgres that can&#x27;t be resolved by more resources.</p><p>Every write transaction gets a 32-bit ID, and vacuum uses these IDs (<code>xmin</code>) to decide which row versions are still visible. These IDs are cluster-wide, not per-table. Vacuum freezes old tuples so XIDs can be reused; wraparound is the age of the oldest unfrozen XID (<code>relfrozenxid</code> in <code>pg_class</code>), not how many XIDs the database has ever issued. If that age reaches around 2.1 billion, Postgres goes into read-only mode. <a href="https://planetscale.com/blog/postgres-backups-under-the-hood#logical-backups-and-transaction-wraparound">Postgres backups under the hood</a> covers transaction wraparound in more detail.</p><p>Since each shard is its own cluster, a large table separated across shards is less likely to put a cluster into read-only mode.</p><p>Additionally with isolated I/O, any work vacuum does on one shard does not contend with queries on another. <code>ANALYZE</code> becomes a per-shard operation.</p><h2 id="wasted-time-on-incomplete-repacking"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#wasted-time-on-incomplete-repacking">Wasted time on incomplete repacking</a></h2><p>Vacuum can be prevented from reclaiming space even if it finishes successfully.</p><iframe src="https://planetscale.com/blog/dealing-with-large-tables-in-postgres/iframe#repack" title="Interactive: vacuum needs three passes to reclaim every dead tuple" loading="lazy"></iframe><p>Vacuum can&#x27;t remove a dead tuple while any transaction in the cluster still needs it.</p><p>But a slow vacuum on a large table may have wasted its time to completion. Many of the rows it found to remove may still be required by transactions that started before vacuum finished and haven&#x27;t completed yet. Vacuum will need to walk the entire table again later.</p><p>This can be an issue on tables of any size, but is more annoying on large tables as they take longer to complete.</p><p>Free space that was successfully reclaimed can be reused for new rows, but the table file usually does not shrink. That leftover file size is bloat.</p><p>Should your table get bloated, PlanetScale Insights scans once a day and opens a recommendation when estimated bloat is over 25% and 100MB.</p><p>To compact a bloated table while reads and writes continue, enable <a href="https://planetscale.com/docs/postgres/extensions/pg_squeeze"><code>pg_squeeze</code></a> on the database&#x27;s Clusters page (that requires a restart), then run a one-time squeeze or register the table for regular cleanup.</p><p>Be aware that <code>pg_squeeze</code> has no throttle. Squeezing a large table can saturate I/O and CPU and cause an outage. Only run it when the cluster has spare capacity.</p><p><span class="bg-orange-100/80 text-orange-700 dark:bg-orange-800/70 dark:text-orange-200">Partitioning</span> creates more, smaller heaps. So a wasted pass is cheaper and can be retried sooner. Squeeze is per partition, so the spare disk you need to run squeeze is the size of a partition, not the whole table.</p><p>Splitting the table does not split the snapshot horizon. <code>xmin</code> is still cluster-wide, so a long query anywhere on the cluster still pins every partition.</p><p>You can drop old or bloated partitions instead of compacting them.</p><p><span class="bg-yellow-100/70 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-300">Vertical scaling</span> adds more spare disk space, which may be the only reason squeeze can run at all and/or finish sooner. That lets you compact the same large file faster.</p><p><span class="bg-blue-100/80 text-blue-700 dark:bg-blue-800/80 dark:text-blue-300">Sharding</span> creates distinct Postgres database clusters, so a long query or dump on one shard doesn&#x27;t pin vacuum on any other. Squeeze runs only on the copy in its shard, not the entire large table. Each cluster tracks its own <code>xmin</code>.</p><p>In a sharded database, wasted vacuum passes are cheaper and less likely to happen.</p><h2 id="slow-queries-hold-connections-longer"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#slow-queries-hold-connections-longer">Slow queries hold connections longer</a></h2><p>It is impossible to write a Postgres article about performance and not mention that <em>Postgres has a connection-per-process architecture</em>. Connection hygiene matters even more when your database has a large table.</p><iframe src="https://planetscale.com/blog/dealing-with-large-tables-in-postgres/iframe#connections" title="Interactive: two connections stuck on slow queries against events" loading="lazy"></iframe><p>Sequential scans (reading large segments of the table from start to finish), big sorts, and heavy joins against a large table take longer, so they hold connections longer.</p><p>Raising <code>max_connections</code> feels like a logical solution to avoid &quot;too many clients already,&quot; but this only lets more concurrent connections perform these slow queries. At best, this starves other workloads of new connections. At worst, a traffic spike on your large table is more likely to trigger an out-of-memory (OOM) event.</p><p>A large table will not stay in <code>shared_buffers</code>. More processes scanning it means more cache eviction, more disk usage, slower queries, and longer-held connections.</p><p><span class="bg-orange-100/80 text-orange-700 dark:bg-orange-800/70 dark:text-orange-200">Partitioning</span> splits your large table, which can help queries finish faster (and release connections sooner) so long as they access only one or a few partitions. But cross-partition queries can get worse.</p><p>Buy all the RAM and CPU you like with <span class="bg-yellow-100/70 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-300">vertical scaling</span>, but you&#x27;re still not putting a hundreds-of-GB table into cache. You cannot spend your way out of inefficient connection handling.</p><p><span class="bg-blue-100/80 text-blue-700 dark:bg-blue-800/80 dark:text-blue-300">Sharding</span> spreads your backends across multiple clusters, where each shard-distinct query is shorter and more likely cached.</p><p>A connection held by a large table is one the rest of the product cannot use. With workloads spread across shards there may be less overlap.</p><p>A scatter-gather query with no shard key can hold more connections, not fewer. Selecting the right key to shard on is critical to seeing performance benefits from sharding.</p><h2 id="slower-backups-and-recovery"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#slower-backups-and-recovery">Slower backups and recovery</a></h2><p>Postgres gives you three ways to take a backup. A large table complicates each one in a unique way.</p><iframe src="https://planetscale.com/blog/dealing-with-large-tables-in-postgres/iframe#backups" title="Interactive: copying a large table looks busy but barely makes progress" loading="lazy"></iframe><p>A logical dump with <code>pg_dump</code> writes live rows, so dead tuples stay out of the output. The dump still walks the bloated heap, and it holds a transaction snapshot the whole time. On a small table, that pin is brief. On a large table, the backup is a long-running transaction against the cluster.</p><p>A file system backup copies the data directory, bloat and all. Faster than dumping rows. A consistent copy usually means shutting the database down, or relying on atomic snapshots from the file system. You might be okay with a maintenance window for a database of small tables, but a single large table could add hours just to copy one relation.</p><p>Continuous archiving copies the data files while Postgres is still running, then keeps the WAL so you can replay through the modified bits and land on a consistent copy. No dump pin, no shutdown. You still copy the large table, bloat, and its indexes. What grows with the table is restore time. How long an incident waits on this heap plus the WAL written while the copy ran.</p><p>That third option is <a href="https://planetscale.com/blog/postgres-backups-under-the-hood">how PlanetScale backs up Postgres</a>. We copy the data files and continuously archive WAL on a throwaway node rather than on your primary, so the backup doesn&#x27;t compete with production queries for disk and doesn&#x27;t pin the primary.</p><p>We can&#x27;t skip the size of those files. A large table that doesn&#x27;t fit in memory, plus its indexes, plus its bloat, is what has to land in object storage.</p><p><span class="bg-orange-100/80 text-orange-700 dark:bg-orange-800/70 dark:text-orange-200">Partitioning</span> rearranges files but doesn&#x27;t shrink the copy. A pin held during dump is still cluster-wide. A physical backup includes the same bloat even if it is split across smaller tables.</p><p><span class="bg-yellow-100/70 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-300">Vertical scaling</span> means faster copies through more resources. But the files aren&#x27;t any smaller, so restore is only quicker because the disks are faster.</p><p>But neither partitioning nor vertical scaling changes the <em>size</em> of the data.</p><p><span class="bg-blue-100/80 text-blue-700 dark:bg-blue-800/80 dark:text-blue-300">Sharding</span> breaks down backups and restores into smaller, distinct units of work, allowing them to complete much faster. Recovery time depends on the slowest shard instead of the largest table.</p><h2 id="too-many-indexes"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#too-many-indexes">Too many indexes</a></h2><p>A large table isn&#x27;t viable to scan from start to finish and is likely queried in many different ways.</p><iframe src="https://planetscale.com/blog/dealing-with-large-tables-in-postgres/iframe#indexes" title="Interactive: indexes keep accumulating until they push the events table off the page" loading="lazy"></iframe><p>To keep queries fast, you keep adding indexes to the table. While indexes make queries fast, they aren&#x27;t free. They take disk, get vacuumed, get backed up, and make every write touch more files.</p><p>There are no good options here. Prune indexes and your queries get slower. Accumulate indexes and pay for it with an enlarged heap, while also vacuuming, packing, and copying many access paths.</p><p>The indexes that make a large table usable are part of what makes it too large to keep indexing.</p><p>Indexes on a <span class="bg-orange-100/80 text-orange-700 dark:bg-orange-800/70 dark:text-orange-200">partitioned</span> table are created on the logical table but stored and updated on each partition. Those smaller indexes are faster to build and cheaper to <code>REINDEX</code>.</p><p><span class="bg-yellow-100/70 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-300">Vertical scaling</span> buys you more space to store more indexes ... but that&#x27;s like fixing traffic congestion by adding another lane to the freeway.</p><p><span class="bg-blue-100/80 text-blue-700 dark:bg-blue-800/80 dark:text-blue-300">Sharding</span> creates more, smaller databases, so having more indexes is less of a problem. You can afford to create many more indexes than if your large table was on a single cluster since a write only updates indexes on the shard that owns that row. Sharding is the <em>good option</em> for balancing a lot of data and many indexes.</p><h2 id="wide-tables-split-across-pages-and-files"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#wide-tables-split-across-pages-and-files">Wide tables split across pages and files</a></h2><p>A table is not only large because it has many rows; it might be that the rows are wide with many columns, or each row contains a large amount of data.</p><iframe src="https://planetscale.com/blog/dealing-with-large-tables-in-postgres/iframe#wide" title="Interactive: a wide row fills most of a page so the next row has to take a new one" loading="lazy"></iframe><p>If a row exceeds ~2KB, oversized values go to TOAST, a separate table and indexes next to the heap you are already struggling to vacuum.</p><p>Each toasted value gets a 32-bit OID, about 4 billion per table. Updates of toasted values take a new OID. High-churn wide tables fill that space, and inserts slow down as Postgres hunts for a free one before the hard stop.</p><p>Whatever is left still has to fit into a single 8KB page, as Postgres will not split a row across pages. If a tuple cannot fit in the free space on any existing page, it gets a new page.</p><p>For a read-heavy table, you want pages packed tight (a high fillfactor), so scans walk as few pages as possible. For an update-heavy table, you want the opposite. Lower the fillfactor so an update can often stay on the same page instead of allocating a new one.</p><p>On a small table, the wrong fillfactor is a few wasted pages. On a large, wide table it could be terabytes of sparse pages, or a constant stream of new ones that vacuum, WAL, and backups all have to follow.</p><p>A large table is a heap, plus indexes, plus TOAST, and some operations still walk those files one at a time. <code>pg_database_size()</code> is one example. It stats every file in the database serially. On a large, wide table, that can pin a core.</p><p><span class="bg-orange-100/80 text-orange-700 dark:bg-orange-800/70 dark:text-orange-200">Partitioning</span> is a genuinely good solution for this class of large-table problems. Fillfactor can differ per-partition. Each partition gets its own TOAST table, so OID space is per slice.</p><p>However, with more files in one cluster, <code>pg_database_size()</code> can get worse.</p><p><span class="bg-yellow-100/70 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-300">Vertical scaling</span> can hide a bad fillfactor for a while with extra disk space. Faster disks make TOAST vacuum and file <code>stat()</code>s cheaper. But you cannot buy greater limits than 8KB pages, the 2KB TOAST threshold, or the 4 billion OIDs.</p><p><span class="bg-blue-100/80 text-blue-700 dark:bg-blue-800/80 dark:text-blue-300">Sharding</span> creates individual Postgres instances, so TOAST OID space resets per cluster. A bad fillfactor hurts less because the heap is smaller. File walks are per shard.</p><p>Sharding does not, by itself, let hot and cold rows use different fillfactors. Partitioning still wins there.</p><h2 id="sharding-is-the-solution-to-large-tables"><a href="https://planetscale.com/blog/dealing-with-large-tables-in-postgres#sharding-is-the-solution-to-large-tables">Sharding is the solution to large tables</a></h2><p>Just as partitioning breaks up a single table into many small pieces, sharding distributes the workload of your reads and writes across many database clusters.</p><p>Let&#x27;s revisit the cascade delete story.</p><p>When replicas lagged, every read went to the primary. On a sharded database, each shard is its own cluster, with its own WAL and replicas. If the affected rows of A and B lived on one shard, that shard could still lag, and its reads pile onto its primary, but that could not fail the LSN check for the rest of the product. If those 100,000 deletes were spread across shards, the WAL burst would be split. No replica stream would have to replay the whole thing.</p><p>Your single-cluster large table likely processes a high throughput of traffic. Vertical scaling can help, but sharding does it better. With more clusters receiving and processing writes, a single table doesn&#x27;t strain the entire cluster.</p><p>Additionally, cluster limits that cannot be solved by partitioning or vertical scaling are resolved. Postgres&#x27; hard-coded ceilings of 32-bit XIDs, TOAST OID and more become per-shard limits instead.</p><p>Sharded database backups are individually smaller and can be <a href="https://planetscale.com/blog/massively-parallel-postgres-backups">massively parallelized</a>, making database restoration faster as well. Operations finish at the time of the slowest shard, not all at once waiting on large, bloated tables.</p><p>Cache eviction is a problem with large tables and their working sets. While more resources won&#x27;t fit a large table into RAM, you often can per shard. Shard-local queries become shorter and faster.</p><p>Neki, sharded Postgres, lets your application write queries as if it were a single database, while splitting a large table into smaller, automatically distributed parts.</p><p>It contains the logic for where to send reads and writes. You still have to determine how that data is spread. That fan-out is covered in our post on <a href="https://planetscale.com/blog/what-is-a-data-topology">data topologies</a>.</p><p>Sharding shares the large table load across multiple clusters. If that&#x27;s a problem you need solved, <a href="https://auth.planetscale.com/sign-up">sign up for a PlanetScale account</a> to use Neki today.</p><div class="mb-3 border p-3 border-blue-600 dark:border-blue-500"><p><span class="bg-blue-600 px-sm text-white dark:bg-blue-500 dark:text-black">Note</span></p><p>If you&#x27;re interested, we also have an article covering <a href="https://planetscale.com/blog/dealing-with-large-tables">big tables with MySQL and Vitess</a>.</p></div>]]></content>
    <summary><![CDATA[Postgres presents pretty predictable performance problems when dealing with large tables. Sharding solves this.]]></summary>
  </entry>
  <entry>
    <title>The history of Postgres sharding</title>
    <link href="https://planetscale.com/blog/the-history-of-postgres-sharding"/>
    <id>https://planetscale.com/blog/the-history-of-postgres-sharding</id>
    <published>2026-08-24T00:00:00.000Z</published>
    <updated>2026-08-24T00:00:00.000Z</updated>
    <author>
      <name>Josh Brown</name>
    </author>
    <category term="engineering"/>
    <category term="neki"/>
    <category term="postgres"/>
    <content type="html"><![CDATA[<p>Over the past 20 years, many companies have achieved such a scale that their database could no longer support the workload assigned to it. The solution is almost universally to shard the database, but only when absolutely necessary due to the added complexity.</p><p>MySQL was a much more popular choice for relational databases in the 2010s and because of this, the tooling around things like online schema changes and sharding progressed much quicker. Tools like <a href="https://github.com/github/gh-ost">gh-ost</a>, <a href="https://planetscale.com/vitess">Vitess</a>, and more were built around this ecosystem.</p><p>Postgres, on the other hand, had one-offs, then Citus, then proxy-like solutions. Now, in 2026, <a href="https://planetscale.com/neki">Neki</a> builds on the lessons learned from the incredible engineers that have built not only Vitess, but all the other sharding solutions that came before it.</p><p>To fully understand Neki, and why we built it, it&#x27;s important to have an understanding of how we got here.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/timeline-B6yj_Ow3.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/timeline-darkmode-C5eUHlHZ.svg?auto=compress%2Cformat"><img alt="A twenty-year timeline from Ultima Online shards through Skype, Vitess, Instagram, Citus, and Neki." src="https://planetscale-images.imgix.net/assets/timeline-B6yj_Ow3.svg?auto=compress%2Cformat" width="1504" height="300" loading="lazy"></picture></p><h2 id="it-started-in-a-video-game"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#it-started-in-a-video-game">It started in a video game</a></h2><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/uo-Co9VDuNu.png?auto=compress%2Cformat"><img alt="An Ultima Online world copied onto independent parallel shards." src="https://planetscale-images.imgix.net/assets/uo-Co9VDuNu.png?auto=compress%2Cformat" width="1280" height="878" loading="lazy"></picture></p><p>One of the more prominent theories is that the term &quot;Shard&quot; originated, of all places, from a video game. Ultima Online is an <a href="https://en.wikipedia.org/wiki/Massively_multiplayer_online_role-playing_game">MMORPG</a> created in 1997. The developers quickly realized they needed to host players on multiple servers to sustain growth.</p><p>To support this, they invented lore that would make the idea of multiple servers make sense in the fictional universe. <a href="https://www.raphkoster.com/2009/01/08/database-sharding-came-from-uo/">After digging through heaps of lore</a>, &quot;Shards&quot; seemed like the best fit.</p><p>At the end of Ultima I, an evil wizard is defeated who had tried to trap the world into a crystal. When the wizard is defeated, his crystal breaks into &quot;shards&quot;, each holding a copy of the world within it.</p><p>And thus, the term &quot;sharding&quot; was used to denote parallel, independently evolving servers. This term slowly made its way through the industry, narrowing its meaning to database sharding in particular.</p><h2 id="why-mysql-got-there-first"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#why-mysql-got-there-first">Why MySQL got there first</a></h2><p>The LAMP stack was all the rage in the early 2010s. This gave the sharding story for MySQL a head start, as most of the successful companies had started in an era where MySQL was the popular choice.</p><p>Facebook was one of the first companies to truly hit a global scale, serving over 100m active users in 2008 on roughly 1,800 MySQL nodes. Initially, sharding was done at the application level. Each user was assigned an ID that could be used to determine which shard their data lives on.</p><p>Eventually, as Facebook grew, their need for a specialized layer on top of MySQL became apparent. Facebook created <a href="https://engineering.fb.com/2013/06/25/core-infra/tao-the-power-of-the-graph/">TAO</a> to accommodate the scale and access patterns that fit their needs.</p><p>YouTube, similarly, started as a single primary node. When the load grew too high, they added replicas. When the primary could no longer handle write pressure, they began the monumental task of sharding.</p><p>Originally, their solution was also baked into the application layer itself. YouTube&#x27;s backend would determine which database server a user request should go to. Around 2010, they pulled out the routing layer into a standalone project, and thus <a href="https://vitess.io/docs/23.0/overview/history/">Vitess was born</a>.</p><p>Many companies that began scaling their LAMP-based stacks slowly consolidated on Vitess as their sharding solution.</p><p>It took Postgres much longer to get a similar solution. Many companies that would later hit Postgres scaling limits built in-house solutions. Most of these were never separated into standalone software, often due to how fine tuned they were for each company&#x27;s needs.</p><h2 id="build-it-yourself-then-live-in-it"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#build-it-yourself-then-live-in-it">Build it yourself, then live in it</a></h2><p>Early Postgres sharding was frequently built as custom, in-house solutions. If a single Postgres node couldn&#x27;t handle your scale, start spinning up more nodes, create a router, and a team of engineers to manage it.</p><p>For companies successful enough to need sharding, dedicating a team to solving database scaling was not an impossible ask. If the database can&#x27;t scale up, the application will start failing, and growth stops.</p><h3 id="skype-and-plproxy"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#skype-and-plproxy">Skype and PL/Proxy</a></h3><p>In 2007 <a href="https://plproxy.github.io/">Skype announced PL/Proxy</a> to the Postgres mailing list. The announced version was Skype&#x27;s second iteration (v2) of PL/Proxy that ran as an extension on a Postgres Proxy database.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/pl-proxy-DHPI1ZZe.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/pl-proxy-darkmode-DHtmfdrB.svg?auto=compress%2Cformat"><img alt="PL/Proxy architecture: one central routing database sending queries to downstream shards." src="https://planetscale-images.imgix.net/assets/pl-proxy-DHPI1ZZe.svg?auto=compress%2Cformat" width="1504" height="540" loading="lazy"></picture></p><p>PL/Proxy was one of the earliest sharding solutions, allowing operators to route queries to different shards by defining SQL functions in the proxy database. The downside to this approach was that every operation that would result in a sharded query would need a defined function to route to the proper shard, defined by the user.</p><p>For instance, if you wanted to shard a <code>users</code> table, you would need to create PL/Proxy functions to insert, update, and read. For each new query pattern, you would need to create a new function and call it from the application layer.</p><p>Although this drastically improved scalability by removing the need for the application to keep track of each downstream shard, the application and database operators would need to work in tandem to ensure every query pattern is defined in the proxy and routed correctly.</p><h3 id="instagram-and-logical-shards"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#instagram-and-logical-shards">Instagram and logical shards</a></h3><p>Instagram is another early pioneer of Postgres sharding. In 2012, Instagram&#x27;s ~30 million users could no longer fit onto a single EC2 instance running Postgres.</p><p>The solution for Instagram was to do logical sharding at the application layer. The application would map thousands of logical schemas down to a few physical shards. Any given physical database could have multiple logical schemas (collections of tables) within it.</p><p>When a given schema grew too large, it could be migrated to a new physical database, and the application would re-map that logical schema to the new database address. This was an elegant solution for their use case, since it required low overhead to manage. This system helped Instagram scale for years.</p><p>Instagram had debated going with PL/Proxy, but decided to hand-roll their own solution to keep with a more minimalistic design, specifically not needing to deal with the SQL function overhead, and for reduced network latency. Their application would talk directly to each shard without a proxy.</p><p>The downside to this approach, however, is that cross shard queries must be managed in the application layer, and moving data between databases requires careful coordination between the app&#x27;s mapping of schemas and physical shards. Sharding was also <a href="https://medium.com/instagram-engineering/sharding-ids-at-instagram-1cf5a71e5a5c">limited to their custom integer ID approach</a>, so every table that needed sharding would also need an ID column.</p><h2 id="then-someone-put-it-in-postgres"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#then-someone-put-it-in-postgres">Then someone put it in Postgres</a></h2><p>Citus was the first open-source sharding solution for Postgres that was designed for wider adoption. Designed originally in 2011, Citus started as a fork of Postgres.</p><p>Later on in 2016, Citus was refactored into a pure extension of Postgres, rather than a fork. Citus shards data by defining nodes as either coordinators or workers.</p><p>Every node is a standalone Postgres instance running the Citus extension. The coordinator node holds the routing map, routes queries as needed, and keeps track of all downstream worker nodes. Worker nodes store the sharded data, and execute the queries passed on to them from the coordinator.</p><p>Citus improves on earlier Postgres sharding solutions by keeping the sharding metadata and routing logic within one central node. This adds some undesirable side effects, however. First, the coordinator becomes the bottleneck. Although some queries in Citus 11+ can go through worker nodes, your application either needs to send all requests to the coordinator node, or keep track of multiple connection strings to each worker it wants to distribute load onto.</p><p>Another downside is that shard management and backups are a semi-manual process. Adding a shard with more resources for a noisy tenant, or many small shards for a wide shard space requires substantial manual configuration of not only the database servers themselves, but wiring them up together with Citus. Managing backups is also external to Citus, so operators still need to build the proper infrastructure to accommodate disaster recovery and healing nodes as needed.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/citus-CBpzQboB.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/citus-darkmode-CPP1ktHJ.svg?auto=compress%2Cformat"><img alt="A Citus cluster with a Citus extension wrapping Postgres on a coordinator and worker nodes." src="https://planetscale-images.imgix.net/assets/citus-CBpzQboB.svg?auto=compress%2Cformat" width="1504" height="640" loading="lazy"></picture></p><p>Other similar attempts have been made to bring sharding to Postgres such as PgDog and Aurora Limitless. PgDog is a spiritual successor to PgCat, both of which improve on Citus&#x27;s architecture substantially.</p><p>PgDog operates as a pure proxy layer in front of Postgres clusters. Although PgDog supports sharding, managing schema changes and resharding operations is still a very involved and manual task. Managing backups and disaster recovery of the Postgres nodes is also up to the operator.</p><p>Aurora operates in a similar fashion. Routers front Postgres nodes backed by EBS, with support for sharding.</p><h2 id="hide-the-shard-key"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#hide-the-shard-key">Hide the shard key</a></h2><p>Other solutions to scaling Postgres resulted in not using real Postgres at all, opting for a Postgres-compatible approach. Google published the <a href="https://cloud.google.com/spanner">Spanner</a> research paper in 2012 while using it internally, later releasing it as a public service in 2017.</p><p>Spanner&#x27;s approach hides sharding from the user and operator altogether, opting for automatic distribution of data across nodes. Instead of defining shard keys or indexes, Spanner splits and moves tables automatically as table size grows.</p><p>This is done by storing the data as key-value pairs. These key-value pairs are grouped into &quot;splits&quot; and replicated across the storage layer, using the <a href="https://en.wikipedia.org/wiki/Paxos_(computer_science)">Paxos</a> consensus algorithm to maintain consistency.</p><p><a href="https://www.cockroachlabs.com/">CockroachDB</a> and <a href="https://www.yugabyte.com/">Yugabyte</a> are similar distributed Postgres-compatible database solutions. Note that they are Postgres-compatible, not actual Postgres solutions. Inevitably this results in lower fine-tune control over the cluster compared to the large amount of settings and flags available for actual Postgres clusters.</p><div class="mb-3 border p-3 border-blue-600 dark:border-blue-500"><p><span class="bg-blue-600 px-sm text-white dark:bg-blue-500 dark:text-black">Note</span></p><p>Yugabyte and CockroachDB are open source projects. However, Spanner is closed source.</p></div><p>All of these solutions share the same pitfalls. Performance can be disjoint from expected results since the underlying data access layer is not truly Postgres.</p><p>Extension support also becomes limited, along with some Postgres native SQL features and settings.</p><p>Data placement also becomes hard to predict. Related rows can easily drift across multiple storage locations causing drastically increased latency.</p><p>Explicit sharding makes it easy for engineers to understand and plan around cross-shard latency penalties. Automatic sharding blurs these lines, making it impossible to determine when or how a table will shard, and how drastically performance will drop because of it.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/distributed-sql-CEYpq2Bq.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/distributed-sql-darkmode-DMouKgnD.svg?auto=compress%2Cformat"><img alt="An application request fans out through multiple routers to KV store buckets, with related rows landing on different buckets." src="https://planetscale-images.imgix.net/assets/distributed-sql-CEYpq2Bq.svg?auto=compress%2Cformat" width="1504" height="640" loading="lazy"></picture></p><h2 id="building-the-best-solution"><a href="https://planetscale.com/blog/the-history-of-postgres-sharding#building-the-best-solution">Building the best solution</a></h2><p>The amount of engineering work that went into many of these systems cannot be overstated. As we have seen however, that does not make them perfect. Neki builds on the lessons and foundations from all of its predecessors.</p><p>Neki&#x27;s goal is to raise the bar for scaling Postgres.</p><p>While Spanner and CockroachDB shard data automatically, Neki requires explicit sharding. Developers have complete control over exactly how data is distributed across clusters.</p><p>Neki also uses true Postgres clusters under the hood. This means support for Postgres extensions, stronger SQL compatibility, and predictable performance.</p><p>Compared to PL/Proxy or Citus, sharding is defined by a routing file called the <a href="https://planetscale.com/blog/what-is-a-data-topology">data topology</a>, allowing database operators to define and evolve sharding schemes over time. Since each Neki router only holds a cache of the topology, routers can be horizontally scaled with ease.</p><p>One connection string can go to any router, letting your application treat 1,000 shards as <a href="https://planetscale.com/blog/making-768-servers-look-like-1">one unified database</a>. No single router becomes the bottleneck.</p><p>Each Neki shard has its own primary and replicas. If a primary dies, Neki can bring the cluster back to a healthy state. Routers sit in front, connecting all the shards together.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-CDJ4xt0w.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-darkmode-DOytGnEo.svg?auto=compress%2Cformat"><img alt="An application connects to a Neki router that uses an explicit data topology to send queries to the correct Postgres shards." src="https://planetscale-images.imgix.net/assets/neki-CDJ4xt0w.svg?auto=compress%2Cformat" width="1504" height="705" loading="lazy"></picture></p><p>Neki also allows each group of shards to be independently sizeable and scalable, making it possible to precisely control resources for every part of your cluster.</p><p>Unlike PgDog or Citus, Neki handles backups, restores, node health, <a href="https://planetscale.com/docs/neki/monitoring/query-insights">Insights</a>, connection pooling, schema changes, and much more. By being more than just an extension or pure proxy, Neki can manage every part of the database life cycle.</p><p>Sharding used to be one of the scariest things a growing team could face. Neki aims to change that, making it easier than ever for teams to reach truly planet-scale with as little friction as possible, the familiarity of real Postgres, and incredible performance. <a href="https://auth.planetscale.com/sign-up">Sign up for a PlanetScale account</a> to use Neki today.</p>]]></content>
    <summary><![CDATA[Why has it taken so long to get good Postgres sharding? The last 20 years are the answer.]]></summary>
  </entry>
  <entry>
    <title>What is a data topology?</title>
    <link href="https://planetscale.com/blog/what-is-a-data-topology"/>
    <id>https://planetscale.com/blog/what-is-a-data-topology</id>
    <published>2026-08-17T00:00:00.000Z</published>
    <updated>2026-08-17T00:00:00.000Z</updated>
    <author>
      <name>Harshit Gangal</name>
    </author>
    <author>
      <name>Ahmed Darwich</name>
    </author>
    <category term="engineering"/>
    <category term="neki"/>
    <category term="postgres"/>
    <content type="html"><![CDATA[<p>“Vitess for PostgreSQL” is a useful shorthand for the ambition behind Neki, but it significantly understates the architecture we have built. Neki carries forward what we learned from Vitess in a new sharding architecture designed around PostgreSQL’s internals and semantics from the start.</p><p>PlanetScale has spent years building and operating Vitess at scale. That experience has shaped how we approach query routing, data placement, and the challenge of making many database servers behave like one.</p><p>We have previously written about <a href="https://planetscale.com/blog/making-768-servers-look-like-1">how Neki makes hundreds of database servers behave like one</a>. Here, we take a closer look at the mechanism that controls where data lives and how queries are routed.</p><p>One of Neki’s core design goals is flexibility in data placement. There are many ways engineers need to map their logical database schemas onto physical shards in order to optimize for performance and scalability.</p><p>That configuration is expressed as a <strong>data topology</strong>.</p><h2 id="what-is-a-data-topology"><a href="https://planetscale.com/blog/what-is-a-data-topology#what-is-a-data-topology">What is a data topology?</a></h2><p><a href="https://planetscale.com/blog/what-is-database-sharding-and-how-does-it-work">Sharding</a> spreads data across multiple servers, but it also creates a unique routing problem. How does each query find the shard or shards that hold the data it needs?</p><p>Without a routing layer, the application would need to keep track of where its data lives and choose the correct shard for each query.</p><p>A data topology is how Neki answers that question. It is a JSON configuration that describes a PostgreSQL sharding scheme by mapping logical PostgreSQL tables to groups of physical shards and giving routers the information they need to route queries.</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/neki-router-data-topology-shards-DNE6dqRA.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/neki-router-data-topology-shards-darkmode-DQHVp1U6.svg?auto=compress%2Cformat"><img alt="A customer query enters a Neki router, which uses its cached data topology to select a PostgreSQL shard containing a primary and replicas." src="https://planetscale-images.imgix.net/assets/neki-router-data-topology-shards-DNE6dqRA.svg?auto=compress%2Cformat" width="1504" height="870" loading="lazy"></picture></p><p>A data topology has three building blocks:</p><ul><li><strong>Shard indexes</strong> specify the column or expression Neki uses to route a row and how its value is transformed for routing. The selected column or expression is called the <em>shard key</em>, and its transformed value is the <em>routing key</em> that Neki uses to select a shard.</li><li><strong>Shard groups</strong> define a set of physical shards and assign a range of routing keys to each one. They also specify the default shard index for tables in the group. Related tables that use the same shard key can be colocated, allowing joins and transactions between them to stay local.</li><li><strong>Databases</strong> bind tables to shard groups, following the PostgreSQL hierarchy of databases, schemas, and tables. A table can name its shard group directly or inherit a default from its schema, its database, or the cluster.</li></ul><p>Together, these building blocks tell Neki how to route queries.</p><p>The data topology does not provision or manage physical shards. Those shards are provisioned separately, and the topology refers to each one by a stable ID. In Neki, a shard consists of a PostgreSQL primary and its replicas.</p><h2 id="example-of-a-data-topology"><a href="https://planetscale.com/blog/what-is-a-data-topology#example-of-a-data-topology">Example of a data topology</a></h2><p>Consider a <code>store</code> database that partitions related data around its customers.</p><div class="code-block" data-language="sql"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">CREATE</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> TABLE</span><span style="--shiki-light:#5E49AF;--shiki-dark:#B7A5FB"> customers</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1"> (</span></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">  customer_id </span><span style="--shiki-light:#F35815;--shiki-dark:#F35815">bigint</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> PRIMARY KEY</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">,</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">  name</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> text</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> NOT NULL</span><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">,</span></span>
<span class="line"><span style="--shiki-light:#F35815;--shiki-dark:#F35815">  address</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> text</span><span style="--shiki-light:#F35815;--shiki-dark:#F35815"> NOT NULL</span></span>
<span class="line"><span style="--shiki-light:#2B2B2B;--shiki-dark:#E1E1E1">);</span></span>
<span class="line"></span></code></pre></div></div><p>Suppose the <code>customers</code> table needs to be distributed across two shards. Here&#x27;s how a data topology might look:</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/data-topology-json-CCh5Wtoi.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/data-topology-json-darkmode-CSBdTDxQ.svg?auto=compress%2Cformat"><img alt="An example Neki data topology annotated with its shard index, shard group, and database mapping." src="https://planetscale-images.imgix.net/assets/data-topology-json-CCh5Wtoi.svg?auto=compress%2Cformat" width="1504" height="1477" loading="lazy"></picture></p><h3 id="shard-indexes"><a href="https://planetscale.com/blog/what-is-a-data-topology#shard-indexes">Shard indexes</a></h3><p>The topology names this shard index <code>customer_id_xxhash</code>. It tells Neki to convert each <code>customer_id</code> into a routing key using <code>xxhash</code>.</p><p>Neki supports three shard-index strategies:</p><ul><li><strong>Hash-based</strong> indexes use a hash function to map a value into the routing space. Neki uses <code>xxhash</code>.</li><li><strong>Modulo-based</strong> indexes divide an integer by a configured modulus and use the remainder.</li><li><strong>Range-based</strong> indexes use an integer value directly, without hashing it.</li></ul><p>Each strategy takes a different path from <code>customer_id</code> to a physical shard:</p><p><picture class="block"><source media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" srcSet="https://planetscale-images.imgix.net/assets/shard-index-routing-B0_p97UG.svg?auto=compress%2Cformat"><source media="(prefers-color-scheme: dark)" srcSet="https://planetscale-images.imgix.net/assets/shard-index-routing-darkmode-As3uSSoH.svg?auto=compress%2Cformat"><img alt="Three Neki shard-index strategies showing how hash-based routing with xxhash, modulo-based routing, and range-based routing map customer IDs to shards." src="https://planetscale-images.imgix.net/assets/shard-index-routing-B0_p97UG.svg?auto=compress%2Cformat" width="1504" height="958" loading="lazy"></picture></p><p>In this example, <code>customer_id</code> is the shard key, and its value is <code>42</code>. Each strategy transforms its value into a different routing key. <code>xxhash</code> produces <code>0x2e4fe982b68910ac</code>, which falls in the range assigned to <code>customer-shard-1</code>. <code>modulo</code> calculates <code>42 mod 4</code>, producing <code>2</code>, which falls in the range assigned to <code>customer-shard-2</code>. <code>range</code> uses <code>42</code> directly, which falls in the range assigned to <code>customer-shard-1</code>. In each case, the shard group selects the physical shard whose range contains the routing key.</p><h3 id="shard-groups"><a href="https://planetscale.com/blog/what-is-a-data-topology#shard-groups">Shard groups</a></h3><p>The <code>customer_data</code> shard group divides the possible <code>xxhash</code> results between two shards. This example focuses on hash-based routing, but shard groups work similarly with modulo-based and range-based strategies. Key-range boundaries are written as hexadecimal prefixes, so <code>80</code> expands to <code>0x8000000000000000</code>, the midpoint of the routing space:</p><div class="code-block" data-language="text"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>customer-shard-1: 0x0000000000000000 through 0x7fffffffffffffff</span></span>
<span class="line"><span>customer-shard-2: 0x8000000000000000 through 0xffffffffffffffff</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>For example, these two customer IDs land on different shards because their hashes fall in different ranges.</p><div class="code-block" data-language="text"><div class="max-w-full min-w-0"><pre class="shiki shiki-themes planetscale-light planetscale-dark" style="--shiki-light:#2b2b2b;--shiki-dark:#e1e1e1;--shiki-light-bg:#ebebeb;--shiki-dark-bg:#1a1a1a" tabindex="0"><code><span class="line"><span>customer_id 42 → 0x2e4fe982b68910ac → customer-shard-1</span></span>
<span class="line"><span>customer_id 1  → 0xf36b4a1a44f78bf3 → customer-shard-2</span></span>
<span class="line"><span></span></span></code></pre></div></div><p>Neki first hashes the raw customer ID, then finds the range containing the resulting routing key.</p><p>Every data topology also names an <code>authoritative</code> group, a single-shard home for database-wide metadata such as sequences and schema information. Queries against <code>customers</code> still use <code>customer_data</code>.</p><h3 id="databases"><a href="https://planetscale.com/blog/what-is-a-data-topology#databases">Databases</a></h3><p>The <code>databases</code> section follows the familiar PostgreSQL hierarchy of databases, schemas, and tables.</p><p>Most tables do not need to appear in the data topology. An unlisted table inherits the default shard group from its schema, then its database, then the cluster, allowing a topology to shard an entire database with one default and list only the exceptions.</p><p>Put together, the explicit entry for <code>store.public.customers</code> maps it to <code>customer_data</code>, where it uses the <code>customer_id_xxhash</code> shard index and the group’s key ranges.</p><h2 id="the-foundation-of-neki-internals"><a href="https://planetscale.com/blog/what-is-a-data-topology#the-foundation-of-neki-internals">The foundation of Neki internals</a></h2><p>On one side of a Neki router is the user&#x27;s application, speaking the PostgreSQL wire protocol. On the other, the application&#x27;s data can be spread across an ever-growing number of physical shards.</p><p>The data topology connects the two, informing the router how data is divided and which shard or shards should receive a query. The result is a live configuration that maps logical databases to physical locations. Neki updates it during resharding, table moves, and imports, all while the application continues serving traffic.</p><p>From here, we can explore the rest of Neki’s architecture and how it helps <a href="https://planetscale.com/blog/making-768-servers-look-like-1">make 768 servers look like 1</a>.</p><p>Running PostgreSQL at scale? <a href="https://auth.planetscale.com/sign-up">Sign up for a PlanetScale account</a> to use Neki today.</p>]]></content>
    <summary><![CDATA[A data topology describes the sharding scheme a Neki router uses to map logical PostgreSQL tables to physical shards and route queries.]]></summary>
  </entry>
</feed>
