Partitioning & Sharding Strategies
Scaling databases horizontally by dividing data across servers.
Breaking Through the Single-Server Ceiling
When your database grows beyond what a single server can handle, you need to divide your data across multiple servers. Partitioning splits tables within a single database instance, while sharding distributes data across multiple database instances. Both techniques transform queries like "scan 1 billion rows" into "scan 100 million rows on each of 10 partitions in parallel." This lesson covers range, list, and hash partitioning strategies, horizontal vs vertical partitioning, and production sharding patterns: geography-based (usually forced by data residency law), tenant-based (Slack and other B2B SaaS shard by workspace), and hash-based (Cassandra and DynamoDB). You'll learn how to route queries to the correct shard, handle cross-shard joins, and when to use each strategy. Instagram is worth studying separately: it shards PostgreSQL into several thousand logical shards, implemented as schemas, that map onto a much smaller number of physical servers, so growing the cluster means moving schemas rather than re-bucketing rows.
Try it locally (PostgreSQL 16)
You can reproduce the examples in this lesson on your own machine. Everything shown here was verified against this container.
# Plain PostgreSQL 16 is all this lesson needs. docker run -d --name pg-demo \ -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \ -p 5432:5432 postgres:16-alpine # The partitioning half of the lesson lives in one database: docker exec -it pg-demo psql -U demo -d demo -c "CREATE DATABASE ecommerce" docker exec -it pg-demo psql -U demo -d ecommerce # The sharding half needs several databases on this one server, standing in # for what would be separate machines in production. Create them all now: docker exec -i pg-demo psql -U demo -d demo <<'EOF' CREATE DATABASE users_us; CREATE DATABASE users_eu; CREATE DATABASE users_asia; CREATE DATABASE routing; CREATE DATABASE saas_shard1; CREATE DATABASE saas_shard2; CREATE DATABASE saas_shard3; CREATE DATABASE users0; CREATE DATABASE users1; CREATE DATABASE users2; CREATE DATABASE shard0; CREATE DATABASE shard1; CREATE DATABASE tweets_shard0; CREATE DATABASE tweets_shard1; CREATE DATABASE tweets_shard2; CREATE DATABASE tweets_shard3; EOF # Each example creates its own tables; the schemas are small enough that # they are shown inline where they are used. # Check they are all there. Inside psql, \l lists databases and \l+ adds # size on disk. From the shell, query the catalog directly: docker exec -it pg-demo psql -U demo -d demo \ -c "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname;" # datname # --------------- # demo # ecommerce # postgres # routing # saas_shard1 # ... 12 more ... # datistemplate = false drops template0 and template1, the templates every # new database is cloned from, which you almost never want to see listed. # If a GUI client shows only one database, it is scoped to the one in its # connection URL rather than missing the others. In DBeaver: Edit Connection # -> PostgreSQL tab -> tick "Show all databases", then reconnect. # Clean up everything: docker rm -f pg-demo
Table Partitioning: Divide and Conquer Within One Database
Table partitioning splits a large table into smaller physical pieces (partitions) while maintaining a single logical table. The database automatically routes queries to the relevant partitions, dramatically improving performance for large datasets.
Partition Pruning
Figure 1: The application names one table. The planner proves from the WHERE clause which partitions cannot contain a matching row and never opens them. The row counts shown are the real ones from the 450,000-row sample table you build a few sections below, so you can check this picture against your own query plans.
What partitioning buys you
- Pruning - a query filtered on the partition key opens only the partitions that can match, so scan cost tracks the slice you asked for rather than the size of the table
- Smaller indexes - each partition carries its own, so an index stays shallow and is far likelier to sit in memory
- Cheap retention - dropping a month is DROP TABLE on one partition, a metadata operation, instead of a DELETE that rewrites rows and leaves bloat behind
- Parallelism - the planner can scan several partitions at once, which a single heap cannot do as readily
Range Partitioning
Range partitioning divides data based on value ranges (dates, IDs, prices). This is the most common strategy for time-series data, where queries typically filter by date ranges.
Creating Range Partitions (PostgreSQL)
Run this and every other partitioning statement on this page in theecommerce database from the setup block above (psql -U demo -d ecommerce), in the defaultpublic schema. The sharding sections later on use the separate per-shard databases instead, and say so where they do.
-- Create parent partitioned table
CREATE TABLE orders (
order_id BIGSERIAL,
customer_id INTEGER NOT NULL,
order_date DATE NOT NULL,
total_amount DECIMAL(10, 2),
status VARCHAR(20)
) PARTITION BY RANGE (order_date);
-- Create partitions for each year
CREATE TABLE orders_2022 PARTITION OF orders
FOR VALUES FROM ('2022-01-01') TO ('2023-01-01');
CREATE TABLE orders_2023 PARTITION OF orders
FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');
CREATE TABLE orders_2024 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
-- Create default partition for future dates
CREATE TABLE orders_default PARTITION OF orders DEFAULT;Load Some Data
Every partitioning result on this page comes from this exact table, so run this before the examples below. It is deliberately free of random(): you should get the same row counts printed here, which means you can check the query plans against yours.
-- Seed the partitions so the examples below have something to prune.
-- 450,000 orders spread evenly over 2022-01-01 .. 2024-12-31 (1096 days).
-- Deterministic: no random(), so you get exactly the row counts shown.
INSERT INTO orders (customer_id, order_date, total_amount, status)
SELECT
(g % 50000) + 1,
DATE '2022-01-01' + (g % 1096),
ROUND((10 + (g * 37 % 49000) / 100.0)::numeric, 2),
(ARRAY['completed','pending','shipped','cancelled'])[1 + (g % 4)]
FROM generate_series(0, 449999) g;
ANALYZE orders;
SELECT tableoid::regclass AS partition, count(*)
FROM orders GROUP BY 1 ORDER BY 1;Expected Output:
partition | count -------------+-------- orders_2022 | 150015 orders_2023 | 149925 orders_2024 | 150060 (3 rows)
Using Range Partitions with Python
import psycopg
from datetime import datetime
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Insert works transparently - database routes to correct partition
cursor.execute("""
INSERT INTO orders (customer_id, order_date, total_amount, status)
VALUES (%s, %s, %s, %s)
""", (12345, datetime(2024, 6, 15), 299.99, 'completed'))
conn.commit()
# Query with date filter - partition pruning in action
cursor.execute("""
SELECT COUNT(*), AVG(total_amount)
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'
""")
count, avg_amount = cursor.fetchone()
print(f"2024 orders: {count}, Average: ${avg_amount:.2f}")
# EXPLAIN shows partition pruning
cursor.execute("""
EXPLAIN (ANALYZE, VERBOSE)
SELECT * FROM orders
WHERE order_date BETWEEN '2024-06-01' AND '2024-06-30'
""")
for line in cursor.fetchall():
print(line[0])Expected Output:
2024 orders: 150061, Average: $254.66 Seq Scan on public.orders_2024 orders (cost=0.00..3431.91 rows=12123 width=31) (actual time=0.009..5.301 rows=12301 loops=1) Output: orders.order_id, orders.customer_id, orders.order_date, orders.total_amount, orders.status Filter: ((orders.order_date >= '2024-06-01'::date) AND (orders.order_date <= '2024-06-30'::date)) Rows Removed by Filter: 137760 Planning Time: 0.184 ms Execution Time: 5.646 ms This is partition pruning made visible. The table holds 450,000 seeded rows across three partitions (150,061 of them in 2024 once this script's own INSERT lands), but the plan names exactly one: "Seq Scan on public.orders_2024". The planner proved from the WHERE clause that no other partition could contain a matching row, so the others were never opened. Note what pruning does NOT do: inside orders_2024 it still scanned the whole partition and threw away 137,760 rows to find 12,301. Partitioning narrowed the search to a year; only an index on order_date would narrow it to a month. Your cost estimate and timings will differ slightly (cost estimates move with ANALYZE's sample), but the row counts are deterministic: same seed, same 12,301 and 137,760.
Partition Maintenance
A range-partitioned table is not something you create once and forget. It needs partitions to exist before the rows that belong in them arrive, and it needs old partitions retired once they age out of the retention window. Both jobs are small enough to run from a cron job or scheduled task, and both are where partitioning schemes usually go wrong in production.
The script below does one of each against the table you just seeded. The create_monthly_partitions() function provisions twelve monthly partitions for next year and is safe to re-run, andarchive_and_drop_partition() retires the oldest year by detaching it, copying it into an archive schema, and dropping the original.
# Automated partition maintenance.
from contextlib import suppress
import psycopg
DSN = "host=localhost dbname=ecommerce user=demo password=demo"
def create_monthly_partitions(year, month_count=12):
"""Create monthly partitions for a year. Safe to re-run."""
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
for month in range(1, month_count + 1):
start = f"{year}-{month:02d}-01"
end = f"{year + 1}-01-01" if month == 12 else f"{year}-{month + 1:02d}-01"
name = f"orders_{year}_{month:02d}"
# Check first so the log tells the truth. CREATE TABLE IF NOT
# EXISTS alone would let the loop print "Created" every month
# forever, whether or not it created anything.
cur.execute("SELECT to_regclass(%s)", (name,))
if cur.fetchone()[0] is not None:
print(f"already exists: {name}")
continue
cur.execute(f"""
CREATE TABLE {name} PARTITION OF orders
FOR VALUES FROM ('{start}') TO ('{end}')
""")
print(f"created: {name}")
def archive_and_drop_partition(partition_name):
"""Detach a partition, copy it into the archive schema, then drop it."""
# DETACH ... CONCURRENTLY cannot run inside a transaction block, so this
# connection is autocommit. See the caveat below about default partitions.
with psycopg.connect(DSN, autocommit=True) as conn, conn.cursor() as cur:
cur.execute("CREATE SCHEMA IF NOT EXISTS archive")
# Detach FIRST. If you copy while the partition is still attached,
# rows inserted between the copy and the detach are lost.
cur.execute(f"ALTER TABLE orders DETACH PARTITION {partition_name}")
cur.execute(f"CREATE TABLE archive.{partition_name} "
f"AS SELECT * FROM {partition_name}")
cur.execute(f"SELECT count(*) FROM archive.{partition_name}")
n = cur.fetchone()[0]
cur.execute(f"DROP TABLE {partition_name}")
print(f"archived {n:,} rows, dropped {partition_name}")
create_monthly_partitions(2025)
with suppress(psycopg.errors.UndefinedTable):
archive_and_drop_partition("orders_2022")Expected Output:
created: orders_2025_01 created: orders_2025_02 created: orders_2025_03 created: orders_2025_04 created: orders_2025_05 created: orders_2025_06 created: orders_2025_07 created: orders_2025_08 created: orders_2025_09 created: orders_2025_10 created: orders_2025_11 created: orders_2025_12 archived 150,015 rows, dropped orders_2022 # Second run: the to_regclass check reports the truth instead of claiming # to have created twelve partitions all over again. already exists: orders_2025_01 already exists: orders_2025_02 ... already exists: orders_2025_12
Three things this script is careful about
Each of these bites in production, and none of them is obvious from reading the happy-path code.
- Detach before you copy. Copying an attached partition and detaching afterwards loses any row inserted in between. Detaching first freezes the partition, so the copy is complete by construction.
DETACH ... CONCURRENTLYhas two conditions. It takes a much weaker lock, so it does not block writers to the rest of the table, but it cannot run inside a transaction block (henceautocommit=Trueabove), and PostgreSQL refuses it outright while aDEFAULTpartition exists:Our table hasERROR: cannot detach partitions concurrently when a default partition existsorders_default, so the code above uses plainDETACH, which holdsACCESS EXCLUSIVEon the parent for its duration. Drop the default partition first if you need the concurrent form.- A
DEFAULTpartition is not the safety net it looks like. Once a row for an unpartitioned range lands in it, you can no longer create that range's real partition:You have to move those rows out by hand first. So creating partitions ahead of time is not optional hygiene: it is the thing that keeps the default partition empty and your options open.ERROR: updated partition constraint for default partition "orders_default" would be violated by some row
List Partitioning
List partitioning divides data based on discrete values (country codes, product categories, status values). Each partition claims a whole list of values rather than a single one, which is what makes it the natural fit for grouping related keys: the example below puts all of North America in one partition and all of Europe in another.
Creating List Partitions
-- Partition by country (geography-based)
CREATE TABLE users (
user_id BIGSERIAL,
email VARCHAR(255),
country_code CHAR(2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY LIST (country_code);
-- North America partition
CREATE TABLE users_north_america PARTITION OF users
FOR VALUES IN ('US', 'CA', 'MX');
-- Europe partition
CREATE TABLE users_europe PARTITION OF users
FOR VALUES IN ('GB', 'DE', 'FR', 'IT', 'ES', 'NL');
-- Asia partition
CREATE TABLE users_asia PARTITION OF users
FOR VALUES IN ('CN', 'JP', 'IN', 'KR', 'SG');
-- Default partition for other countries
CREATE TABLE users_other PARTITION OF users DEFAULT;Querying List Partitions
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Insert users from different countries
users = [
('alice@example.com', 'US'),
('bob@example.co.uk', 'GB'),
('chen@example.cn', 'CN'),
('diego@example.mx', 'MX')
]
for email, country in users:
cursor.execute("""
INSERT INTO users (email, country_code)
VALUES (%s, %s)
""", (email, country))
conn.commit()
# Query European users - scans only users_europe partition
cursor.execute("""
SELECT COUNT(*) as euro_users
FROM users
WHERE country_code IN ('GB', 'DE', 'FR', 'IT', 'ES')
""")
euro_count = cursor.fetchone()[0]
print(f"European users: {euro_count}")
# Get partition distribution
cursor.execute("""
SELECT
tableoid::regclass AS partition_name,
COUNT(*) as user_count
FROM users
GROUP BY tableoid
-- The tiebreaker matters: three partitions hold one row each, and
-- without a second sort key their relative order is unspecified.
ORDER BY user_count DESC, partition_name
""")
print("\nUsers per partition:")
for partition, count in cursor.fetchall():
print(f" {partition}: {count} users")Expected Output:
European users: 1 Users per partition: users_north_america: 2 users users_europe: 1 users users_asia: 1 users Four inserts, no partition named anywhere in the INSERT. PostgreSQL read country_code and routed each row itself. Notice what is missing: users_other, the DEFAULT partition, does not appear. It holds no rows, and GROUP BY only reports groups that exist, so an empty partition is invisible here. That is worth internalising before you use this query to audit a live table: absence in this output means "no rows", not "no partition". The default partition still earns its place. Without it a user from, say, 'BR' is rejected outright rather than stored: ERROR: no partition of relation "users" found for row LIST partitioning makes you enumerate every value you will ever accept, so a default partition is the difference between entering a new market being a data-modelling task and being an outage.
When a New Value Shows Up
That users_other default is a trap in waiting
The default keeps that BR signup from being rejected, but it does not make the problem go away, and this is where the maintenance caveat from the previous section bites hardest. Range partitioning at least lets you provision next year's partitions on a schedule. A list key has no schedule: you find out you needed aBR partition when a Brazilian signs up, and by then the row is already sitting in users_other, which is precisely what blocks you from creating the partition you now want.
-- A Brazilian signs up. No BR partition exists, so the row lands in the
-- default and nothing looks wrong yet.
INSERT INTO users (email, country_code) VALUES ('bruno@example.br', 'BR');
-- Order by the partition NAME, not by tableoid: ORDER BY 1 here would sort
-- by OID, which is creation order rather than anything you can predict.
SELECT tableoid::regclass AS partition, email, country_code
FROM users ORDER BY tableoid::regclass::text, email;Expected Output:
partition | email | country_code ---------------------+-------------------+-------------- users_asia | chen@example.cn | CN users_europe | bob@example.co.uk | GB users_north_america | alice@example.com | US users_north_america | diego@example.mx | MX users_other | bruno@example.br | BR (5 rows)
The row is parked in the catch-all, and the table still answers every query correctly, so nothing prompts you to act. The bill arrives the day you try to give South America a real partition:
-- Months later, South America has earned its own partition.
CREATE TABLE users_south_america PARTITION OF users
FOR VALUES IN ('BR', 'AR', 'CL');Expected Output:
ERROR: updated partition constraint for default partition "users_other" would be violated by some row
PostgreSQL will not let the new partition claim a value the default already holds a row for. The way out is to detach the default, create the real partition, move the rows back through the parent so they route themselves, and reattach, all in one transaction so no writer ever sees the table without its catch-all:
-- Detach the default, create the real partition, move the rows back through
-- the parent so they route themselves, then reattach.
BEGIN;
ALTER TABLE users DETACH PARTITION users_other;
CREATE TABLE users_south_america PARTITION OF users
FOR VALUES IN ('BR', 'AR', 'CL');
INSERT INTO users SELECT * FROM users_other
WHERE country_code IN ('BR', 'AR', 'CL');
DELETE FROM users_other
WHERE country_code IN ('BR', 'AR', 'CL');
ALTER TABLE users ATTACH PARTITION users_other DEFAULT;
COMMIT;
SELECT tableoid::regclass AS partition, email, country_code
FROM users ORDER BY tableoid::regclass::text, email;Expected Output:
BEGIN
ALTER TABLE
CREATE TABLE
INSERT 0 1
DELETE 1
ALTER TABLE
COMMIT
partition | email | country_code
---------------------+-------------------+--------------
users_asia | chen@example.cn | CN
users_europe | bob@example.co.uk | GB
users_north_america | alice@example.com | US
users_north_america | diego@example.mx | MX
users_south_america | bruno@example.br | BR
(5 rows)Hash Partitioning
Hash partitioning distributes data evenly across partitions using a hash function. This ensures uniform distribution when there's no natural partitioning key or when you need balanced partition sizes.
Creating Hash Partitions
-- Hash partition by user_id for even distribution
CREATE TABLE sessions (
session_id UUID DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL,
login_time TIMESTAMP,
logout_time TIMESTAMP,
ip_address INET
) PARTITION BY HASH (user_id);
-- Create 8 hash partitions (power of 2 is recommended)
CREATE TABLE sessions_part_0 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE sessions_part_1 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 1);
CREATE TABLE sessions_part_2 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 2);
CREATE TABLE sessions_part_3 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 3);
CREATE TABLE sessions_part_4 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 4);
CREATE TABLE sessions_part_5 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 5);
CREATE TABLE sessions_part_6 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 6);
CREATE TABLE sessions_part_7 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 7);Verifying Hash Distribution
import psycopg
from datetime import datetime, timedelta
import random
random.seed(42) # so you get exactly the distribution printed below
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Insert sessions for various users
print("Inserting 10,000 sessions...")
for i in range(10000):
user_id = random.randint(1, 100000)
login_time = datetime.now() - timedelta(days=random.randint(0, 30))
cursor.execute("""
INSERT INTO sessions (user_id, login_time)
VALUES (%s, %s)
""", (user_id, login_time))
conn.commit()
# Check distribution across partitions
cursor.execute("""
SELECT
tableoid::regclass AS partition,
COUNT(*) as row_count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) as percentage
FROM sessions
GROUP BY tableoid
ORDER BY partition
""")
print("\nHash partition distribution:")
print(f"{'Partition':<25} {'Rows':<10} {'%'}")
print("-" * 45)
for partition, count, pct in cursor.fetchall():
print(f"{partition:<25} {count:<10} {pct}%")Expected Output:
Inserting 10,000 sessions... Hash partition distribution: Partition Rows % --------------------------------------------- sessions_part_0 1285 12.85% sessions_part_1 1243 12.43% sessions_part_2 1204 12.04% sessions_part_3 1209 12.09% sessions_part_4 1281 12.81% sessions_part_5 1244 12.44% sessions_part_6 1243 12.43% sessions_part_7 1291 12.91% Every partition lands within 4% of the 1,250 average (1,204 low, 1,291 high), from nothing but a hash of user_id. That evenness is the entire reason to choose HASH: no key is "hot", and no partition grows faster than its neighbours. The cost is that you give up pruning on anything but equality on the partition key. A query for last week's sessions must touch all eight partitions, because the hash is over user_id and scatters adjacent timestamps everywhere. Even a range query on user_id itself has to scan all eight. Choose HASH for even write spread and single-user lookups, RANGE for queries that filter on a range of the partition key.
Horizontal vs Vertical Partitioning
Horizontal partitioning divides rows across partitions (what we've covered so far), while vertical partitioning divides columns. Both can be used together for optimal performance.
Horizontal Partitioning
Horizontal partitioning splits a table by rows. Every partition keeps the full column list, so each one is a smaller table with an identical schema and each row lives in exactly one of them. Everything covered so far in this lesson has been horizontal: range, list, and hash are just different rules for deciding which row goes where.
Horizontal Partitioning: Same Columns, Fewer Rows
Figure 2: The column list is copied to every partition, unchanged. Only the rows are divided, so a query that needs all categories has to visit more than one partition.
What horizontal partitioning buys you
- Fewer rows scanned - a query filtered on the partition key reads one partition instead of the whole table
- Better parallelization - separate partitions can be scanned at the same time
- Cheap archiving - retiring a slice is a
DROP TABLEon one partition rather than a largeDELETE
Vertical Partitioning
Vertical partitioning splits a table by columns instead, separating the ones read on every request from the ones read almost never. Both tables keep the same key, so a row is reassembled with a join when you actually need the cold half. Note that this is a hand-built pattern rather than a PostgreSQL feature: there is noPARTITION BY for columns, you create two tables and keep them in step yourself.
Vertical Partitioning: Hot Columns, Cold Columns
Figure 3: The large, rarely read columns move out so the hot table stays small enough to keep in cache. The dashed edge is the join you pay for only when a request genuinely needs the cold columns.
What vertical partitioning buys you
- Smaller hot table - dropping the large text and JSONB columns leaves more rows per page, so more of the table fits in memory
- Better cache utilization - the bytes actually being read stop competing with bytes nobody asked for
- Less I/O per query - the common lookup never touches the cold columns at all
Implementing Vertical Partitioning
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Create hot table (frequently accessed columns)
cursor.execute("""
CREATE TABLE users_core (
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create cold table (rarely accessed columns)
cursor.execute("""
CREATE TABLE users_extended (
user_id BIGINT PRIMARY KEY REFERENCES users_core(user_id),
bio TEXT,
avatar_url VARCHAR(500),
preferences JSONB,
metadata JSONB
)
""")
# Insert user data
cursor.execute("""
INSERT INTO users_core (email, username)
VALUES ('alice@example.com', 'alice')
RETURNING user_id
""")
user_id = cursor.fetchone()[0]
cursor.execute("""
INSERT INTO users_extended (user_id, bio, preferences)
VALUES (%s, %s, %s)
""", (user_id, 'Software engineer from California',
'{"theme": "dark", "notifications": true}'))
conn.commit()
# Fast query (uses only hot table - small, fast indexes)
cursor.execute("""
SELECT user_id, email, username
FROM users_core
WHERE email = 'alice@example.com'
""")
print("User core data:", cursor.fetchone())
# Full profile query (joins both tables)
cursor.execute("""
SELECT c.email, c.username, e.bio, e.preferences
FROM users_core c
LEFT JOIN users_extended e ON c.user_id = e.user_id
WHERE c.user_id = %s
""", (user_id,))
print("Full profile:", cursor.fetchone())Expected Output:
User core data: (1, 'alice@example.com', 'alice')
Full profile: ('alice@example.com', 'alice', 'Software engineer from California', {'theme': 'dark', 'notifications': True})
Two things worth noticing in the second line. psycopg 3 hands back the
JSONB column as a real Python dict, not a string, so no json.loads() is
needed. And PostgreSQL's JSON true became Python's True on the way out.
The point of the split is that the first query never touches bio,
avatar_url, preferences or metadata. On a real users table those columns
are most of the bytes and almost none of the reads, so keeping them in a
second table makes the hot table small enough to stay cached.Sharding: Distributing Data Across Database Instances
Sharding takes partitioning to the next level by distributing data across multiple independent database servers. Each shard is a complete database instance with its own storage, CPU, and memory. Unlike partitioning (which is handled by the database), sharding requires application-level routing logic.
Sharding Architecture
Figure 4: Compare this with Figure 1. There the application named one table and the database did the routing; here the database knows nothing about its siblings, so the arrows are the application's own decision.
What each shard actually is
- A separate PostgreSQL instance - its own process, configuration, and version, not a partition living inside a shared server.
- Independent resources - CPU, memory, and disk are not shared with the other shards, which is exactly where the extra headroom comes from.
- A subset of the data - no shard holds the whole dataset, and no shard knows the others exist.
- A transaction boundary - a single
BEGIN/COMMITcannot span two shards, so atomicity stops at the shard edge. This is the constraint that shapes every design decision in the rest of this lesson.
Geography-Based Sharding
Geography-based sharding distributes data by geographic region, reducing latency for users and complying with data residency regulations (GDPR, data sovereignty laws). This is usually driven by law rather than performance: if EU user data must stay in the EU, the region effectively becomes your shard key whether you wanted it to or not.
Shard Router Implementation
Set Up the Shard Schemas
Each shard needs its own copy of the table. Save this as shards.sql and run it through psql, not your shell:docker exec -i pg-demo psql -U demo -d demo < shards.sql
-- \c switches databases mid-script, which is how a single file sets up
-- several independent shards. Nothing propagates a schema between them:
-- in production these are separate servers, so you create the same table
-- three times, by hand or by migration tool.
\c users_us
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
country_code CHAR(2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
\c users_eu
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
country_code CHAR(2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
\c users_asia
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
country_code CHAR(2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);import psycopg
from typing import Dict
# One DSN per shard. In production these are three separate servers; with
# the single-container setup from "Try it locally" they are three databases
# on the same server, which is enough to show the routing behaviour.
SHARD_DSN = {
'us': "host=localhost dbname=users_us user=demo password=demo",
'eu': "host=localhost dbname=users_eu user=demo password=demo",
'asia': "host=localhost dbname=users_asia user=demo password=demo",
}
class GeographyShardRouter:
"""Route queries to the appropriate geographic shard"""
def __init__(self):
self.shards: Dict[str, psycopg.Connection] = {
region: psycopg.connect(dsn) for region, dsn in SHARD_DSN.items()
}
# Map country codes to regions
self.country_to_region = {
'US': 'us', 'CA': 'us', 'MX': 'us',
'GB': 'eu', 'DE': 'eu', 'FR': 'eu', 'IT': 'eu',
'CN': 'asia', 'JP': 'asia', 'IN': 'asia', 'KR': 'asia'
}
def get_shard(self, country_code: str):
"""Get database connection for a country"""
region = self.country_to_region.get(country_code, 'us') # Default to US
return self.shards[region]
def insert_user(self, email: str, country_code: str):
"""Insert user into the appropriate shard"""
conn = self.get_shard(country_code)
with conn.cursor() as cursor:
cursor.execute("""
INSERT INTO users (email, country_code)
VALUES (%s, %s)
RETURNING user_id
""", (email, country_code))
user_id = cursor.fetchone()[0]
conn.commit()
return user_id
def get_user(self, email: str, country_code: str):
"""Retrieve user from the shard implied by country_code"""
conn = self.get_shard(country_code)
with conn.cursor() as cursor:
cursor.execute("""
SELECT user_id, email, country_code, created_at
FROM users
WHERE email = %s
""", (email,))
return cursor.fetchone()
router = GeographyShardRouter()
# Insert users in their home regions
us_user_id = router.insert_user('alice@example.com', 'US')
eu_user_id = router.insert_user('bob@example.co.uk', 'GB')
asia_user_id = router.insert_user('chen@example.cn', 'CN')
print(f"US user created: {us_user_id} (stored in US shard)")
print(f"EU user created: {eu_user_id} (stored in EU shard)")
print(f"Asia user created: {asia_user_id} (stored in Asia shard)")
# Look Bob up with the right key, then with the wrong one.
print(f"\nLookup with 'GB': {router.get_user('bob@example.co.uk', 'GB')}")
print(f"Lookup with 'US': {router.get_user('bob@example.co.uk', 'US')}")Expected Output:
US user created: 1 (stored in US shard) EU user created: 1 (stored in EU shard) Asia user created: 1 (stored in Asia shard) Lookup with 'GB': (1, 'bob@example.co.uk', 'GB', datetime.datetime(2026, 7, 29, 16, 7, 8, 2471)) Lookup with 'US': None Two things this output shows that a diagram cannot. First, every user got user_id 1. Each shard runs its own BIGSERIAL sequence and they know nothing about each other, so ids collide across shards from the very first insert. The moment you need a globally unique reference, a foreign key from another service, an audit log entry or a URL, per-shard sequences are unusable. This is the concrete reason sharded systems reach for UUIDv7 or a coordinated id service. Second, asking the wrong shard returns None, not an error. Bob exists, but looked up with country_code 'US' the router sends the query to the US shard and finds nothing. A sharding key is not metadata: get it wrong and the row is simply invisible, with no exception to alert you. Any code path that cannot supply the key must either fan out to every shard or consult a lookup table. (Your timestamp will differ, and so will nothing else.)
Tenant-Based Sharding (Multi-Tenancy)
Tenant-based sharding distributes data by customer/organization (tenant). Each shard contains data for one or more tenants. Slack, Salesforce, and other B2B SaaS platforms use this strategy to isolate customer data and provide dedicated resources for large customers.
Tenant Shard Router
Set Up the Routing and Shard Schemas
Two schemas here: a routing table that maps tenants to shards, and the workspace table each shard carries. Feed it to psql the same way as the previous one.
-- The routing database holds the tenant -> shard map. It is small, it is
-- read on every request, and it is the one thing that is NOT sharded.
\c routing
CREATE TABLE tenant_shards (
tenant_id BIGINT PRIMARY KEY,
tenant_name VARCHAR(100) NOT NULL,
shard_name VARCHAR(20) NOT NULL
);
-- Each tenant shard holds the actual data.
\c saas_shard1
CREATE TABLE workspaces (
workspace_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
\c saas_shard2
CREATE TABLE workspaces (
workspace_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
\c saas_shard3
CREATE TABLE workspaces (
workspace_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);import psycopg
from typing import Dict, Optional
# One DSN per shard, plus the small routing database that says which shard
# owns which tenant. With the single-container setup from "Try it locally"
# these are four databases on the same server.
SHARD_DSN = {
'shard_1': "host=localhost dbname=saas_shard1 user=demo password=demo",
'shard_2': "host=localhost dbname=saas_shard2 user=demo password=demo",
'shard_3': "host=localhost dbname=saas_shard3 user=demo password=demo",
}
ROUTING_DSN = "host=localhost dbname=routing user=demo password=demo"
class TenantShardRouter:
"""Route queries based on tenant (organization) ID"""
def __init__(self):
self.shards: Dict[str, psycopg.Connection] = {
name: psycopg.connect(dsn) for name, dsn in SHARD_DSN.items()
}
self.routing_db = psycopg.connect(ROUTING_DSN)
def get_tenant_shard(self, tenant_id: int) -> str:
"""Look up which shard contains this tenant's data"""
cursor = self.routing_db.cursor()
cursor.execute("""
SELECT shard_name
FROM tenant_shards
WHERE tenant_id = %s
""", (tenant_id,))
result = cursor.fetchone()
cursor.close()
if not result:
raise ValueError(f"Tenant {tenant_id} not found in routing table")
return result[0]
def assign_tenant_to_shard(self, tenant_id: int, tenant_name: str) -> str:
"""Assign new tenant to least-loaded shard"""
cursor = self.routing_db.cursor()
# Find shard with fewest tenants.
# The shard list has to come from the LEFT side: grouping
# tenant_shards alone would only ever return shards that already
# HAVE tenants, so an empty shard could never win and every new
# tenant would pile onto the first one.
cursor.execute("""
SELECT s.shard_name, COUNT(t.tenant_id) AS tenant_count
FROM (VALUES ('shard_1'), ('shard_2'), ('shard_3'))
AS s(shard_name)
LEFT JOIN tenant_shards t ON t.shard_name = s.shard_name
GROUP BY s.shard_name
ORDER BY tenant_count ASC, s.shard_name
LIMIT 1
""")
result = cursor.fetchone()
shard_name = result[0] if result else 'shard_1'
# Record tenant assignment
cursor.execute("""
INSERT INTO tenant_shards (tenant_id, tenant_name, shard_name)
VALUES (%s, %s, %s)
""", (tenant_id, tenant_name, shard_name))
self.routing_db.commit()
cursor.close()
return shard_name
def create_workspace(self, tenant_id: int, workspace_name: str):
"""Create workspace in tenant's shard"""
shard_name = self.get_tenant_shard(tenant_id)
conn = self.shards[shard_name]
cursor = conn.cursor()
cursor.execute("""
INSERT INTO workspaces (tenant_id, name)
VALUES (%s, %s)
RETURNING workspace_id
""", (tenant_id, workspace_name))
workspace_id = cursor.fetchone()[0]
conn.commit()
cursor.close()
return workspace_id, shard_name
def get_tenant_workspaces(self, tenant_id: int):
"""Get all workspaces for a tenant"""
shard_name = self.get_tenant_shard(tenant_id)
conn = self.shards[shard_name]
cursor = conn.cursor()
cursor.execute("""
SELECT workspace_id, name, created_at
FROM workspaces
WHERE tenant_id = %s
-- created_at alone can tie at microsecond resolution, so pin
-- the order with the primary key as well.
ORDER BY created_at DESC, workspace_id DESC
""", (tenant_id,))
workspaces = cursor.fetchall()
cursor.close()
return workspaces, shard_name
# Usage
router = TenantShardRouter()
# Assign new tenants to shards
for tenant_id, name in [(101, 'Acme Corp'), (102, 'Globex'), (103, 'Initech'),
(104, 'Umbrella'), (105, 'Soylent')]:
print(f"{name:<9} assigned to {router.assign_tenant_to_shard(tenant_id, name)}")
# Create a workspace in the tenant's shard
ws_id, shard = router.create_workspace(101, 'Engineering Team')
print(f"\nWorkspace {ws_id} created for tenant 101 on {shard}")
# An unknown tenant is refused loudly rather than defaulting somewhere
try:
router.create_workspace(999, 'Ghost Team')
except ValueError as e:
print(f"ValueError: {e}")Expected Output:
Acme Corp assigned to shard_1 Globex assigned to shard_2 Initech assigned to shard_3 Umbrella assigned to shard_1 Soylent assigned to shard_2 Workspace 1 created for tenant 101 on shard_1 ValueError: Tenant 999 not found in routing table Round-robin across the three shards, which is what "least loaded" should look like when every tenant is new. Note how easy this is to get wrong: group tenant_shards on its own and an empty shard has no rows to be counted, so it can never be the minimum, and every tenant piles onto shard_1 forever. The shard list has to come from somewhere other than the assignment table, which is why the query above starts from a VALUES list and LEFT JOINs the assignments onto it. The ValueError matters as much as the assignment. An unknown tenant is refused loudly rather than silently defaulting to some shard, because a tenant written to the wrong shard is invisible to every later read - the same failure mode as the wrong-country lookup in the previous example, and just as silent if you let it happen. Worth saying out loud: "least loaded" here counts tenants, not bytes or queries. One enterprise customer can outweigh a thousand small ones, so a real system re-balances on actual size or load rather than trusting this counter forever.
Hash-Based Sharding
Hash-based sharding uses a hash function to evenly distribute data across shards. Cassandra and DynamoDB both partition this way, using consistent hashing so that adding a node remaps only a fraction of the keys. It ensures balanced load but makes range queries across shards difficult, since adjacent keys deliberately land on different shards.
Hash Shard Router (Modulo Hashing)
Set Up the Shard Schemas
Four shards, one identical tweets table in each. Through psql again, not your shell.
-- Four shards this time, the same table in each.
\c tweets_shard0
CREATE TABLE tweets (
tweet_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
tweet_text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
\c tweets_shard1
CREATE TABLE tweets (
tweet_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
tweet_text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
\c tweets_shard2
CREATE TABLE tweets (
tweet_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
tweet_text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
\c tweets_shard3
CREATE TABLE tweets (
tweet_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
tweet_text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);import psycopg
import hashlib
from typing import List
class HashShardRouter:
"""Route queries using hash-based (modulo) sharding.
NOTE: This uses modulo hashing (hash % shard_count) for simplicity.
It distributes evenly, but changing shard_count remaps almost every
key. Production systems use CONSISTENT HASHING (a hash ring) so that
adding/removing a shard only remaps ~1/N of the keys. See the note
below the code.
"""
def __init__(self, shard_count: int = 4):
self.shard_count = shard_count
# Initialize database connections
self.shards = {
i: psycopg.connect(f"host=localhost dbname=tweets_shard{i} "
f"user=demo password=demo")
for i in range(shard_count)
}
def get_shard_id(self, key: str) -> int:
"""Determine shard ID using hash function"""
# Use MD5 hash for consistent distribution
hash_value = int(hashlib.md5(key.encode()).hexdigest(), 16)
return hash_value % self.shard_count
def insert_tweet(self, user_id: int, tweet_text: str):
"""Insert tweet into the shard determined by user_id hash"""
shard_id = self.get_shard_id(str(user_id))
conn = self.shards[shard_id]
cursor = conn.cursor()
cursor.execute("""
INSERT INTO tweets (user_id, tweet_text)
VALUES (%s, %s)
RETURNING tweet_id
""", (user_id, tweet_text))
tweet_id = cursor.fetchone()[0]
conn.commit()
cursor.close()
return tweet_id, shard_id
def get_user_tweets(self, user_id: int):
"""Get all tweets for a user from their shard"""
shard_id = self.get_shard_id(str(user_id))
conn = self.shards[shard_id]
cursor = conn.cursor()
cursor.execute("""
SELECT tweet_id, tweet_text, created_at
FROM tweets
WHERE user_id = %s
-- created_at alone can tie for tweets posted in the same
-- instant, so pin the order with the id as well.
ORDER BY created_at DESC, tweet_id DESC
LIMIT 20
""", (user_id,))
tweets = cursor.fetchall()
cursor.close()
return tweets, shard_id
def get_tweet_by_id(self, tweet_id: int):
"""Get tweet by ID - requires searching all shards (slow!)"""
for shard_id, conn in self.shards.items():
cursor = conn.cursor()
cursor.execute("""
SELECT tweet_id, user_id, tweet_text, created_at
FROM tweets
WHERE tweet_id = %s
""", (tweet_id,))
result = cursor.fetchone()
cursor.close()
if result:
return result, shard_id
return None, None
# Usage
router = HashShardRouter(shard_count=4)
# Insert tweets for different users
users = [
(12345, "Hello from user 12345!"),
(67890, "Tweet from user 67890"),
(11111, "User 11111 posting"),
(22222, "Another tweet from 22222"),
(12345, "Second tweet from 12345")
]
print("Inserting tweets:")
for user_id, text in users:
tweet_id, shard_id = router.insert_tweet(user_id, text)
print(f" User {user_id} → Shard {shard_id} (Tweet ID: {tweet_id})")
# Verify hash distribution
print("\nVerifying shard distribution:")
shard_counts = {}
for user_id, _ in users:
shard_id = router.get_shard_id(str(user_id))
shard_counts[shard_id] = shard_counts.get(shard_id, 0) + 1
for shard_id in range(router.shard_count):
count = shard_counts.get(shard_id, 0)
print(f" Shard {shard_id}: {count} tweets")
# Retrieve user's tweets (fast - single shard query)
tweets, shard_id = router.get_user_tweets(12345)
print(f"\nUser 12345 tweets (from shard {shard_id}):")
for tweet_id, text, created_at in tweets:
print(f" - {text}")Expected Output:
Inserting tweets: User 12345 → Shard 3 (Tweet ID: 1) User 67890 → Shard 2 (Tweet ID: 1) User 11111 → Shard 3 (Tweet ID: 2) User 22222 → Shard 1 (Tweet ID: 1) User 12345 → Shard 3 (Tweet ID: 3) Verifying shard distribution: Shard 0: 0 tweets Shard 1: 1 tweets Shard 2: 1 tweets Shard 3: 3 tweets User 12345 tweets (from shard 3): - Second tweet from 12345 - Hello from user 12345! MD5 is deterministic, so these shard assignments are not a sample: run it yourself and 12345 lands on shard 3 every time, on every machine. Which makes the distribution line the honest part of this output. Three of five tweets on shard 3, nothing at all on shard 0. Hashing gives you an even spread over many keys, not over five, and "even in expectation" is not a promise about any particular run. The hash distribution demo earlier in this lesson needed 10,000 rows before it looked flat. Notice the tweet ids too: shard 2 and shard 1 both minted tweet_id 1, the same per-shard sequence collision as the geographic router. get_tweet_by_id has to walk every shard precisely because a tweet id says nothing about where the tweet lives, and it stops at the first match, which is only correct because these ids happen not to collide in this tiny example. In a real system that lookup is either wrong or preceded by a global id scheme.
hash(key) % shard_count. It distributes evenly, but if you change shard_count (add or remove a shard), almost every key maps to a different shard, forcing a massive data migration. Production systems (Cassandra, DynamoDB, Riak) instead use consistent hashing: keys and shards are placed on a hash ring, so adding or removing a shard only remaps roughly 1/N of the keys. Reach for a consistent-hashing library rather than plain modulo when you expect to reshard.Query Routing and Cross-Shard Operations
Query routing is critical in sharded systems. Single-shard queries are fast, but cross-shard queries (scatter-gather) require querying multiple shards and merging results. Cross-shard transactions are generally impossible, requiring careful schema design.
Query Patterns
1. Single-shard query
"Get user 12345's profile." The shard key is in the query, so the router hashes it, picks one shard, and asks only that one. This is sharding working as intended, and it is the only pattern that keeps costing the same as you add shards.
Measured here: 0.16 ms.
2. Scatter-gather
"Get all users with premium status." No shard key, so every shard has to be asked, and the client merges, sorts, and paginates what comes back. Workable, but the cost scales with the number of shards and the slowest one sets the latency.
Measured here: 2.06 ms across three local shards, about 13x the single-shard lookup. Both figures come from containers on one machine, so treat the ratio as the lesson and the absolute numbers as a floor: real shards sit behind a network.
3. Cross-shard join
"Join users with their orders" when the two are sharded on different keys. No single database holds both sides, so the join cannot happen in SQL at all: you pull candidate rows from every shard and join them in application memory. There is no honest latency number to quote, because the cost tracks how much data you move rather than how many rows you return, and it degrades as the tables grow. Avoid this shape rather than tune it.
Best practice
Choose a shard key that puts related data on the same shard, and pattern 3 stops existing. That is not a tuning trick, it is the design decision the other two patterns depend on: the co-located join later in this lesson is a normal SQLJOIN only because users and orders shareuser_id as their shard key.
Implementing Scatter-Gather Queries
Set Up and Seed the Shards
Scatter-gather only shows its shape with real row counts, so this script seeds as well as creates. It also uses \set, another psql command, so it too has to go through psql.
-- This example needs enough rows for the LIMIT push-down to matter, so the
-- schema comes with a seed: 1,200 users matching '%john%' per shard plus
-- 300 that do not, so the WHERE clause is doing real work.
--
-- created_at is unique across ALL three shards (:shard offsets the
-- seconds). That matters: the two query strategies below are compared for
-- equality, and a created_at tie across shards would let them return
-- different-but-equally-valid top-100 lists.
\c users0
\set shard 0
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL
);
INSERT INTO users (email, username, created_at)
SELECT 'john' || g || '.s' || :shard || '@example.com',
'johnny_' || g,
TIMESTAMP '2026-01-01 00:00:00' + ((g * 3 + :shard) || ' seconds')::interval
FROM generate_series(1, 1200) g;
INSERT INTO users (email, username, created_at)
SELECT 'other' || g || '.s' || :shard || '@example.com',
'other_' || g,
TIMESTAMP '2026-01-01 00:00:00' + ((g * 7 + :shard) || ' seconds')::interval
FROM generate_series(1, 300) g;
\c users1
\set shard 1
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL
);
INSERT INTO users (email, username, created_at)
SELECT 'john' || g || '.s' || :shard || '@example.com',
'johnny_' || g,
TIMESTAMP '2026-01-01 00:00:00' + ((g * 3 + :shard) || ' seconds')::interval
FROM generate_series(1, 1200) g;
INSERT INTO users (email, username, created_at)
SELECT 'other' || g || '.s' || :shard || '@example.com',
'other_' || g,
TIMESTAMP '2026-01-01 00:00:00' + ((g * 7 + :shard) || ' seconds')::interval
FROM generate_series(1, 300) g;
\c users2
\set shard 2
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL
);
INSERT INTO users (email, username, created_at)
SELECT 'john' || g || '.s' || :shard || '@example.com',
'johnny_' || g,
TIMESTAMP '2026-01-01 00:00:00' + ((g * 3 + :shard) || ' seconds')::interval
FROM generate_series(1, 1200) g;
INSERT INTO users (email, username, created_at)
SELECT 'other' || g || '.s' || :shard || '@example.com',
'other_' || g,
TIMESTAMP '2026-01-01 00:00:00' + ((g * 7 + :shard) || ' seconds')::interval
FROM generate_series(1, 300) g;import psycopg
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Tuple
SHARD_DSN = {
'shard_1': "host=localhost dbname=users0 user=demo password=demo",
'shard_2': "host=localhost dbname=users1 user=demo password=demo",
'shard_3': "host=localhost dbname=users2 user=demo password=demo",
}
class ShardedQueryExecutor:
"""Execute a query across every shard and merge the results."""
def __init__(self, shards: dict):
self.shards = shards
self.executor = ThreadPoolExecutor(max_workers=len(shards))
@staticmethod
def _query_shard(shard_id, conn, query, params):
with conn.cursor() as cur:
cur.execute(query, params or ())
return shard_id, cur.fetchall()
def scatter_gather(self, query, params=None, limit=100, verbose=True):
"""Scatter to every shard in parallel, gather, merge, sort, truncate."""
futures = {
self.executor.submit(self._query_shard, sid, conn, query, params): sid
for sid, conn in self.shards.items()
}
merged = []
for future in as_completed(futures):
shard_id, rows = future.result()
merged.extend(rows)
if verbose:
print(f" {shard_id}: {len(rows):,} rows")
# The global sort has to happen here: no shard can order rows it
# cannot see. Index 3 is created_at.
merged.sort(key=lambda r: r[3], reverse=True)
return merged[:limit]
def sequential(self, query, params=None, limit=100):
"""Same work, one shard at a time. For comparison only."""
merged = []
for shard_id, conn in self.shards.items():
merged.extend(self._query_shard(shard_id, conn, query, params)[1])
merged.sort(key=lambda r: r[3], reverse=True)
return merged[:limit]
SEARCH = """
SELECT user_id, email, username, created_at
FROM users
WHERE username LIKE %s OR email LIKE %s
"""
# The same query, but with the ordering and the cut pushed down to each
# shard. A shard still cannot know the global answer, but it does know that
# only its own top `limit` rows can possibly survive the merge.
SEARCH_PUSHED_DOWN = SEARCH + """
ORDER BY created_at DESC
LIMIT %s
"""
LIMIT = 100
term = '%john%'
executor = ShardedQueryExecutor(
{sid: psycopg.connect(dsn) for sid, dsn in SHARD_DSN.items()}
)
print("Naive scatter-gather: no LIMIT inside the shard query")
rows = executor.scatter_gather(SEARCH, (term, term), limit=LIMIT)
print(f" -> merged down to {len(rows)} rows\n")
print("With ORDER BY + LIMIT pushed into each shard")
rows_pd = executor.scatter_gather(SEARCH_PUSHED_DOWN, (term, term, LIMIT), limit=LIMIT)
print(f" -> merged down to {len(rows_pd)} rows")
print(f" -> same answer as the naive version: {rows == rows_pd}\n")
def timeit(fn, reps=5):
fn() # warm
best = min(_time(fn) for _ in range(reps))
return best
def _time(fn):
t0 = time.perf_counter()
fn()
return (time.perf_counter() - t0) * 1000
par = timeit(lambda: executor.scatter_gather(SEARCH, (term, term), LIMIT, verbose=False))
seq = timeit(lambda: executor.sequential(SEARCH, (term, term), LIMIT))
print(f"Parallel scatter-gather: {par:6.1f} ms")
print(f"Sequential equivalent: {seq:6.1f} ms")Expected Output:
Naive scatter-gather: no LIMIT inside the shard query shard_2: 1,200 rows shard_1: 1,200 rows shard_3: 1,200 rows -> merged down to 100 rows With ORDER BY + LIMIT pushed into each shard shard_1: 100 rows shard_3: 100 rows shard_2: 100 rows -> merged down to 100 rows -> same answer as the naive version: True Parallel scatter-gather: 2.9 ms Sequential equivalent: 5.7 ms Read the shard order first, and then run it again. It changes: 2,1,3 here, 1,3,2 on the next run. as_completed yields whichever shard answers first, not the order you submitted, which is exactly why the code sorts the merged list afterwards instead of trusting arrival order. The two scatter-gathers return an identical answer, and that is the point of pushing ORDER BY ... LIMIT down. The naive version dragged 3,600 rows across the wire to return 100. The pushed-down version moved 300. No shard can know whether its rows will survive the global sort, but it does know that nothing below its own top 100 can, so 100 per shard is the smallest correct amount to send. Both still need the merge on the client, because only the client sees all three shards. On the timing: parallel wins here, roughly 2x on three shards. Do not read that as a law. The win is bounded by the number of shards, and thread overhead is a fixed cost paid whether or not the queries are slow. Scatter across three tiny shards on localhost and the threads can cost more than the queries; scatter across twelve shards on separate hosts with real network latency and parallelism is the only thing keeping the query usable. Measure your own shape rather than assuming either direction.
Avoiding Cross-Shard Joins
Set Up and Seed the Co-located Shards
Both tables are sharded on the same key, which is the whole point of the example.
-- Both tables live on both shards, and both are keyed by user_id. That is
-- what co-location means here: user 12345 and every one of that user's
-- orders sit on the same server, so the JOIN never crosses a shard.
--
-- user_id % 2 picks the shard, so 12345 and 12347 land on shard1 and 12346
-- lands on shard0. Carol (12347) deliberately gets no orders.
\c shard0
CREATE TABLE users (
user_id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL
);
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
total_amount NUMERIC(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL
);
INSERT INTO users (user_id, email, username) VALUES
(12346, 'bob@example.com', 'bob_jones');
INSERT INTO orders (user_id, total_amount, status, created_at) VALUES
(12346, 42.00, 'pending', '2026-03-01 10:00:00');
\c shard1
CREATE TABLE users (
user_id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL
);
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
total_amount NUMERIC(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL
);
INSERT INTO users (user_id, email, username) VALUES
(12345, 'alice@example.com', 'alice_smith'),
(12347, 'carol@example.com', 'carol_white');
INSERT INTO orders (user_id, total_amount, status, created_at) VALUES
(12345, 299.99, 'completed', '2026-03-01 09:00:00'),
(12345, 89.50, 'shipped', '2026-03-02 14:30:00'),
(12345, 154.00, 'completed', '2026-03-03 11:15:00');import psycopg
# Users and orders are BOTH sharded on user_id, so a user and that user's
# orders always land on the same shard. That co-location is what makes the
# JOIN below legal at all.
SHARD_DSN = ["host=localhost dbname=shard0 user=demo password=demo",
"host=localhost dbname=shard1 user=demo password=demo"]
def get_shard(user_id: int) -> psycopg.Connection:
return psycopg.connect(SHARD_DSN[user_id % len(SHARD_DSN)])
# BAD: two round trips, then stitch the pieces together in Python.
def get_user_with_orders_bad(user_id: int):
conn = get_shard(user_id)
with conn.cursor() as cur:
cur.execute("SELECT user_id, email, username FROM users WHERE user_id = %s",
(user_id,))
user = cur.fetchone()
cur.execute("""SELECT order_id, total_amount, status
FROM orders WHERE user_id = %s
ORDER BY created_at DESC""", (user_id,))
orders = cur.fetchall()
return user, orders
# GOOD: one round trip, the database does the nesting.
def get_user_with_orders_good(user_id: int):
conn = get_shard(user_id)
with conn.cursor() as cur:
cur.execute("""
SELECT
u.user_id, u.email, u.username,
COALESCE(json_agg(
json_build_object(
'order_id', o.order_id,
'total', o.total_amount,
'status', o.status
) ORDER BY o.created_at DESC
) FILTER (WHERE o.order_id IS NOT NULL), '[]') AS orders
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE u.user_id = %s
GROUP BY u.user_id, u.email, u.username
""", (user_id,))
return cur.fetchone()
user, orders = get_user_with_orders_bad(12345)
print("BAD (2 queries):")
print(f" user = {user}")
print(f" orders = {orders}")
row = get_user_with_orders_good(12345)
print("\nGOOD (1 query):")
print(f" user_id = {row[0]}")
print(f" username = {row[2]}")
print(f" orders = {row[3]}")
# A user with no orders still comes back, with an empty list rather than
# a row of NULLs. That is what the FILTER + COALESCE pair is buying.
print(f"\nUser with no orders: {get_user_with_orders_good(12347)}")Expected Output:
BAD (2 queries):
user = (12345, 'alice@example.com', 'alice_smith')
orders = [(3, Decimal('154.00'), 'completed'), (2, Decimal('89.50'), 'shipped'), (1, Decimal('299.99'), 'completed')]
GOOD (1 query):
user_id = 12345
username = alice_smith
orders = [{'order_id': 3, 'total': 154.0, 'status': 'completed'}, {'order_id': 2, 'total': 89.5, 'status': 'shipped'}, {'order_id': 1, 'total': 299.99, 'status': 'completed'}]
User with no orders: (12347, 'carol@example.com', 'carol_white', [])
One round trip instead of two, orders already nested and already sorted
newest first. Co-location is what makes it possible: because users and
orders are both sharded on user_id, both sides of the JOIN are guaranteed
to be on the same machine, so the database can do the join at all.
Carol shows why FILTER + COALESCE are there. Without them a LEFT JOIN that
matches nothing produces json_agg([null]) rather than an empty array, and
downstream code iterating "orders" hits a null. She comes back with [].
Now compare the money columns in the two outputs, because this one is a
trap. The BAD path returns Decimal('89.50'), the exact NUMERIC. The GOOD
path returns 89.5, a Python float: json_build_object turned NUMERIC into a
JSON number and psycopg parsed it back as a float, losing both the exact
type and the trailing zero. Sum a few thousand of those and you will not
match the ledger. If a nested value is money, cast it to text inside the
JSON (o.total_amount::text) and parse it back to Decimal yourself, or keep
the totals out of the JSON and aggregate them in SQL.
The other trade is size. json_agg builds the whole array in memory, so this
shape works for a user with three orders and falls over for one with fifty
thousand. Co-location makes the join possible; it does not make an unbounded
join sensible. Paginate the child rows once they can grow without limit.Choosing the Right Partitioning/Sharding Strategy
The choice between partitioning and sharding, and which strategy to use, depends on your specific requirements, query patterns, and scale needs.
Use Range Partitioning When
- Queries filter by date/time ranges
- Data has natural time-series properties
- Need to archive old data easily
- Example: Logs, orders, events, metrics
Use List Partitioning When
- Data naturally groups by categories
- Queries filter by specific values
- Need data isolation (compliance, geography)
- Example: Multi-region apps, product categories
Use Hash Partitioning When
- Need even distribution across partitions
- No natural partitioning key
- Queries typically by primary key
- Example: User sessions, distributed workloads
Use Sharding When
- Single database can't handle the load
- Need independent scaling per shard
- Geographic distribution required
- Example: Multi-tenant SaaS, global applications
- Complexity: Application must handle routing, can't use database joins
- Resharding: Adding/removing shards requires data migration
- Transactions: Cross-shard transactions impossible (use sagas instead)
- Queries: Scatter-gather queries are slow
Key Takeaways
- Partitioning: Divides tables within one database (transparent to apps)
- Range partitioning: Split by value ranges (dates, IDs) - best for time-series
- List partitioning: Split by discrete values (countries, categories)
- Hash partitioning: Even distribution using hash function
- Sharding: Distributes data across multiple databases (requires routing)
- Geography sharding: Store data in user's region (low latency, compliance)
- Tenant sharding: Isolate customer data (B2B SaaS pattern)
- Hash sharding: Even distribution with consistent hashing