Skip to content
academia.sh

Lesson 12 / 25

Table Partitioning

Splitting a table into ranges, lists, and hashes; showing by measurement that a query touches only the relevant partition; dropping old data instead of deleting it; and computing the distribution of a partition key.

Contents

The Index Maintenance lesson left a question open. Deleting old loan records left dead space everywhere it failed to overlap with index order, and recovering that space required rewriting the table from scratch. The source of the problem is not the delete itself but the fact that the data being deleted was mixed in among the live data.

Partitioning (partitioning) prevents this mixing from the outset: a table that is logically a single table is stored physically as more than one piece. Each piece is called a partition (partition), and which partition a row falls into is decided by the partition key (partition key). This lesson sets up three forms of partitioning, measures their gain, and also shows where the gain does not come from.

Range Partitioning

The most common form is range partitioning (range partitioning): value ranges of the partition key correspond to partitions. In library records the natural key is the pickup date, and the natural range is the year.

Some engines offer partitioning through their syntax; where they do not, the same structure is built with separate tables and a union view. The run below follows the second path. Each partition table states its own range with a value check, and a view gathers the partitions under a single name.

rm -f unpartitioned.db partitioned.db partition.sql
cat > setup.sql <<'SQL'
CREATE TABLE loan (
  loan_id      INTEGER PRIMARY KEY,
  book_id      INTEGER NOT NULL,
  member_id    INTEGER NOT NULL,
  pickup_date  TEXT NOT NULL,
  return_date  TEXT
);
INSERT INTO loan (loan_id, book_id, member_id, pickup_date, return_date)
WITH RECURSIVE counter(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM counter WHERE n < 2000000)
SELECT n, 1 + ((n * 7) % 200000), 1 + ((n * 13) % 120000),
       date('2018-01-01', '+' || ((n * 37) % 2437) || ' days'),
       CASE WHEN n % 9 = 0 THEN NULL
            ELSE date('2018-01-01', '+' || (((n * 37) % 2437) + 14) || ' days') END
FROM counter;
SQL
sqlite3 unpartitioned.db < setup.sql
sqlite3 unpartitioned.db 'CREATE INDEX loan_pickup ON loan(pickup_date);'

# One partition table per year, a value check, and a view gathering the partitions.
{
  echo "ATTACH 'unpartitioned.db' AS source;"
  for y in 2018 2019 2020 2021 2022 2023 2024; do
    s=$((y + 1))
    cat <<EOF
CREATE TABLE loan_$y (
  loan_id      INTEGER PRIMARY KEY,
  book_id      INT NOT NULL,
  member_id    INT NOT NULL,
  pickup_date  TEXT NOT NULL CHECK (pickup_date >= '$y-01-01' AND pickup_date < '$s-01-01'),
  return_date  TEXT);
INSERT INTO loan_$y SELECT * FROM source.loan
  WHERE pickup_date >= '$y-01-01' AND pickup_date < '$s-01-01';
CREATE INDEX loan_${y}_pickup ON loan_$y(pickup_date);
EOF
  done
  echo "CREATE VIEW loan AS"
  echo "  SELECT * FROM loan_2018 UNION ALL SELECT * FROM loan_2019 UNION ALL"
  echo "  SELECT * FROM loan_2020 UNION ALL SELECT * FROM loan_2021 UNION ALL"
  echo "  SELECT * FROM loan_2022 UNION ALL SELECT * FROM loan_2023 UNION ALL"
  echo "  SELECT * FROM loan_2024;"
} > partition.sql
sqlite3 partitioned.db < partition.sql
sqlite3 partitioned.db <<'SQL'
.mode column
.headers on
SELECT count(*) AS view_rows FROM loan;
SELECT name AS partition_, count(*) AS pages FROM dbstat
WHERE name LIKE 'loan\_20__' ESCAPE '\' GROUP BY name ORDER BY name;
SQL
view_rows
---------
2000000
partition_  pages
----------  -----
loan_2018   2966
loan_2019   2982
loan_2020   2970
loan_2021   2959
loan_2022   2964
loan_2023   2982
loan_2024   1994

The view returns all two million rows: from the application’s point of view the table is still a single one. Underneath it there are seven separate tables, seven separate indexes, and seven separate trees of roughly three thousand pages each.

Touching Only the Relevant Partition

Partitioning’s first claim is this: a query that filters on the partition key never reads the other partitions at all. This claim is invisible in plan output — the plan lists all seven partitions.

