In the post on bucketing a hot index I used yb_hash_code() to decide where a row lands. It works the other way round too. Put it in a WHERE clause and YugabyteDB will seek straight into the tablet which owns that slice of the hash space, so you can hand every worker of a batch job or a queue its own slice of the table and have them land on different nodes rather than all piling onto the same one. I had assumed something like WHERE yb_hash_code = rand(0, 8) would be enough to fan the workers out across the tservers, so this post checks what the planner actually does with it.

Glossary

  • Tablet - the shard YugabyteDB splits a table into, which lives on one node and is replicated to a couple of others
  • Tablet leader - the replica which actually serves a tablet’s reads and writes, ie: the node doing the work
  • Hash space - the 65,536 hash codes (0x0000 to 0xFFFF) a hash sharded row can land on, each tablet owns a contiguous chunk of them
  • yb_hash_code(...) - YSQL function which returns the DocDB hash code of its arguments, which lets you work out which tablet a row lives in
  • PgGate - the layer in each YSQL backend which turns a Postgres scan into read requests against the tablets

All the numbers below come from a disposable three node cluster running YugabyteDB 2026.1.2 in Docker, driven by run-benchmark.sh. The full output is on the benchmark results page.

How a table maps onto the hash space

Create a table with a hash primary key and six tablets, and YugabyteDB cuts the hash space into six equal ranges at floor(i * 65536 / 6), then spreads the leaders of those ranges across your nodes. You can see the result in yb_tablet_metadata, the same view you will be using later on to work out your slices.

CREATE TABLE jobs (
    id uuid DEFAULT gen_random_uuid(),
    created_at timestamptz NOT NULL,
    payload text NOT NULL,
    PRIMARY KEY (id HASH)
) SPLIT INTO 6 TABLETS;

SELECT start_hash_code, end_hash_code, leader
FROM yb_tablet_metadata
WHERE relname = 'jobs'
ORDER BY start_hash_code;
 start_hash_code | end_hash_code |    leader
-----------------+---------------+--------------
               0 |         10922 | ybfan-1:5433
           10922 |         21845 | ybfan-1:5433
           21845 |         32768 | ybfan-2:5433
           32768 |         43690 | ybfan-3:5433
           43690 |         54613 | ybfan-3:5433
           54613 |         65536 | ybfan-2:5433

The end of each range is exclusive, so a predicate of yb_hash_code(id) >= 10922 AND yb_hash_code(id) < 21845 covers just the second tablet.

worker 1 worker 2 worker 3 worker 4 worker 5 worker 6 WHERE yb_hash_code(id) >= lo AND yb_hash_code(id) < hi tablet 1 tablet 2 tablet 3 tablet 4 tablet 5 tablet 6 0-10921 10922-21844 21845-32767 32768-43689 43690-54612 54613-65535 0x0000 0xFFFF hash space, 65,536 codes node 1 node 2 node 3 leads tablets 1, 4 leads tablets 2, 5 leads tablets 3, 6
Each worker's range lines up with one tablet so it only ever talks to one leader, and with six of them running every node has a backend doing work rather than a single backend on a single node having to decode every row.

When a yb_hash_code range becomes a seek

When the arguments you pass to yb_hash_code() are exactly the hash key of a table or index, in the same order, the planner turns a range on it into bounds on the scan itself. DocDB is then only asked for the tablets which overlap that range, and EXPLAIN (ANALYZE, DIST) on the 2 million row jobs table shows the difference pretty clearly.

-- SELECT id, payload FROM jobs
 Seq Scan on jobs
   Storage Table Read Requests: 979
   Storage Table Read Ops: 1957
   Storage Table Rows Scanned: 2000000

-- ... WHERE yb_hash_code(id) >= 10922 AND yb_hash_code(id) < 21845
 Index Scan using jobs_pkey on jobs
   Index Cond: ((yb_hash_code(id) >= 10922) AND (yb_hash_code(id) < 21845))
   Storage Table Read Requests: 326
   Storage Table Read Ops: 326
   Storage Table Rows Scanned: 333453

-- ... WHERE yb_hash_code(id) >= 5000 AND yb_hash_code(id) < 16000
 Index Scan using jobs_pkey on jobs
   Index Cond: ((yb_hash_code(id) >= 5000) AND (yb_hash_code(id) < 16000))
   Storage Table Read Requests: 177
   Storage Table Read Ops: 329

-- ... WHERE yb_hash_code(id) % 6 = 1
 Seq Scan on jobs
   Filter: ((yb_hash_code(id) % 6) = 1)
   Rows Removed by Filter: 1666509
   Storage Table Rows Scanned: 2000000

The aligned range sends exactly one op per request, because there is only one tablet to ask. The range from 5000 to 16000 straddles the first two tablets and sends two ops per request, one to each, in parallel. The modulus reads all 2 million rows and throws away five sixths of them, because % 6 scatters the matching codes across the whole space and leaves no range for the planner to seek to. A modulus is still fine inside an index expression, which is how the bucketing post used it. However, as a predicate on a plain hash key it gives the planner nothing to turn into scan bounds, so it reads everything.

Reading the whole table

Before slicing anything by hand it is worth knowing that PgGate already does part of this fan-out itself. An unordered scan of a hash sharded table creates one operation per tablet and keeps min(2 * tservers, 16) of them in flight at once. The logic lives in PgDocReadOp::PopulateParallelSelectOps, next to a comment explaining that ordered scans need tablet order and so go one tablet at a time. However, it cannot spread the rows out again, and every one of them still comes back through a single Postgres backend on a single node, which has to decode each row and send it on to your client regardless of how many tablets it was read from.

