This guide uses a range shard index on tenant_id to place one large tenant on a dedicated Neki shard while every other tenant shares a second shard, then moves the dedicated shard to a larger configuration profile while reads continue. The range key bounds are hexadecimal, which determines where the boundaries go.
What you will build
A multi-tenant documents table where tenant 42 lives alone on one shard, other tenants live on another, and the dedicated shard runs a larger cluster size than the rest.
tenant_id (range index, bounds in hex)
1..41 -> sh2 (default profile, PS-10)
42 -> sh3 (large-tenant profile, PS-20) 0x2a <= tenant_id < 0x2b
43.. -> sh2
AI Prompt
Using the PlanetScale CLI (pscale 0.344.0) and psql, isolate one large tenant on its own shard in a Neki database.
Requirements:
- Create the Neki database with --engine neki, --cluster-size PS-10-AWS-ARM-NEKI and --replicas 2, add two shards, and wait until SELECT count(*) FROM __neki.list_shards() returns 3.
- Use a shard index of type "range" on tenant_id. The start and end values in key_ranges are hexadecimal strings of the integer key. Tenant 42 is 2a, so the dedicated range is start "2a" and end "2b".
- One shard may appear in more than one key range of a group. Use three ranges: shard A below 2a, shard B from 2a to 2b, and shard A again from 2b upward.
- Apply the topology before loading any rows. A topology change does not move rows that already exist, and the router then returns no rows for affected keys without an error.
- Prove isolation by counting rows on each shard with SET __neki.shard, not only through the router.
- Create a second configuration profile with pscale branch config-profile create and move the dedicated shard to it with pscale branch shard assign. Treat the change as finished when the profile state is ready, not when the shard shows ready.
- Read the large tenant continuously during the change and log every result.
- Run the verification step below before finishing.
Prerequisites
A PlanetScale organization with access to the Neki Platform Preview. Create an account if you do not have one, and see Neki availability and access for how an organization administrator joins the preview.
pscale0.344.0 with a service token that can create databases,psql(versions 18.6 and 16.15 both produced the documented output), andjq:
export PLANETSCALE_SERVICE_TOKEN_ID="<token id>"
export PLANETSCALE_SERVICE_TOKEN="<token>"
export PLANETSCALE_ORG="<organization name>"
pscale version
- A Neki database with three shards and an empty
documentstable:
pscale database create tenants --engine neki --org "$PLANETSCALE_ORG" \
--region us-east --cluster-size PS-10-AWS-ARM-NEKI --replicas 2 --wait
pscale role create tenants main admin --inherited-roles postgres --org "$PLANETSCALE_ORG" --format json > role.json
export PGHOST=$(jq -r .access_host_url role.json) PGUSER=$(jq -r .username role.json) \
PGPASSWORD=$(jq -r .password role.json) PGPORT=5432 PGDATABASE=postgres PGSSLMODE=require
rm role.json
until psql -At -c "SELECT 1" >/dev/null 2>&1; do sleep 5; done
pscale branch shard create tenants main --config-profile default --count 2 --org "$PLANETSCALE_ORG"
until [ "$(psql -At -c 'SELECT count(*) FROM __neki.list_shards()')" = "3" ]; do sleep 10; done
for u in $(psql -At -c "SELECT uid FROM __neki.list_shards()"); do
until psql -At -c "SET __neki.shard='$u';" -c "SELECT 1" >/dev/null 2>&1; do sleep 5; done
done
psql -q -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE documents (
doc_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
title text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX documents_tenant_idx ON documents (tenant_id);
SQL
SHARD_1=$(psql -At -c "SELECT uid FROM __neki.list_shards() WHERE name='sh1'")
SHARD_2=$(psql -At -c "SELECT uid FROM __neki.list_shards() WHERE name='sh2'")
SHARD_3=$(psql -At -c "SELECT uid FROM __neki.list_shards() WHERE name='sh3'")
Note: Output samples are trimmed to the relevant lines, and shard ids differ per database. Each shard bills as a three-node cluster.
PS-10-AWS-ARM-NEKIlists at $30 per month per shard andPS-20-AWS-ARM-NEKIat $50, prorated to the millisecond. Current rates are on the Neki pricing page. Delete the database when you finish withpscale database delete tenants --force --org "$PLANETSCALE_ORG".
1. Apply the range topology to the empty table
Tenant 42 is 2a in hexadecimal. The dedicated range runs from 2a up to, but not including, 2b. The first and third ranges both point at the same shard.
cat > data-topology.json <<JSON
{
"shard_indexes": { "range_tenant_id": { "type": "range", "columns": ["tenant_id"] } },
"shard_groups": [
{ "uid": "$SHARD_1", "key_ranges": [ { "shard_uid": "$SHARD_1" } ] },
{
"uid": "tenant_data",
"default_shard_index": "range_tenant_id",
"key_ranges": [
{ "shard_uid": "$SHARD_2", "end": "2a" },
{ "shard_uid": "$SHARD_3", "start": "2a", "end": "2b" },
{ "shard_uid": "$SHARD_2", "start": "2b" }
]
}
],
"databases": { "postgres": { "schemas": { "public": { "tables": { "documents": { "shard_group": "tenant_data" } } } } } },
"default_shard_group": "$SHARD_1",
"authoritative_shard_group": "$SHARD_1"
}
JSON
pscale branch data-topology update tenants main --org "$PLANETSCALE_ORG" --format json < data-topology.json | jq -c '.data_topology | keys'
sleep 10
Real output:
["authoritative_shard_group","databases","default_shard_group","shard_groups","shard_indexes"]
The topology was accepted with the same shard in two key ranges.
2. Load tenants
Insert five documents for each of tenants 1 to 100, then 2,000 more for tenant 42:
psql -At -c "INSERT INTO documents (tenant_id, title) SELECT t, 'doc ' || t || '-' || n FROM generate_series(1,100) t, generate_series(1,5) n;"
psql -At -c "INSERT INTO documents (tenant_id, title) SELECT 42, 'bulk doc ' || n FROM generate_series(1,2000) n;"
Real output:
INSERT 0 500
INSERT 0 2000
3. Confirm placement on each shard
Count rows, distinct tenants, and the tenant id range on each shard directly:
for u in "$SHARD_2" "$SHARD_3" "$SHARD_1"; do
echo -n "$u (rows, distinct tenants, min, max): "
psql -At -c "SET __neki.shard='$u';" -c "SELECT count(*), count(DISTINCT tenant_id), min(tenant_id), max(tenant_id) FROM documents;" | tail -1
done
echo -n "tenant 42 via router: "; psql -At -c "SELECT count(*) FROM documents WHERE tenant_id = 42;"
Real output:
shfcr2aair7m4c (rows, distinct tenants, min, max): 495|99|1|100
shqnyqd0q1ia4n (rows, distinct tenants, min, max): 2005|1|42|42
sh0713ei63w0gj (rows, distinct tenants, min, max): 0|0||
tenant 42 via router: 2005
The dedicated shard holds 2,005 rows and exactly one tenant. The shared shard holds the other 99 tenants. The authoritative shard holds none. Tenant 42 has 2,005 rows because the first load added 5 and the second added 2,000.
Check the routing for a query that names a regular tenant and the large tenant:
psql -At -c "EXPLAIN (NEKI_PLAN, COSTS OFF, FORMAT TEXT) SELECT * FROM documents WHERE tenant_id IN (7, 42);" | head -6
Collapse
-> Route [IN]
Query: SELECT documents.doc_id, documents.tenant_id, documents.title, documents.created_at FROM public.documents WHERE tenant_id = ANY($1)
ShardGroup: tenant_data
Values: $1
4. Move the dedicated shard to a larger profile
Create a profile with a larger cluster size and assign only the dedicated shard to it. The shard id for pscale branch shard assign is the id field of the shard list, not the name.
pscale branch config-profile create tenants main large-tenant --cluster-size PS-20-AWS-ARM-NEKI --replicas 2 \
--org "$PLANETSCALE_ORG" --format json | jq -c '{name, state, cluster_size}'
DEDICATED_ID=$(pscale branch shard list tenants main --org "$PLANETSCALE_ORG" --format json | jq -r --arg n "$SHARD_3" '.[] | select(.name==$n) | .id')
Real output:
{"name":"large-tenant","state":"ready","cluster_size":"PS_20_AWS_ARM_NEKI"}
Start a reader that queries tenant 42 every two seconds and logs the result, then assign the shard:
cat > probe.sh <<'EOF'
#!/bin/bash
while [ ! -f stop.flag ]; do
out=$(psql -At -c "SELECT count(*) FROM documents WHERE tenant_id = 42;" 2>&1 | tail -1 | cut -c1-120)
echo "$(date -u +%T) $out" >> probe.log
sleep 2
done
EOF
chmod +x probe.sh
rm -f stop.flag probe.log
nohup ./probe.sh >/dev/null 2>&1 &
pscale branch shard assign tenants main "$DEDICATED_ID" --config-profile large-tenant --org "$PLANETSCALE_ORG" --format json
Real output:
[
{
"id": "6oqj3g1c8wji",
"status": "assigned"
}
]
The shard list shows ready: true almost immediately, so wait on the profile state instead:
for i in $(seq 1 60); do
st=$(pscale branch config-profile list tenants main --org "$PLANETSCALE_ORG" --format json | jq -r '[.[].state] | join(",")')
echo "$(date -u +%T) profiles: $st"
[ "$st" = "ready,ready" ] && break
sleep 20
done
touch stop.flag; sleep 3
pscale branch config-profile list tenants main --org "$PLANETSCALE_ORG"
Real output, trimmed. The profiles reported applying,applying for about three and a half minutes after the assignment in this run. Other runs of the same steps took about eight and about ten minutes:
21:11:51 profiles: applying,applying
...
21:14:55 profiles: applying,applying
21:15:15 profiles: ready,ready
NAME STATE DEFAULT ARCHITECTURE CLUSTER SIZE REPLICAS SHARDS
default ready Yes aarch64 PS_10_AWS_ARM_NEKI 2 2
large-tenant ready No aarch64 PS_20_AWS_ARM_NEKI 2 1
Verify the result
Check that the reader never failed, that the data is intact on the dedicated shard, and that the shard now belongs to the larger profile:
echo "reader outcomes:"; cut -d' ' -f2- probe.log | sort | uniq -c
pscale branch shard list tenants main --org "$PLANETSCALE_ORG" --format json | jq -c '.[] | {display_name, configuration_profile}'
psql -At -c "SET __neki.shard='$SHARD_3';" -c "SELECT count(*), count(DISTINCT tenant_id) FROM documents;" | tail -1
Real output (77 reads during the change):
reader outcomes:
77 2005
{"display_name":"sh1","configuration_profile":"default"}
{"display_name":"sh2","configuration_profile":"default"}
{"display_name":"sh3","configuration_profile":"large-tenant"}
2005|1
Expected: every logged read returned the same count for tenant 42 and none returned an error, sh3 reports the large-tenant profile, and the dedicated shard still holds only tenant 42. The number of reads depends on how long the change takes: 77 reads in the run above 235 in a run where the change took about eight minutes, and 585 in a run where it took about ten. Only reads were tested during the change; writes were not.
How it works
A shard index of type range routes an integer key by its value, and key range bounds are hexadecimal strings of that value, so tenant 42 sits in the range from 2a to 2b. A key range names a shard, and the topology accepted the same shard in two ranges of one group, which lets the shared shard cover both sides of the dedicated range. Configuration profiles set the cluster size for every shard assigned to them, so moving one shard to a different profile changes only that shard's hardware. The data topology reference describes shard indexes and key ranges, and cluster configuration covers profiles and shards.
Common issues
Range bounds are hexadecimal
A first attempt used "end": "42" and "start": "42" intending tenant 42. Inserting tenants 1 to 100 placed tenants 1 to 65 on the first shard and 66 to 100 on the second:
shfb4tbu7t104l: 325|1|65
shbbxms99wl674: 175|66|100
42 was read as hexadecimal, which is 66 in decimal. Write bounds in hexadecimal: 2a for 42, and 2b for the first value after it.
Changing the topology after loading rows
Topology changes do not move rows that already exist. After changing from the 42 split above to the 2a/2b ranges with 500 rows already loaded, five tenant 42 rows were still on the first shard while the router sent tenant 42 queries to the second shard:
shard 1 rows for tenant 42, queried directly: 5
tenant 42 via router: 0
scatter count(*): 500
No error was returned. Apply the topology to an empty table, or move rows with a workflow such as Reshard.
The shard shows ready while the profile is still applying
pscale branch shard list reported large-tenant ready=true two seconds after the assignment. pscale branch config-profile list then showed applying for both profiles until the change completed. Wait on the profile STATE. pscale branch config-profile changes list tenants main large-tenant shows the change as applying and then completed.
Statements fail with "no healthy sidecars" right after the shards appear
In one run the router listed all three shards, and the first CREATE TABLE then failed:
ERROR: no healthy sidecars available for shard shaenzcm88g3e0 with type SIDECAR_TYPE_PRIMARY
About a minute later a direct SELECT 1 against each shard succeeded and DDL worked. The prerequisites wait for that direct check on every shard before creating tables, so the error does not occur there.
Next steps
- Reshard a table that already has rows: see "Reshard a Live Table onto New Neki Shards with the PlanetScale CLI".
- Add a column to a sharded table without blocking writes: see "Run an Online Schema Change Across Neki Shards with the PlanetScale CLI".
- To isolate a tenant in your own database, start from the Neki data topology reference and cluster configuration.
verification:
status: verified
tested_at: "2026-10-12"
product_version: "pscale 0.344.0; Neki PostgreSQL 18.6 (Platform Preview)"
command: "count rows and distinct tenants on the dedicated shard with SET __neki.shard, and read the reader log and shard profile after the profile change"
expected_result: "The dedicated shard holds only tenant 42, every reader result is the same count with no errors, and the shard reports the large-tenant profile. The number of reads varies with change duration."