Neki, sharded Postgres, is now available. Get started
Navigation

The lifecycle of a sharded Postgres query

If you’ve ever used Postgres and thought, “wow, this is such a simple piece of software, I understand every part of it perfectly,” you have not yet met the query planner.

Now consider sharding that database across a thousand servers. How would you serve a query?

All you'd need is a system that replicates the Postgres auth system, wire protocol, and parser, plus a shard-aware distributed query planner, graceful handling of all server failure scenarios, and connection pooling that overcomes the Postgres process-per-connection architecture. Easy, right?

Let's follow the journey of a Postgres query through all the layers of this elegant yet beautifully complex sharded system. Doing so will help us understand what goes into making large-scale sharded Postgres deployments appear to be a single Postgres server even when they span thousands of servers.

Though a real database may have hundreds of tables, in this example we will keep the schema simple: two tables spread across four shards.

CREATE TABLE customers (
    id          BIGINT PRIMARY KEY,
    name        TEXT NOT NULL,
    email       TEXT NOT NULL,
    country     TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    total       NUMERIC(12,2) NOT NULL,
    status      TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

As for the query whose journey we will follow, it is a simple SELECT statement which requests the customer name, order total, and order placement date for all recent orders.

SELECT customers.name, orders.total, orders.created_at
FROM customers
JOIN orders ON orders.customer_id = customers.id
WHERE orders.created_at >= $1;

Before we execute this query, there's one important thing to understand about sharding: the point is to scale databases beyond the constraints of a single server. This means we are now part of a distributed system. Our rows can now live on different servers, so we need rules for where to store them and how to find them.

In this case, we choose customers.id as the shard key for customers and orders.id as the shard key for orders. These primary keys are a natural, albeit naive, starting point as we will soon see. Each time we want to store a customers or orders row, the system takes the id, computes a hash, then uses this hash to determine where it is stored.

customersshard key: ididnameordersshard key: ididcustomer_idtotalShard 1customersordersShard 2customersordersShard 3customersordersShard 4customersorders2Alex6Jeff32Maja1Leah320732$30.00320332$70.0032022$25.0032016$40.0032082$15.00320932$20.00321$45.0016$60.00

Notice how the customers and orders are spread out across many servers. This makes a sharded database practically infinitely scalable, but presents interesting engineering challenges to overcome. Use the Follow Maja button to see how one customer and their orders are shard-located. The keen observer will notice that these are not co-located. Hold that thought, it will be important soon.

Let's begin the journey through a Neki sharded database.

Authentication

Before an application can send a query, it must authenticate.

Some sharded databases leave routing to the application layer, choosing the appropriate Postgres instance and connecting to it. Our router hides that complexity. It becomes Postgres to your application, managing auth, connections, and query distribution across the individual servers.

To support this, we implemented the full Postgres authentication exchange, including SCRAM-SHA-256, entirely in Go.

After TCP establishment, SSL negotiation, and the TLS handshake, the client sends a startup message containing the Postgres role, database name, and requested session settings. The router checks its authentication rules, and the SCRAM exchange begins.

The router sends a challenge. The driver uses its password and the challenge to calculate a proof, which the router checks against its stored verifier. The router then returns a signature for the driver to verify. This allows the router to validate that the client knows the correct password, without that password ever having to be transported over the wire.

Finally, the router checks database access, applies the session settings, and sends its startup responses. ReadyForQuery tells the client it can send our query.

Protocol

With the secure connection established, the application can now pass our query to its Postgres driver.

Many don’t know, but Postgres actually has TWO ways to send queries through its wire protocol: Simple and Extended. You might also not know that different Postgres drivers and even different settings within the same driver can send the same query differently. The router's goal is to work with any Postgres client, so it supports both.

Postgres Simple vs Postgres Extended Protocol

With the simple protocol, the client sends the entire SQL statement including the timestamp in a Query message.

Query
  SELECT customers.name, orders.total, orders.created_at
  FROM customers
  JOIN orders ON orders.customer_id = customers.id
  WHERE orders.created_at >= '2026-09-09 00:00:00+00';

In the extended protocol, our query is communicated in 5 parts.

Parse
  statement: ""
  query: SELECT customers.name, orders.total, orders.created_at
         FROM customers
         JOIN orders ON orders.customer_id = customers.id
         WHERE orders.created_at >= $1;

Bind
  statement: ""
  portal: ""
  parameters: ["2026-09-09 00:00:00+00"]

Describe
  portal: ""

Execute
  portal: ""

Sync
  • Parse asks the router to prepare the SQL, with $1 still in place. This creates an unnamed prepared statement, even though the application hasn't explicitly issued PREPARE.
  • Bind supplies the timestamp and creates a portal for this execution. A portal tracks the execution state of a query, tying the prepared statement to its parameter values and requested result formats.
  • Describe asks which columns the portal will return and in what formats.
  • Execute tells the router to execute that portal.
  • Sync marks the end of the batch.

For ad hoc queries (say, from a command line like psql), the simple protocol offers a simple interaction. Send the complete SQL and get the result back. It also can accept multiple SQL statements in one request.

The extended protocol, however, gives Postgres drivers separate control over preparation, parameter binding, and execution. That separation is useful when an application repeatedly executes the same statement with different values. Reusing a prepared statement avoids parsing the SQL and resolving its names and types repeatedly.

Because our router isn't an actual Postgres process, we implement this precise protocol behavior with some optimizations of our own. That's a story for a different day, however, and from the application's perspective our router is Postgres.

Parsing

Whether simple or extended, we end up with a string of SQL that must be processed. When communicating directly with Postgres, the Postgres parser will tokenize this query, build an abstract syntax tree (AST), then pass it along to the Postgres query planner. The AST represents the different parts of our query: the tables we reference, how we join them, the filter we apply, and the columns we want back.

We must do the same parsing and tree-building phase here at the router in a way that matches Postgres’ syntax precisely, both supporting and rejecting syntax identically. We built this in ~18,000 lines of Go, with strict requirements and tests to ensure our version was as performant as, if not more performant than, the C implementation in Postgres itself.

Like a compiler’s parser, it turns source text into an abstract syntax tree, giving the router a structure it can inspect and rewrite as it works out how to execute the query.

There are some cases where the router won't need to parse the SQL at all. For performance, the goal of the router is to avoid work whenever possible. It first checks for a reusable cached plan. A cache hit would let it skip parsing and planning. This time, however, there is no match. The router must parse, and then this AST is passed to the query planner.

Planning

By now we've done a significant amount of work and all we’ve accomplished is turning a SQL query into an abstract syntax tree. We’ve yet to consider sharding, yet to touch Postgres, and yet to answer the question "how do we execute this query fast?"

The thing that makes a sharded database system good is how well engineered the query planner is. Strap in, you’re about to find out just how much work went into making Neki’s do as little work as possible. It’s downright lazy.

Let’s reconsider the original query we’ve been following the journey of:

SELECT customers.name, orders.total, orders.created_at
FROM customers
JOIN orders ON orders.customer_id = customers.id
WHERE orders.created_at >= $1;

If we were on a single node of Postgres, what would it take to execute this? No shards, proxies, or query routers (for now). Postgres has its own query planner which, after getting the AST from the parser, must decide things like:

  • Using internal statistics, estimate how many orders match created_at >= $1.
  • Decide how to read each table: sequential, index, bitmap, or index-only scan.
  • Choose the join direction and whether to use a nested loop, hash join, or merge join.
  • Check whether an index on orders.created_at would help filter the orders.

These are just some of the decisions needed for our simple query. More complex queries give the planner more to consider, including additional joins, aggregations, subqueries, and sorting.

Recall that since this is a sharded database, all the rows from each of our tables are spread across shards. In this case we have 4 shards, but the principles we’re going through in this section apply whether there are 4 shards storing a terabyte or 400 shards storing a petabyte.

To decide which shard gets each row, we designate a column as the shard key. Earlier, we chose customers.id for customers and orders.id for orders. Let's revisit the visual from earlier.

customersshard key: ididnameordersshard key: ididcustomer_idtotalShard 1customersordersShard 2customersordersShard 3customersordersShard 4customersorders2Alex6Jeff32Maja1Leah320732$30.00320332$70.0032022$25.0032016$40.0032082$15.00320932$20.00321$45.0016$60.00

That decision will soon become a problem.

This layout allows for the row of a customer to reside on one shard, while the corresponding orders for that single customer are spread across many. How are we supposed to join them now?

A sharded query planner must be built to take any SQL query and construct the most optimal plan for executing across many Postgres nodes.

Building the plan

The result of the query planner is yet another tree. The planner’s job is to convert an AST describing the query's request into a query plan describing the full sequence of operations the cluster must complete to compute the results.

To construct this, the planner within the router must be aware of both (a) the full Postgres schema and (b) the rules for data distribution across shards.

It gets (a) from the database’s authoritative shard, the shard designated as the source of schema metadata for that database. The router gets the table names, columns, and types from here. It does not request this info from the authoritative shard for every query, but maintains a cache and communicates with it regularly for updates.

It gets (b) from the data topology, stored centrally in etcd and cached on every router node.

Using this information, the router resolves the columns in our query and their types, a step called semantic analysis. It also looks for potential shortcuts that could avoid distributed planning, but our query is not so lucky.

How to join?

Next up: Take the AST and turn it into a query plan tree. The first step is to transform this into a rudimentary plan, which it will refine in several phases. The base plan produced from the original query looks like:

SelectOutputClausesTargetListoutputcustomers.namecustomers.nameorders.totalorders.totalorders.created_atorders.created_atFrom (list)tablesJoin InnerJoinClustercustomerscustomersordersordersOperator =join predicateorders.customer_idorders.customer_id = customers.idcustomers.idorders.customer_id = customers.idWhereorders-only predicateOperator >=orders.created_at >= $1orders.created_atorders.created_at >= $1ParamRef$1orders.created_at >= $1

This is pretty simple. JoinCluster groups our two tables and their inner join condition. Inner joins can be reordered, giving the planner different ways to approach the same query. OutputClauses describes what to do with the rows we find. Here, we just need three columns back. Other queries might need sorting, grouping, aggregation, LIMIT, or even DISTINCT.

The planner still has to decide how to join those rows, which is the harder part.

Joining across shards

The planner uses our data topology to determine which parts of the query can run on the shards. We didn't show it earlier, but we specify this routing map in a data topology JSON file. For our two tables, each sharded by a hash of its own id, it looks like this (using postgres as the database name):

{
  "authoritative_shard_group": "metadata",
  "shard_indexes": {
    "xxhash_id": {
      "type": "xxhash",
      "columns": ["id"]
    }
  },
  "shard_groups": [
    {
      "uid": "metadata",
      "key_ranges": [{ "shard_uid": "shard1" }]
    },
    {
      "uid": "main_shards",
      "default_shard_index": "xxhash_id",
      "key_ranges": [
        { "shard_uid": "shard1", "end": "40" },
        { "shard_uid": "shard2", "start": "40", "end": "80" },
        { "shard_uid": "shard3", "start": "80", "end": "c0" },
        { "shard_uid": "shard4", "start": "c0" }
      ]
    }
  ],
  "databases": {
    "postgres": {
      "schemas": {
        "public": {
          "tables": {
            "customers": { "shard_group": "main_shards" },
            "orders": { "shard_group": "main_shards" }
          }
        }
      }
    }
  }
}

This configuration tells the routers that the customers and orders tables are each to be spread out across the four shards in the main_shards group, and each is sharded by a hash of its id column. When storing a new row, the router:

  • Extracts its id.
  • Runs it through a deterministic xxhash function.
  • Uses the resulting hash to choose a shard, based on the provided ranges.

Each of our four shards is responsible for a different range of hash values.

id 1id 2id 6id 32xxhashShard 1-40Shard 240-80Shard 380-c0Shard 4c0-

We said earlier that our choice of sharding keys would make things more complex. Here’s the first consequence. A customer’s orders can be spread across all four shards, while the customer row lives on just one. To join them, we have to bring the matching rows together in the router.

Our planner is smart, though, and begins updating the JoinCluster node by turning it into a plan for retrieving and joining the rows. For this query, it needs to separate the customers and orders work, identify filters each shard can apply, and choose how the router will match the results.

It starts by creating a Route plan node for each table. The work inside each route becomes a SQL query for Postgres to execute. The route also specifies how to choose the destination shards, so a single route can send its query to one shard or several.

JoinClusterJoinClustertablesJoinClustercustomersRoute: customersrouting: Scattercolumns: id, nameordersRoute: ordersrouting: Scatterfilter: created_at >= $1columns: total,created_at, customer_idjoin predicatejoin predicateorders.customer_id = customers.idorders.customer_id = customers.idjoin predicateorders.customer_id = customers.idorders-only predicateRoute: ordersrouting: Scatterfilter: created_at >= $1columns: total,created_at, customer_idorders.created_at >= $1Route: ordersrouting: Scatterfilter: created_at >= $1columns: total,created_at, customer_id

We now know we will need to send scatter-gather queries to fetch the necessary rows both for the customers and orders tables. However, there are several approaches to take for this join. Should we fetch customers first or orders first? Should we filter orders at the shard layer, or the router? Should the join be completed in a nested loop or with a hash table?

That's the next step of the planning process.

Joining across many Postgres instances

To fulfill JOIN orders ON orders.customer_id = customers.id, the router must match qualifying orders with their customers. There are several ways to approach this.

A nested-loop join is a concept that may be familiar to database internals enthusiasts. For everyone else, the idea is simple: take rows from one input and, for each one, search the other input for matches.

The router would first send a query to every shard to gather all customers rows. As rows start coming in, the router loops through each customer, one-by-one. For each, it makes a scatter-gather query to all shards, collecting all orders for that customer. Even a customer with no qualifying orders costs us four shard queries to find that out.

router
idname
······
······
······
······
SELECT id, name
FROM customers;
nametotalcreated_at
·········
·········
·········
·········
·········
·········
Shard 1

customers

2Alex

orders

idcust.
320732
320932
Shard 2

customers

6Jeff

orders

idcust.
320332
32082
Shard 3

customers

32Maja

orders

idcust.
32022
321
Shard 4

customers

1Leah

orders

idcust.
32016
16

First gather every customer from all four shards.

This is inefficient. There are better ways.

Hash join is another join technique which will suit our needs much better. Here, the idea is to build a lookup table from one input, then use it to find matches as rows arrive from the other. Similarly, the router will send out a request to fetch all customers rows upfront, but this time build a hash map in the router’s memory, keyed by customer ID. We can then do a single, large scatter-gather, asking for all orders rows created on or after September 9th.

router
idcust.totaldate
············
············
············
············
············
············
nametotalcreated_at
·········
·········
·········
·········
·········
·········
SELECT id, name
FROM customers;
keyname
······
······
······
······
Shard 1

customers

2Alex

orders

idcust.
320732
320932
Shard 2

customers

6Jeff

orders

idcust.
320332
32082
Shard 3

customers

32Maja

orders

idcust.
32022
321
Shard 4

customers

1Leah

orders

idcust.
32016
16

Fetch customers once, then look up each order’s customer inside the router.

Keeping the customer hash table in memory uses resources, but lets us match orders as they arrive without sending another query for each customer. The join rule compares the estimated costs of these approaches. Both perform the matching in the router, using rows supplied by Postgres on the shards.

The planner also considers reversing the inputs. customers first isn’t a requirement of either type of join.

These aren’t the planner’s only options. It also supports merge joins, batched nested-loop joins, and more. Its job is to find the most efficient plan for the query using the information available.

Choosing for this query

Which table the planner chooses to fetch first, and which join methodology to use, depends significantly on the table sizes.

Suppose we have 100,000 customers and 1,000,000 orders across the four shards. Using its default estimate for the date inequality, it expects roughly one third of the orders to qualify, about 300,000.

The Picasso diagram below visualizes under what scenarios the planner would choose various join algorithms and orderings. Change the row counts to see how the planner’s choice changes.

Orders
Customers
Nested loop · orders firstNested loop · customers firstHash join · build customersHash join · build ordersBatched nested loop

Let’s walk through why it chooses a hash join for these row counts.

With the nested loop approach we just described, that would mean 100,000 customer lookups (all of them) and then for each one, a scatter-gather across orders. That means fetching approximately 400,000 rows (100,000 customers and 300,000 orders), but spread across thousands of individual queries between the router and shards.

The hash join would fetch customers with four shard queries and qualifying orders with another four. Those eight queries would still transfer an estimated 400,000 rows into the router, including customers we might never match. This technique requires the additional step of building the hash table in memory.

The planner also tracks the number of distinct values in indexed columns, helping it estimate how many rows an equality filter or join will match. Using those estimates, it weighs the number of shard requests, rows transferred, rows processed, and rows held in memory. For these row counts, fetching the customer input and keeping it available for local lookups has a much lower estimated cost than making the repeated requests.

There’s another choice for the planner to make here. It could build the hash table from either input. The planner estimates a smaller memory requirement for the 100,000 customer rows than for 300,000 qualifying orders, so it chooses customers.

With those choices made, we can turn our initial plan into the final query plan.

OutputClausesCollapseoutputoutputcustomers.namecustomers.nameorders.totalorders.totalorders.created_atorders.created_atJoinClusterHashJoin (INNER)tablesHashJoin (INNER)customersRoute: customersScatter · commercebuild sidecolumns: id, nameordersRoute: ordersScatter · commerceprobe sidefilter: created_at >= $1columns: customer_id,total, created_atjoin predicatebuild key: customers.idprobe key: orders.customer_idorders.customer_id = customers.idbuild key: customers.idprobe key: orders.customer_idorders-only predicateRoute: ordersScatter · commerceprobe sidefilter: created_at >= $1columns: customer_id,total, created_atorders.created_at >= $1Route: ordersScatter · commerceprobe sidefilter: created_at >= $1columns: customer_id,total, created_at

The customers route builds the hash table, and the orders route supplies the rows to probe it. Both routes are scatter routes, meaning each sends its query to all four shards. Neither has a condition that narrows its request to particular shard-key values.

But what happens if that hash table doesn’t fit in memory?

Memory is really good and fast until you run out of it. If the hash table exceeds its memory budget, the router spills to disk. It partitions customers and orders by their join keys and joins corresponding partitions one at a time.

Illustrative rows and a small memory budget demonstrate spilling, not the memory needed by the article’s example. Shards sit below the router, the application above it, and temporary disk storage to the right of RAM inside the router. Blue customer rows travel up from the shards to fill the router’s hash table. At the memory budget they spill into three temporary disk partitions. Yellow orders arrive from the shards, pass through write buffers in RAM, then flush to disk partitions using the matching join keys. After both inputs are partitioned, the router loads one customer partition into RAM and probes it with the corresponding orders, returning matches before processing the next partition. Disk copies remain while the in-memory hash table is reused. The illustrative memory meter shows blue customer memory and yellow order write/read buffers and processing memory. Orders are streamed through the retained customer hash table; the entire order partition is not loaded into it. The meter is not a measured byte count or the exact memory-budget accounting counter, and excludes other router work. Each in-memory customer partition clears after its join finishes, before the next partition loads. Each yellow order carries customer_id, total, and created_at through RAM and disk. Joined results contain customers.name, orders.total, and orders.created_at. The timestamp is shown as a compact date: September 9, 2026; example times range from 12:00 to 17:00 UTC. All six matches travel upward to the application. The cycle repeats after a pause.

Whether the hash table fits in memory or spills to disk, the join still happens in the router. We now have a plan. Let’s execute it.

Execution

We still haven’t left the router, but soon we will progress to another node in the cluster.

If this were all happening on a single instance, Postgres could join the tables locally. Our plan, however, puts the join in the router, which doesn’t hold either table.

Thanks, sharding.

The rows the plan needs are spread out across four separate instances, so the router now has to send separate customer and order queries to the shards, then join the rows those queries return.

First, each of the four shards will need to execute the customer query:

SELECT customers.id, customers.name
FROM public.customers;

Once the router has built the customer hash table, each shard must then execute and return results for:

SELECT orders.customer_id, orders.total, orders.created_at
FROM public.orders
WHERE orders.created_at >= $1;

The order query uses the same $1 timestamp parameter, '2026-09-09 00:00:00+00', on all four shards. The first four requests are ready to leave the router.

Connecting to Postgres

Our customer request is headed to all four shards. Let's focus in on what happens on shard number 2. The router doesn’t connect to Postgres directly but rather it sends the request to a sidecar, a separate process running alongside Postgres.

This adds another step before our query reaches Postgres but also puts connection management in one place alongside each instance, where requests from different routers can share a pool of Postgres connections. The sidecar is responsible for making those connections safe to reuse.

If we had instead gone with a direct Postgres connection, the query would execute within a pre-established session. In our architecture, however, that session belongs to the router. Our shard requests need to carry the database, authenticated role, and session settings along with the query, so each part runs with the permissions and settings our application expects.

The router prepares a sequence of extended Postgres protocol messages: Parse, Bind, Execute, and Sync. This time, Parse contains our customer query rather than the original join.

It bundles these messages into the raw field of an ExecuteRequest, alongside the destination and session information. The request uses protobuf, which carries the Postgres messages directly as bytes.

Now it needs a way to send that request. The router maintains a pool of long-lived, bidirectional gRPC streams for each sidecar. It borrows an idle stream, opening one if needed, and sends the ExecuteRequest. The sidecar will return response chunks over that same stream.

Router
ExecuteRequestprotobuf · gRPC

raw Postgres messages

  1. Parse

    SELECT customers.id, customers.name
    FROM public.customers
  2. Bind

    parameters none

  3. Execute

    max_rows = 0

  4. Sync

SidecarShard 2 · primary

Our customer request has finally reached shard 2. Not Postgres yet, though.

Inside a Sidecar

We’ve reached the sidecar of shard 2, but we are not running in Postgres yet. Postgres’s process-per-connection model strikes again. Remember, each Postgres connection requires a separate server backend process, consuming memory even while idle.

If every router keeps its own pool of connections to every shard, those processes can quickly add up as we scale. The sidecar gives those routers a shared pool of warm Postgres connections to borrow.

Our customer query can use one of those connections, but it may still carry settings and identity from its previous use. This is where the session information we sent alongside the SQL comes in.

The Router borrows an available gRPC stream to shard 2’s sidecar. Our request carries SQL, Settings, and identity, shown together on the sidecar surface. An existing Postgres connection lifts out of the Sidecar’s separate pool. Its previous settings and identity give way to the state from our request. Settings are reconciled first, then identity. Only after both are ready does SQL move to Postgres, shard 2’s primary. Already matching state can be reused without changing it. Streams and Postgres connections are not permanently paired. Each pool has its own parallel lanes. Pool occupancy and timing are illustrative. Lifting and docking depict checkout and session preparation, not a new physical connection or transfer of the pool itself. The loop repeats the explanation without depicting a response or release.

SELECT customers.id, customers.name FROM public.customers
Settings
Session settings
Identity
Authenticated role
Settings
Previous useSession settings
Identity
Previous useAuthenticated role

The pool first looks for an available connection whose settings match our request. If it finds one, we can leave them alone and use it. Otherwise, the sidecar resets the previous settings to their defaults and applies ours.

The sidecar even checks the role of the session and confirms that it’s correct, preventing any role poisoning. Our query must run with our permissions, regardless of who last borrowed the connection.

The sidecar forwards the SQL and our customer query finally heads to Postgres.

Finally, Postgres

Our customer query now reaches Postgres.

SELECT customers.id, customers.name
FROM public.customers;

We spent all that time planning at the router level and now Postgres needs a plan of its own. This is a true Postgres instance, so it uses its own planner and statistics to choose how to read the customers table. It then executes that plan against the customers stored on shard 2.

As those rows become available, Postgres sends them back to the sidecar over the borrowed connection. We have the beginnings of our hash table!

Back to the router

The sidecar relays those rows to the router over the same gRPC stream that sent the request. They arrive in batches, so the router can start building the customer hash table while the shards are still returning rows.

Once all four customer requests have completed and the hash table is built, the hash join starts the orders Route plan node. Four more requests follow the transport and connection-pooling path we just walked through. This time, Postgres applies the September 9th filter on each shard and sends the qualifying orders back.

The returning rows produce matches! The router uses each order’s customer_id to find the matching customer in the hash table, then constructs a result row containing the customer’s name, order total, and creation timestamp. There’s a pretty nifty trick here that will save us some compute cycles.

When we requested the output fields from Postgres, we asked for the same text or binary formats the client selected for our original query. Those values are already encoded correctly. The router copies the encoded names, totals, and timestamps directly from the matching rows in memory to construct results. This saves us the work of decoding them into Go values just to re-encode them.

Router / Hash join50 bytes. One joined row.
customersid 32 · Maja
440000001A0002000000080000000000000020000000044D616A61
orderscustomer_id 32
440000003500030000000800000000000000200000000533302E303000000016323032362D30392D30392031323A30303A30302B3030
DataRow → driver7 new header bytes + 43 copied
44000000310003000000044D616A610000000533302E303000000016323032362D30392D30392031323A30303A30302B3030

The customer row contains ID 32 and Maja. The order row contains customer ID 32, total 30.00, and timestamp 2026-09-09 12:00:00+00. The two binary IDs are read for matching and stay out of the output. Copies of the encoded name, total, and timestamp, including each field-length prefix, move into their exact positions in the new message. These output values use text format. The new header is 44 00 00 00 31 00 03. Its length is 49, excluding the D tag, and its column count is 3. The complete DataRow is 50 bytes. Source bytes remain intact.

We don’t have to wait for every order request to finish either. The application's Postgres client can start receiving results while the shards are still returning orders.

Once all four shards have finished returning orders, our query's journey is complete. The application has its results, and the connection is ready for the next query.

Evaluation Engine

In some cases, we can't use this shortcut of directly copying bytes. In the query we've been following, every value in our output was already in the rows returned by the shards. The router could copy their encoded bytes straight into the result.

But what happens if, instead, we ask for each customer's average order total?

SELECT customers.name, AVG(orders.total) AS average_total
FROM customers
JOIN orders ON orders.customer_id = customers.id
WHERE orders.created_at >= $1
GROUP BY customers.id, customers.name;

This requires additional work from a component in the router that you might recognize as an internal Postgres component: The Eval Engine.

Remember, the orders for a single customer are spread across all four shards. A single shard can't calculate the full average on its own. We can't average the shard averages, either. One shard might have two orders for a customer while another has twenty.

Instead, the router rewrites the average into sums and counts that each shard calculates locally. When the results come back, the router combines them for each customer. The router's evaluation engine then divides the combined sum by the combined count to produce the customer's average.

The router rewrites AVG(orders.total) into SUM(total) and COUNT(total), then sends that work to four shards. Each shard calculates a sum and count for one illustrative customer group. For customer 32, Maja, shard 1 returns a sum of $100 and a count of 2; shard 2, $90 and 3; shard 3, $80 and 1; shard 4, $50 and 2. Counts include only non-null order totals. The router combines these into $320 and 8. Its evaluation engine divides $320 by 8 to produce a $40 average, returned to the application with Maja’s name. Averaging the four shard averages would incorrectly give $46.25. The panels separate conceptual steps, not physical plan operators. This close-up omits the customer join and other groups; each order matches one customer. Values, arrival order, and timing are illustrative. The completed calculation stays visible with reduced motion.

For our original query, however, the Eval Engine does not have much work to do.

A better topology

We’ve seen how much work the router does to join and calculate results across shards. Our choice of sharding keys made that work harder than it needed to be.

Sharding by primary keys wasn’t inherently the problem. We can keep using customers.id to distribute customers. The complexity came from distributing orders by orders.id, independently of the customers we needed to join them to.

If we shard orders by customer_id, using the same hashing and shard mapping as customers, each customer will live alongside their orders. Postgres could then perform the join locally.

customersshard key: ididnameordersshard key: customer_ididcustomer_idtotalShard 1customersordersShard 2customersordersShard 3customersordersShard 4customersorders2Alex6Jeff32Maja1Leah320732$30.00320332$70.0032022$25.0032016$40.0032082$15.00320932$20.00321$45.0016$60.00

Our query still reaches all four shards because it asks for recent orders across all customers. But now we send the join intact to each Postgres instance, which joins its local rows and returns the results. Four shard requests instead of eight, and no customer hash table for the router to build.

Customers remain sharded by customers.id. These are the same illustrative rows used earlier in the article. Orders now use customer_id with the same shard mapping as customers.id. The application sends the same query to the router. The router sends one joined query to each of the four shards. Postgres joins matching customers and qualifying orders locally. Shard 4 is queried too, but has no qualifying orders and returns no joined rows. The other shards return six joined rows in total, which the router forwards as they arrive.Both layouts return the same six results from the router to the application, then pause on the completed result before repeating. Moving the illustrated rows compares two layouts, not a live resharding procedure. Animation speed is not a benchmark.

  • Shard 1 holds customer 2 and selected orders 3202 for customer 2, 3208 for customer 2.
  • Shard 2 holds customer 6 and selected orders 3201 for customer 6.
  • Shard 3 holds customer 32 and selected orders 3207 for customer 32, 3203 for customer 32, 3209 for customer 32.
  • Shard 4 holds customer 1 and selected orders none.

We still need the authentication, transport, and connection management we just walked through. But the work of bringing separately fetched customers and orders together in the router disappears.

The fastest router join is the one we don’t have to run. Probably could’ve mentioned that a few thousand words ago.

See, easy?

Welcome to Neki.