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.

Key Distinction: Partitioning divides a table within one database (PostgreSQL partitions, MySQL partitions). Sharding divides data across multiple independent databases (separate PostgreSQL instances). Both use similar strategies (range, hash, list), but sharding requires application-level routing logic.
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

ApplicationWHERE order_date IN 2024Query Plannerpartition pruningorders_2022150,015 rowsorders_2023149,925 rowsorders_2024150,060 rowsone logical tableskippedskippedscanned

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.

  1. 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.
  2. DETACH ... CONCURRENTLY has 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 (hence autocommit=True above), and PostgreSQL refuses it outright while a DEFAULT partition exists:
    ERROR: cannot detach partitions concurrently when a default partition exists
    Our table has orders_default, so the code above uses plainDETACH, which holds ACCESS EXCLUSIVE on the parent for its duration. Drop the default partition first if you need the concurrent form.
  3. A DEFAULT partition 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:
    ERROR: updated partition constraint for default partition "orders_default" would be violated by some row
    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.

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

productsidnamepricecategory10,000,000 rowsproducts_electronicsidnamepricecategory4,000,000 rowsproducts_booksidnamepricecategory3,000,000 rowselectronicsbooks

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 aDROP TABLE on one partition rather than a large DELETE

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

usersidemailnamebiopreferencesmetadatausers_coreidemailnamehot: read every requestusers_extendedidbiopreferencesmetadatacold: profile page onlyhotcoldJOIN ON id

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

Application Layershard router decides which DBShard 1users 1-1MShard 2users 1M-2MShard 3users 2M-3M

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 singleBEGIN/COMMIT cannot 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.
Modulo vs Consistent Hashing: The router above uses 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
Sharding Trade-offs:
  • 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
Golden Rule: Design your shard key so related data lives together. Single-shard queries beat scatter-gather by roughly an order of magnitude: 0.16ms against 2.06ms, about 13x, in the same three-local-shard benchmark measured earlier in this lesson, and the gap widens as shards multiply and move onto separate hosts. Avoid cross-shard joins by denormalizing data. Start with partitioning (simple), move to sharding only when a single database can't handle the load. Remember: partitioning is transparent to applications, but sharding requires application-level routing logic.