sqlite3 partitioned.db "EXPLAIN QUERY PLAN
SELECT count(*) FROM loan WHERE pickup_date BETWEEN '2021-03-01' AND '2021-03-31';"

for target in "partitioned.db|loan|partitioned view" \
             "partitioned.db|loan_2021|direct 2021 partition" \
             "unpartitioned.db|loan|unpartitioned single table"; do
  db=${target%%|*}; rest=${target#*|}; tbl=${rest%%|*}; label=${rest#*|}
  printf '%-24s ' "$label"
  sqlite3 "$db" <<SQL 2>&1 | grep -E 'Page cache misses|Run Time' | tr '\n' ' '
.stats on
.timer on
SELECT count(*) FROM $tbl WHERE pickup_date BETWEEN '2021-03-01' AND '2021-03-31';
SQL
  echo
done
QUERY PLAN
|--CO-ROUTINE loan
|  `--COMPOUND QUERY
|     |--LEFT-MOST SUBQUERY
|     |  `--SEARCH loan_2018 USING INDEX loan_2018_pickup (pickup_date>? AND pickup_date<?)
|     |--UNION ALL
|     |  `--SEARCH loan_2019 USING INDEX loan_2019_pickup (pickup_date>? AND pickup_date<?)
|     |--UNION ALL
|     |  `--SEARCH loan_2020 USING INDEX loan_2020_pickup (pickup_date>? AND pickup_date<?)
|     |--UNION ALL
|     |  `--SEARCH loan_2021 USING INDEX loan_2021_pickup (pickup_date>? AND pickup_date<?)
|     |--UNION ALL
|     |  `--SEARCH loan_2022 USING INDEX loan_2022_pickup (pickup_date>? AND pickup_date<?)
|     |--UNION ALL
|     |  `--SEARCH loan_2023 USING INDEX loan_2023_pickup (pickup_date>? AND pickup_date<?)
|     `--UNION ALL
|        `--SEARCH loan_2024 USING INDEX loan_2024_pickup (pickup_date>? AND pickup_date<?)
`--SCAN loan
partitioned view         Page cache misses:                   141 Run Time: real 0.002 user 0.001953 sys 0.000144
direct 2021 partition    Page cache misses:                   123 Run Time: real 0.001 user 0.000526 sys 0.000114
unpartitioned single table Page cache misses:                   123 Run Time: real 0.000 user 0.000488 sys 0.000111

The page miss counts confirm the claim. The query asked directly of the 2021 partition read 123 pages; the query asked through the view read 141 pages. The 18-page difference is six empty probes into six partitions’ indexes — one partition costs three pages per empty probe. Six of the seven partitions show up in the plan, but their data is never read.

The name for this distinction is partition pruning (partition pruning). In engines that offer built-in partitioning, pruning happens while the plan is being built, and irrelevant partitions never enter the plan at all; in the model used here it happens at run time, because each empty probe comes back empty immediately within its own index. The two approaches are close in result, not in cost: once partition count reaches the hundreds, the sum of hundreds of empty probes stops being negligible.

The third row corrects an expectation, however. The unpartitioned single table answered the same query with the same 123 pages. With an index already in place, partitioning does not buy read performance — the index was already doing the same job. Partitioning’s gain lies elsewhere.

Deleting Old Data and Dropping a Partition

The real gain is that data can be removed as a whole. The run below does the same job in two setups: removing the records from 2018 from the system.

rm -f s1.db s2.db
cp unpartitioned.db s1.db
cp partitioned.db   s2.db
measure() { printf '%-8s file=%s  total_pages=%s  free=%s\n' "$2" "$(wc -c < "$1")" \
  "$(sqlite3 "$1" 'PRAGMA page_count;')" "$(sqlite3 "$1" 'PRAGMA freelist_count;')"; }

echo "=== unpartitioned: deleting the old year ==="
measure s1.db before
sqlite3 s1.db <<'SQL' | grep 'Run Time'
.timer on
DELETE FROM loan WHERE pickup_date < '2019-01-01';
SQL
measure s1.db delete
sqlite3 s1.db <<'SQL' | grep 'Run Time'
.timer on
VACUUM;
SQL
measure s1.db vacuum

echo
echo "=== partitioned: dropping the old partition ==="
measure s2.db before
sqlite3 s2.db <<'SQL' | grep 'Run Time'
.timer on
DROP VIEW loan;
DROP TABLE loan_2018;
CREATE VIEW loan AS
  SELECT * FROM loan_2019 UNION ALL SELECT * FROM loan_2020 UNION ALL
  SELECT * FROM loan_2021 UNION ALL SELECT * FROM loan_2022 UNION ALL
  SELECT * FROM loan_2023 UNION ALL SELECT * FROM loan_2024;
SQL
measure s2.db drop
sqlite3 s2.db "SELECT count(*) FROM loan;"
=== unpartitioned: deleting the old year ===
before   file= 112066560  total_pages=27360  free=0
Run Time: real 0.636 user 0.146132 sys 0.187506
delete   file= 112066560  total_pages=27360  free=1398
Run Time: real 0.442 user 0.147281 sys 0.262742
vacuum   file= 95285248  total_pages=23263  free=0

=== partitioned: dropping the old partition ===
before   file= 119492608  total_pages=29173  free=0
Run Time: real 0.000 user 0.000080 sys 0.000228
Run Time: real 0.061 user 0.002279 sys 0.006716
Run Time: real 0.000 user 0.000062 sys 0.000154
drop     file= 119492608  total_pages=29173  free=4367
1700444

Both paths removed the same 299,556 rows from the system; their costs are not comparable. In this environment the delete path took 0.636 seconds and freed only 1,398 pages — the rest of the gap was scattered inside the remaining pages. Recovering that gap required a full rewrite, which took another 0.442 seconds, and the table was locked for that whole span. Together the two steps ran past a second.

Dropping the partition took about 0.061 seconds and freed 4,367 pages in a single operation. The work performed is not deleting rows but removing an object: cost is measured not by row count but by a fixed catalog operation. Run times depend on the environment; what is stable is that one cost scales with data and the other does not.

This is the core reason for partitioning in every system with a retention rule. The same reasoning runs in the other direction too: adding an empty partition when a new period begins is also a fixed-cost operation.

List and Hash Partitioning

Two more forms exist. In list partitioning (list partitioning), each partition takes specific values of the key — city, branch, country. In hash partitioning (hash partitioning), the partition is found by passing the key through a hash function and dividing by the partition count; the goal is not a meaningful grouping but an even distribution.

The choice depends on how evenly the partitions will fill, and this can be computed. The model below distributes loans per member close to a power law — a small number of members hold many loans, most members leave only a few records — and counts partition fill for three key choices.

cat > distribution.mjs <<'JS'
// Model: distribution of loan records across partitions. Not a real database's
// partitioner; what is measured is the relationship between key choice and partition fill.
// Loans per member are distributed close to a power law: a few members hold many loans.
const MEMBERS = 120000, RECORDS = 2000000;
const cities = [['Istanbul', 0.42], ['Ankara', 0.24], ['Izmir', 0.16],
                  ['Bursa', 0.11], ['Konya', 0.07]];

function members() {
  const weight = [];
  let total = 0;
  for (let i = 1; i <= MEMBERS; i++) { const a = 1 / Math.pow(i, 0.8); weight.push(a); total += a; }
  return weight.map((a, i) => {
    let threshold = ((i * 7919) % 100) / 100, k = 0, cumulative = 0;
    while (k < cities.length - 1 && (cumulative += cities[k][1]) <= threshold) k++;
    return { member_id: i + 1, loans: Math.max(1, Math.round(a / total * RECORDS)), city: cities[k][0] };
  });
}
const M = 2654435761n;
const hashLow  = (x) => Number((BigInt(x) * M) % 4294967296n);          // low bits
const hashHigh = (x) => Number(((BigInt(x) * M) % 4294967296n) >> 20n); // high bits

function report(label, bucket) {
  const busiest = Math.max(...bucket), quietest = Math.min(...bucket);
  const empty = bucket.filter((s) => s === 0).length;
  const ratio = quietest === 0 ? 'partitions empty: ' + empty : (busiest / quietest).toFixed(2) + 'x';
  console.log(label.padEnd(24), 'partitions', String(bucket.length).padStart(2),
              '| busiest', String(busiest).padStart(8), '| quietest', String(quietest).padStart(8),
              '| ratio', ratio);
}
function distribute(member, P, key) {
  const bucket = new Array(P).fill(0);
  for (const m of member) bucket[key(m.member_id) % P] += m.loans;
  return bucket;
}
const member = members();
console.log('total loans in model:', member.reduce((t, m) => t + m.loans, 0));
console.log('— hash partitioning, near-power-law key —');
for (const P of [8, 16, 64]) {
  report('member_id % P', distribute(member, P, (x) => x));
  report('hash_high % P', distribute(member, P, hashHigh));
}
console.log('— structured key: membership number a multiple of four —');
const fours = member.map((m) => ({ ...m, member_id: m.member_id * 4 }));
report('member_id % 8',  distribute(fours, 8, (x) => x));
report('hash_low % 8',   distribute(fours, 8, hashLow));
report('hash_high % 8',  distribute(fours, 8, hashHigh));
console.log('— list partitioning: city —');
const listBucket = cities.map(([name]) =>
  member.filter((m) => m.city === name).reduce((t, m) => t + m.loans, 0));
report('city', listBucket);
console.log(cities.map(([name], i) => `${name}=${listBucket[i]}`).join('  '));
JS
node distribution.mjs
total loans in model: 2001046
— hash partitioning, near-power-law key —
member_id % P            partitions  8 | busiest   278850 | quietest   238066 | ratio 1.17x
hash_high % P            partitions  8 | busiest   273855 | quietest   233420 | ratio 1.17x
member_id % P            partitions 16 | busiest   158156 | quietest   116396 | ratio 1.36x
hash_high % P            partitions 16 | busiest   154631 | quietest   114336 | ratio 1.35x
member_id % P            partitions 64 | busiest    69604 | quietest    27466 | ratio 2.53x
hash_high % P            partitions 64 | busiest    69318 | quietest    26434 | ratio 2.62x
— structured key: membership number a multiple of four —
member_id % 8            partitions  8 | busiest  1014448 | quietest        0 | ratio partitions empty: 6
hash_low % 8             partitions  8 | busiest  1014448 | quietest        0 | ratio partitions empty: 6
hash_high % 8            partitions  8 | busiest   285482 | quietest   236957 | ratio 1.20x
— list partitioning: city —
city                     partitions  5 | busiest   876922 | quietest   139966 | ratio 6.27x
Istanbul=876922  Ankara=464332  Izmir=312596  Bursa=207230  Konya=139966

Three findings come out of this.

First: hash partitioning balances keys, not rows. At eight partitions the imbalance ratio is 1.17; at sixty-four partitions it is 2.53. As partitions shrink, a single very active member’s weight grows relative to the whole partition, and the hash function’s quality does not change that — the top two rows coming out nearly identical shows this.

Second: the hash function’s quality becomes decisive when the key is structured. With membership numbers all multiples of four, direct division left six of the eight partitions empty. The low bits of the multiplicative hash gave the same result; the reason is arithmetic — multiplying by a single factor and then dividing by a power of two preserves the low bits. Taking the high bits instead brought the distribution back down to a 1.20 ratio. The rule: the low bits of a hash value are not used to choose a partition.

Third: list partitioning never aims for balance at all. Records split by city came out at a 6.27 ratio, and this is not a flaw but reality itself. List partitioning is chosen when partitions need to be managed separately — when one city’s data needs to be stored separately, backed up separately, or deleted separately, imbalance is accepted.

Summary

  • Partitioning stores a table that is logically single across more than one physical piece according to a partition key; range, list, and hash forms differ in how the key is turned into a partition.
  • A query filtering on the partition key does not read the data of irrelevant partitions: in this environment, 141 pages were read through the view against 123 pages read from a single partition directly; the difference is six empty index probes.
  • With an index already in place, partitioning did not buy read performance; the unpartitioned table answered the same query with the same 123 pages.
  • The real gain is removing data as a whole: in this environment, deleting a year and recovering the gap took about 1.08 seconds and freed 1,398 pages; dropping the partition freed 4,367 pages in about 0.06 seconds.
  • Hash partitioning balances keys, not rows; imbalance grew as partition count rose (1.17 → 2.53). On structured keys, the low bits of a hash value cannot be used to choose a partition.

Next Step

Every partition in this lesson lived inside the same database. Distributing partitions across separate machines takes the same idea one step further and changes its nature: a cross-partition query now travels over a network, a transaction can span more than one machine, and changing the partition count forces data to move. The next lesson takes on this pattern — what choosing a shard key imposes on the application, the cost of a cross-shard query, and the amount of data moved during rebalancing, all counted.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close