Lesson 11 / 12
Partitioning and File Layout
Counting what the choice of partition key and the fineness of the partition make a query pay: four layouts give exactly the same answer to the same three questions (200, 1122 and 46 rows) but their costs part ways. When the filter falls outside the partition key no file can be pruned, and the cost of 4520 in the unpartitioned layout rises to 11000 in the period plus region layout; the whole of the difference is the per-file constant paid fifty-four times more. Even on a pruning query the fineness carries the cost from 520 to 1000 and to 1600, while the cells read stay at 400 in all three layouts: this is the small file problem, and while the fixed share of the cost rises from 120 to 600 and to 1200, the cell share does not change at all. In three different mixes of the same ten queries the cheapest layout turns out to be three different layouts, so the key is chosen by looking at the query mix and not at the data.
Published Updated
In this lesson
- Measures on four layouts that a field in the partition key prunes files and a field outside the key does not.
- Calculates how the fixed share of the cost grows while the cells read stay constant as the partition gets finer, and defines the small file problem.
- Shows that the cheapest layout changes under different weights of the same query mix.
Contents
All the numbers of the previous lesson were measured inside a single file. The partition key was
held fixed as period, the filter always fell on that key and every query touched exactly one file.
For this reason the per-file constant entered no difference at all and the gain of the columnar read
looked clean. The key itself was never questioned.
This lesson removes the constant. Two questions are answered in turn: what happens when the filter falls on a field outside the partition key, and where does splitting the same data into more files move the cost, even if the cells read do not rise at all. The answer to the second question is the clearest example of the second claim of this course.
- MF49. The source, the consumption rows and the file layout are fictional and are produced inside the lesson. No warehouse, lake, file format or query engine is called. The seed is 20260218.
- MF50. Partitioning is distributing rows to separate partitions by the value of a key; in this model each partition is a single file and the two words are used as synonyms. The partitioning procedure was measured in the Relational Database Administration course in the Databases curriculum; it is not repeated.
- MF51. Sharding is a separate concept and is not confused with it: sharding distributes rows to independent nodes that have their own storage. It was measured in the Scaling the Data Layer course in the System Design and Distributed Systems curriculum. In this lesson there is a single storage; what is measured is the file layout, not the distribution over nodes.
- MF52. Partition pruning: if the field in the filter is in the partition key, the files that do not match are never touched. A field outside the key prunes no file and all the files are read from end to end.
- MF53. Cost is the number of files touched times the per-file constant of 120, plus the cells read. The constant is a choice; most of the differences measured in this lesson are of the same kind as the constant itself, and every result is written together with the value of the constant.
- MF54. The query reads two columns (
regionandm3); the read is columnar. The effect of column selection was measured in the previous lesson and is held fixed here. - MF55. The three questions are fixed: A asks for a single period, B for a single type, C for a single region of a single period. The correct answer to all three is independent of the layout.
The Partition Key and Pruning
The block below writes the 2200 rows coming out of the pipeline in four different layouts and asks the three questions in each layout. Next to the number of files, the row counts of the smallest and the largest file are printed as well.
# FICTIONAL. The meter readings of the measurement network , the consumption rows and the file # layout are produced inside the lesson; no data warehouse , data lake , file format or query # engine is called. SEED, M32 = 20260218, 0xFFFFFFFF def rng(t): x = ((t ^ (t >> 16)) * 2246822507) & M32 x = ((x ^ (x >> 13)) * 3266489909) & M32 s = [(x ^ (x >> 16)) & M32] def next_value(): s[0] = (s[0] * 1664525 + 1013904223) & M32 return s[0] / 4294967296 return next_value REGION = ["east", "west", "north", "south", "central"] TYPE = ["residential", "commercial"] PERIODS = [f"2026-{a:02d}" for a in range(1, 13)] FILE_CONSTANT = 120 # the fixed cost of touching a file , in cells SUBSCRIBERS = {} for i in range(200): r = rng(SEED + 37 * i) SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)], "start": 10000 + int(r() * 60000)} def rows(): """Consumption rows coming out of the pipeline. In a healthy source the screen and dedupe stages drop no rows , so consumption is computed directly from the source , by period difference.""" raw = [] for i, (sub, a) in enumerate(SUBSCRIBERS.items()): e = a["start"] for p in PERIODS: r = rng(SEED + 101 * i + 7 * PERIODS.index(p)) e += 4 + int(r() * 46) raw.append({"subscriber": sub, "period": p, "region": a["region"], "type": a["type"], "index": e, "unit": "m3"}) table, out = {(k["subscriber"], k["period"]): k for k in raw}, [] for k in raw: i = PERIODS.index(k["period"]) prev = table.get((k["subscriber"], PERIODS[i - 1])) if i else None if prev is not None: out.append(dict(k, m3=k["index"] - prev["index"])) return out def write(records, partitioning=()): """Distributes the rows to files by the partition key; each partition is a file.""" layout = {} for k in records: layout.setdefault(tuple(k[a] for a in partitioning), []).append(k) return layout def query(layout, partitioning, selected, filters=None): """A filter field in the partition key prunes files; a field outside the key prunes no file and has the whole file read.""" filters = {} if filters is None else filters pruning = {a: v for a, v in filters.items() if a in partitioning} remaining = {a: v for a, v in filters.items() if a not in partitioning} files, cells, matching = 0, 0, 0 for key, records in layout.items(): if pruning: pair = dict(zip(partitioning, key)) if any(pair[a] != v for a, v in pruning.items()): continue files += 1 cells += len(records) * len(selected) matching += sum(1 for k in records if all(k[a] == v for a, v in remaining.items())) return {"files": files, "cells": cells, "matching_rows": matching, "cost": files * FILE_CONSTANT + cells} SELECTED = ("region", "m3") LAYOUTS = [("unpartitioned", ()), ("period", ("period",)), ("period+region", ("period", "region")), ("period+region+type", ("period", "region", "type"))] QUESTIONS = [("A period", {"period": "2026-07"}), ("B type", {"type": "commercial"}), ("C period+region", {"period": "2026-07", "region": "north"})] ALL_ROWS = rows() print(f"pipeline rows {len(ALL_ROWS)} , selected columns {len(SELECTED)} ," f" file constant {FILE_CONSTANT} cells") print("A: period filter , B: type filter , C: period+region filter") print("\nlayout files small large" + "".join(f"{a[0] + ' files/cells/cost':>21}" for a, _ in QUESTIONS)) answer = {} for name, part in LAYOUTS: d = write(ALL_ROWS, part) size = [len(x) for x in d.values()] measured = [] for qname, f in QUESTIONS: s = query(d, part, SELECTED, f) answer.setdefault(qname, set()).add(s["matching_rows"]) measured.append(f"{s['files']}/{s['cells']}/{s['cost']}") print(f"{name:<19} {len(d):>5} {min(size):>5} {max(size):>5}" + "".join(f"{x:>21}" for x in measured)) print("\nmatching rows , in all four layouts: " + " , ".join(f"{a[0]} {sorted(answer[a])[0]}" for a, _ in QUESTIONS))
pipeline rows 2200 , selected columns 2 , file constant 120 cells A: period filter , B: type filter , C: period+region filter layout files small large A files/cells/cost B files/cells/cost C files/cells/cost unpartitioned 1 2200 2200 1/4400/4520 1/4400/4520 1/4400/4520 period 11 200 200 1/400/520 11/4400/5720 1/400/520 period+region 55 31 49 5/400/1000 55/4400/11000 1/92/212 period+region+type 110 12 28 10/400/1600 55/2244/8844 2/92/332 matching rows , in all four layouts: A 200 , B 1122 , C 46
The last line is the precondition of the whole table: all four layouts give exactly the same answer to all three questions, 200, 1122 and 46 rows. What is measured is not correctness but what the same answer costs.
The first row gives the floor. The unpartitioned layout has a single file, every question reads it from end to end and the cost is 4520 on all three questions. This layout is indifferent to the question; it neither rewards nor punishes.
Question A is the promise of partitioning. In the period layout the filter falls on the key, ten
files are never opened and the cells read fall from 4400 to 400; the cost drops from 4520 to
520. Up to here partitioning pays off at every scale.
Question B is the limit of the promise and the clearest example of the second claim of this
course. The filter falls on the type field, type is not among the first two keys of any layout,
so there is no pruning and all the files are read. The cells read are 4400 in three layouts;
the only thing that changes is the number of files: 1, 11 and 55. The cost becomes 4520, 5720
and 11000. The 6480 difference between layouts that read the same cells and return the same
1122 rows is nothing other than the per-file constant being paid 54 times more. Partitioning has
made the work worse here.
The break-even point of this row can also be calculated and it is surprisingly low. Without pruning the cells read are 4400 regardless of the number of files, so the cost grows linearly with the number of files and equals the 4520 of the unpartitioned layout only at a single file. For a filter outside the key, more than one partition is always a loss; not even the size of the loss, but its existence, is open to debate.
The fourth row is the first fix that comes to mind: type is put in the key as well. Question B now
prunes, the files fall to 55 and the cells drop to 2244. The cost, however, is 8844 and is
still nearly twice that of the unpartitioned layout. The 2156 units gained in cells do not cover the
6600 units that are the fixed share of fifty-five files. The price of adding a column to the key for
one query is 110 files, and that price is reflected on all the other queries as well.
The Small File Problem
The second example of the second claim is seen where pruning works. Question A prunes in all three partitioned layouts; the block below splits the cost into two parts.
# This block continues the rows() , write() , query() , SELECTED , ALL_ROWS , LAYOUTS and # QUESTIONS definitions of the first block. print("Question A (pruning filter): fixed share and cell share of the cost") print("\nlayout touched files fixed share cell share cost") for name, part in LAYOUTS: s = query(write(ALL_ROWS, part), part, SELECTED, {"period": "2026-07"}) print(f"{name:<19} {s['files']:>15} {s['files'] * FILE_CONSTANT:>11}" f" {s['cells']:>11} {s['cost']:>7}") MIXES = [("A-heavy", (8, 0, 2)), ("C-heavy", (2, 0, 8)), ("B-heavy", (1, 8, 1))] print("\ntotal cost of the ten-query mix") print("\nmix (A,B,C) " + "".join(f"{a:>20}" for a, _ in LAYOUTS) + " cheapest") for mname, weight in MIXES: total = [] for name, part in LAYOUTS: d = write(ALL_ROWS, part) total.append(sum(n * query(d, part, SELECTED, f)["cost"] for n, (_, f) in zip(weight, QUESTIONS))) print(f"{mname:<19} " + "".join(f"{t:>20}" for t in total) + f" {LAYOUTS[total.index(min(total))][0]}")
Question A (pruning filter): fixed share and cell share of the cost layout touched files fixed share cell share cost unpartitioned 1 120 4400 4520 period 1 120 400 520 period+region 5 600 400 1000 period+region+type 10 1200 400 1600 total cost of the ten-query mix mix (A,B,C) unpartitioned period period+region period+region+type cheapest A-heavy 45200 5200 8424 13464 period C-heavy 45200 5200 3696 5856 period+region B-heavy 45200 46800 89212 72684 unpartitioned
The cell share column is the unread finding of the first table: the cell share is 400 in all three partitioned layouts. The 200 rows the query needs neither rise nor fall; finer partitioning merely distributes them to more files. With no growth at all in the data read, the cost rises from 520 to 1600, that is, triples, and the whole of the increase is in the fixed share: 120, 600, 1200. The small file problem is this: while the data a query reads does not change, the number of files opened to reach that data grows.
The dependence of the result on the constant is written openly here. The difference is entirely of the kind of the per-file constant of 120; had the constant been 60, the costs of the three layouts would have been 460, 700 and 1000, and had it been 480, they would have been 880, 2800 and 5200. The order does not change, because as long as the constant is positive, more files cost more; what changes is the magnitude. The sentence “fine partitioning is expensive” is not measured unless the value of the constant is written.
What the constant stands for has to be said here. Touching a file has a cost independent of its row count: finding the file, opening it, reading its header and working out which column sits where. This work is the same whether the file carries twelve rows or two thousand two hundred. All the model does is write that cost in cells; the number 120 is not a measurement but a stand-in given to reduce the measure to a single unit.
As the number of partitions grows, the files also become unequal. The small and large columns of the first table give this: in the 55-file layout the files are between 31 and 49 rows, in the 110-file layout between 12 and 28. Because subscribers are not evenly distributed over regions, the averages of 40 and 20 rows describe no file; the fixed cost, however, is the same for the smallest file and for the largest. The two files at the extremes make this plain: in the 110-file layout the smallest file has two columns read for 24 cells, and more than four fifths of the cost of touching that file is the constant; the single file of the unpartitioned layout has 4400 cells read and the share of the constant stays under three percent. What partitioning makes you pay is dividing the constant over fewer and fewer rows.
The Key Is Chosen for a Query Mix
The second table gives the total cost of three different mixes of ten queries and carries the
decision of the lesson here. In the A-heavy mix the cheapest layout is period, in the C-heavy mix
period+region, and in the B-heavy mix the unpartitioned layout. Three mixes, three winners.
The middle row says the opposite of the one before it. Because query C applies a filter to both the
period and the region, it goes down to a single file in the period+region layout and gives 92
cells / a cost of 212; the same query reads 400 cells in the period layout. The small file
problem is absent in this query, because the fineness overlaps with the filter of the query. The
same 55-file layout is a penalty in query A and a reward in query C.
The last row is the harshest: in a workload that uses only filters outside the key, not partitioning at all is the cheapest decision, 45200 against 46800, 89212 and 72684. Partitioning is a price that is paid when the query has not been written but for which nothing is received in return.
These three rows are the definition of the rule of the course. A layout’s number is not the rows it stores but what a query pays to touch it; a layout whose query is not written down as the one it was chosen for has not been measured. A partition key cannot be chosen by looking at the data, because all four layouts store the same data and give the same answer. The choice is made only by looking at the query mix, and when the mix changes the decision is measured again.
Summary
- If the field in the filter is in the partition key, the files that do not match are never touched:
query A reads 4400 cells in the unpartitioned layout and 400 in the
periodlayout, and the cost falls from 4520 to 520. - When the filter falls outside the key there is no pruning, and partitioning makes the work worse:
in query B, while the cells read are 4400 in all three layouts, the cost is 4520, 5720 and
11000; the whole of the 6480 difference is the per-file constant paid 54 times more. Adding
typeto the key pulls the cost to 8844, but at the price of 110 files. - Even on a pruning query, fineness writes a penalty: in query A the cells read are 400 in all three partitioned layouts, the cost becomes 520, 1000, 1600 and the whole of the increase is in the fixed share (120, 600, 1200). That is the small file problem.
- The result is of the kind of the per-file constant of 120: at a constant of 60 the three values become 460/700/1000, and at 480 they become 880/2800/5200. The order depends on the sign of the constant, the magnitude on its value.
- The four layouts give the same answer to all three questions (200, 1122, 46 rows); in the three
mixes of ten queries the cheapest layout comes out as
period,period+regionand unpartitioned respectively. The key is chosen by looking at the query mix and not at the data.
Next Step
In all the layouts of this lesson, all twelve periods stood in the same place. No row was deleted, no period was moved to another tier; the oldest period could be read at the same price as the newest. Yet the per-file constant is the same for every file, and the number of files grows with the period. The last lesson turns this accumulation into a decision: when the old periods are taken out of the hot tier, how many files and how much cost remain in the hot tier, what does reading from the archive cost, and which question can no longer be answered retroactively.
To keep your progress and take notes, Log in
My notes
Log in to take notes.