I read every row of jobs with one reader doing a plain SELECT, then with 2, 3, 6 and 12 readers. Each reader owned an equal slice of the hash space and was connected round robin to one of the three nodes.

Readers Wall time vs 1 reader
1 2,884 ms 1.0x
2 2,054 ms 1.4x
3 1,281 ms 2.3x
6 1,370 ms 2.1x
12 1,387 ms 2.1x

The gain stops at three readers, one per node, and I would expect the same on your cluster. Past that point every node already has a backend running and serving its own tablets, so extra readers are just more backends fighting for the same CPUs. There is no point sizing a pool of 64 workers for a nine node cluster if the work is a plain read, since the node count is the limit.

There is also a yb_enable_parallel_scan_hash_sharded setting, off by default in 2026.1. Turning it on did not get me a parallel plan for this table, so for now slicing by hand is how you get your backends spread across the cluster.

A queue of workers

The other use is a job queue polled by many workers with FOR UPDATE SKIP LOCKED, a pattern which I covered for Postgres in an earlier post. The problem with a queue is 12 workers all going for the same rows at the same moment, so it is contention you are trying to spread out this time and bandwidth hardly comes into it.

The setup is a queue table with 1 million rows, 1 in 10 of them pending, and 12 workers spread over the three nodes for 30 seconds. Each worker claims 10 jobs per transaction and marks them done.

UPDATE queue SET status = 'done' WHERE id IN (
    SELECT id FROM queue q
    WHERE status = 'pending'
      AND yb_hash_code(id) >= :slice * 1024
      AND yb_hash_code(id) < (:slice + 1) * 1024
    LIMIT 10 FOR UPDATE SKIP LOCKED
);

I ran four variants of that, starting from the index you would probably write first.

Variant Index Slice Claims/s
a (status HASH, created_at ASC), oldest first none 93
b (id HASH) WHERE status = 'pending' none 129
c (id HASH) WHERE status = 'pending' random 1 of 6, one per tablet 367
d (id HASH) WHERE status = 'pending' random 1 of 64 1,003

No transaction failed in any run.

Variant (a) is the index I would have written first, and it is the worst of the four. yb_hash_code('pending') is 39657, so every pending entry in the index sits under one hash code in one tablet with one leader, and all 12 workers ask that tablet for the same 10 oldest rows. Hashing on id in (b) spreads the entries across six tablets, but without a slice every worker still starts its scan from the same place, so they keep racing each other for the same handful of rows. A random slice per transaction in (c) gives each worker a different starting point most of the time. Narrowing it to 1 of 64 in (d) makes two workers sharing a slice rare, so most polls find rows which no other worker is touching.

That is an 11x difference between (a) and (d) on the same cluster and the same data. Every node sat at its full two CPUs in all four runs, so (a) had exactly as much CPU as (d) and spent it on 12 workers fighting over the same 10 rows. With a replication factor of 3 on three nodes every node also holds a replica of every tablet, so the hot tablet in (a) did not show up as one hot node here, but on a larger cluster it would and that node would then hold back your whole queue.

It is also pretty easy to slice on the wrong thing here. You have to slice on the hash key of whichever index the scan actually ends up using, which is not necessarily the table’s primary key. I found this out by giving the queue table its index on (status HASH, created_at ASC) and filtering it on status plus a hash code range on id.

 Index Scan using queue_status on queue
   Index Cond: (status = 'pending'::text)
   Filter: ((yb_hash_code(id) >= 10922) AND (yb_hash_code(id) < 21845))

That index is placed by yb_hash_code(status), so the hash code of id tells DocDB nothing about where to look and the range becomes a plain filter which runs row by row once the rows are back in Postgres. That is why (b) to (d) slice on id, since their index is hashed on id.

I also had to pin the partial index with a pg_hint_plan hint, because FOR UPDATE needs the table row anyway and so the planner preferred to scan the primary key and filter on status, a plan which walks past every row the workers have already marked done and by the end of a run meant reading 12,000 rows to find 10.

/*+ IndexScan(q queue_pending) */ UPDATE queue SET status = 'done' WHERE id IN (...);

Slices after the tablets split

Your tablet boundaries will not stay at sixths, since tablets split as they grow and the split points land wherever the data is. For a batch job it is worth reading yb_tablet_metadata when the job starts and handing out one slice per tablet, or per group of tablets led by the same node, which keeps each reader on one leader. Nothing breaks when a tablet splits mid-run. The slice just straddles two tablets and PgGate sends two ops instead of one, so that reader talks to two leaders for the rest of the job and gets the same rows.

For a queue you do not need to track tablets at all. A fixed number of slices comfortably larger than your worker count, eg: 64 slices of 1,024 codes, keeps workers apart regardless of how the tablets split, and every slice is still a seek into at most a couple of tablets.

Takeaway

  • A range on yb_hash_code() is a seek only when its arguments are exactly the hash key of the table or index being scanned, otherwise it is a row by row filter
  • Use >= and < ranges rather than a modulus, since a modulus cannot be turned into scan bounds and reads everything
  • For bulk reads, one slice per node got me 2.3x over a single SELECT, and going past one per node did not help
  • For a SKIP LOCKED queue, index on the job’s hash key rather than on status, and give every poll a random slice from many more slices than you have workers
  • Read yb_tablet_metadata when you need slices to line up with tablets, and do not worry when a split makes one straddle

TL;DR - slicing on yb_hash_code does fan the work out, but rand(0, 8) is far too few slices, and going from 6 to 64 was worth another 2.7x on its own.