ch01Why Parquet exists
What you see: A tiny table stored both ways, and a count of the bytes each query must touch.
The question#
Why store a table one column at a time, when every program thinks in rows?
An application writes a record, reads a record, updates a record. A CSV file, a JSON Lines file and the pages of most transactional databases all keep each row’s values together, because that is the unit those programs work in. Analytical queries work differently. They read a few columns of a great many rows, and a layout built for whole rows makes them read everything.
This chapter measures that on two files you could have written yourself: the same table of orders as a CSV file and as a Parquet file. Then it shows why the numbers come out as they do. It has no code for you to write. From the next chapter on, most chapters read the bytes of a real file by hand, build what they read into a reader, and check the result against a library you already use.
The experiment#
Two columns from a CSV file#
fixtures/formats/ holds a table of orders twice, both written by pyarrow with its default
settings, as a pipeline’s write_csv and write_table write them. A query wants two of its
columns, country and amount_cents: the total spent in each country. First the CSV file, read
the way programs read CSV, a row at a time, through a file that counts the bytes it hands over:
# Two columns from a CSV file, read the way programs read CSV: a row at a time.
import csv, io, os
from collections import Counter
class Counted(io.FileIO):
"""A file that counts the bytes read from it."""
bytes_read = 0
def readinto(self, buffer):
n = super().readinto(buffer)
self.bytes_read += n
return n
path = "fixtures/formats/orders.csv"
file = Counted(path)
totals = Counter()
for row in csv.DictReader(io.TextIOWrapper(io.BufferedReader(file), newline="")):
# The query wants country and amount_cents; the other fields are read and dropped.
totals[row["country"]] += int(row["amount_cents"])
print(f"read {file.bytes_read} of {os.path.getsize(path)} bytes")
print("amount_cents in the UK:", totals["UK"])
//! Two columns from a CSV file, read the way programs read CSV: a row at a time.
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader, Read};
/// A file that counts the bytes read from it.
struct Counted {
file: File,
bytes_read: u64,
}
impl Read for Counted {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.file.read(buf)?;
self.bytes_read += n as u64;
Ok(n)
}
}
fn main() {
let path = "fixtures/formats/orders.csv";
let file = File::open(path).expect("run this from the repository's root");
let size = file.metadata().unwrap().len();
let mut lines = BufReader::new(Counted {
file,
bytes_read: 0,
});
let mut line = String::new();
lines.read_line(&mut line).unwrap();
let header: Vec<String> = line.trim_end().split(',').map(unquote).collect();
let at = |name: &str| header.iter().position(|h| h == name).unwrap();
let (country, amount) = (at("country"), at("amount_cents"));
let mut totals: HashMap<String, i64> = HashMap::new();
loop {
line.clear();
if lines.read_line(&mut line).unwrap() == 0 {
break;
}
// The query wants country and amount_cents; every other field is read and dropped.
let fields: Vec<String> = line.trim_end().split(',').map(unquote).collect();
*totals.entry(fields[country].clone()).or_default() +=
fields[amount].parse::<i64>().unwrap();
}
println!("read {} of {size} bytes", lines.get_ref().bytes_read);
println!("amount_cents in the UK: {}", totals["UK"]);
}
/// A field without the quotes a CSV writer puts around text.
fn unquote(field: &str) -> String {
field.trim_matches('"').to_string()
}
It read every byte of the file. Each line holds all six columns, and there is no way to reach the next line without reading past the fields you do not want, since nothing says where it starts.
The same two columns from Parquet#
Now the Parquet file, read with a library, through the same kind of counting file:
# The same two columns from the Parquet file of the same table, read with pyarrow.
import io, os
import pyarrow.compute as pc, pyarrow.parquet as pq
class Counted(io.FileIO):
"""A file that counts the bytes read from it, however the library asks for them."""
bytes_read = 0
def readinto(self, buffer):
n = super().readinto(buffer)
self.bytes_read += n
return n
def read(self, size=-1):
data = super().read(size)
self.bytes_read += len(data)
return data
path = "fixtures/formats/orders.parquet"
file = Counted(path)
table = pq.read_table(file, columns=["country", "amount_cents"])
uk = table.filter(pc.equal(table["country"], "UK"))
print(f"read {file.bytes_read} of {os.path.getsize(path)} bytes")
print("amount_cents in the UK:", pc.sum(uk["amount_cents"]).as_py())
//! The same two columns from the Parquet file of the same table, read with the `parquet` crate.
use std::fs::File;
use std::io::Read;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use bytes::Bytes;
use parquet::file::reader::{ChunkReader, FileReader, Length, SerializedFileReader};
use parquet::record::RowAccessor;
use parquet::schema::parser::parse_message_type;
/// A file that counts the bytes read from it, however the crate asks for them.
struct Counted {
file: File,
bytes_read: Arc<AtomicU64>,
}
impl Length for Counted {
fn len(&self) -> u64 {
self.file.len()
}
}
impl ChunkReader for Counted {
type T = CountedRead<<File as ChunkReader>::T>;
fn get_read(&self, start: u64) -> parquet::errors::Result<Self::T> {
Ok(CountedRead(
self.file.get_read(start)?,
self.bytes_read.clone(),
))
}
fn get_bytes(&self, start: u64, length: usize) -> parquet::errors::Result<Bytes> {
self.bytes_read.fetch_add(length as u64, Ordering::Relaxed);
self.file.get_bytes(start, length)
}
}
struct CountedRead<R>(R, Arc<AtomicU64>);
impl<R: Read> Read for CountedRead<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.0.read(buf)?;
self.1.fetch_add(n as u64, Ordering::Relaxed);
Ok(n)
}
}
fn main() {
let path = "fixtures/formats/orders.parquet";
let file = File::open(path).expect("run this from the repository's root");
let size = file.metadata().unwrap().len();
let bytes_read = Arc::new(AtomicU64::new(0));
let reader = SerializedFileReader::new(Counted {
file,
bytes_read: bytes_read.clone(),
})
.expect("a Parquet file");
// Only these two columns, named as the file names them (pyarrow calls the root "schema"):
// the crate reads their chunks and no others.
let wanted = parse_message_type(
"message schema { required binary country (STRING); required int64 amount_cents; }",
)
.unwrap();
let mut uk = 0;
for row in reader.get_row_iter(Some(wanted)).unwrap() {
let row = row.unwrap();
if row.get_string(0).unwrap() == "UK" {
uk += row.get_long(1).unwrap();
}
}
println!(
"read {} of {size} bytes",
bytes_read.load(Ordering::Relaxed)
);
println!("amount_cents in the UK: {uk}");
}
The same answer, from a fraction of the file. In Python the first run loads pyarrow into the
page, which takes a while; in Rust the step needs the parquet crate, so it runs in a Codespace
or at a desk. The two libraries read different amounts. The Rust crate reads the file’s last
bytes, then its footer, then the two columns and nothing else. pyarrow reads a large piece of the
file’s end in its first request, which usually holds the footer and saves a round trip, at the
price of bytes the query did not need. ch10 weighs that trade.
The least any Parquet reader can read, worked out by the book’s reader from each file’s footer, beside what a CSV reader reads:
| Table | CSV file | Read a row at a time | Parquet file | Trailer and footer | Two column chunks | A Parquet reader needs |
|---|---|---|---|---|---|---|
| eight orders, 8 rows | 276 | 276 | 1,705 | 1,160 | 211 | 1,371 |
| the larger table, 5,000 rows | 270,732 | 270,732 | 125,325 | 1,401 | 23,580 | 24,981 |
Computed by the reader from fixtures/formats/, written by pyarrow with its default settings. The query wants country and amount_cents. A row-at-a-time CSV reader reads every byte, because every line holds every column; the Parquet column is the least any Parquet reader can read, found from the file’s footer.
For the larger table, a Parquet reader needs a small part of what a CSV reader reads. For the eight orders it needs more than the whole CSV file, because the footer alone is bigger than the data: Parquet pays a fixed cost for every file, and wins only when a file holds enough rows to repay it. ch15 comes back to that cost, when a table is made of many small files.
One table, two layouts#
Why the difference? A layout is only the order in which a table’s values are stored. Store the eight orders both ways, and mark the values the query needs, one character a value:
# One table, two layouts: the same values in a different order, and the ones a query needs.
import csv
orders = list(csv.reader(open("fixtures/formats/eight-orders.csv")))
header, rows = orders[0], orders[1:]
wanted = {"country", "amount_cents"} # try {"order_id"}, or every column
by_rows = [(name, v) for row in rows for name, v in zip(header, row)]
by_columns = [(name, row[c]) for c, name in enumerate(header) for row in rows]
for label, layout in (("by rows", by_rows), ("by columns", by_columns)):
picture = "".join("#" if name in wanted else "." for name, _ in layout)
runs = len([p for p in picture.split(".") if p])
print(f"{label}, {runs} run{'s' * (runs != 1)}:")
print(picture)
//! One table, two layouts: the same values in a different order, and the ones a query needs.
fn main() {
let text = std::fs::read_to_string("fixtures/formats/eight-orders.csv")
.expect("run this from the repository's root");
let mut lines = text.lines().map(|l| {
l.split(',')
.map(|v| v.trim_matches('"'))
.collect::<Vec<_>>()
});
let header = lines.next().unwrap();
let rows: Vec<Vec<&str>> = lines.collect();
let wanted = ["country", "amount_cents"]; // try ["order_id"], or every column
// Each value with its column's name: by rows, an order's values together; by columns, a
// column's values together.
let by_rows: Vec<(&str, &str)> = rows
.iter()
.flat_map(|row| header.iter().copied().zip(row.iter().copied()))
.collect();
let by_columns: Vec<(&str, &str)> = header
.iter()
.enumerate()
.flat_map(|(c, &name)| rows.iter().map(move |row| (name, row[c])))
.collect();
for (label, layout) in [("by rows", by_rows), ("by columns", by_columns)] {
let picture: String = layout
.iter()
.map(|(name, _)| if wanted.contains(name) { '#' } else { '.' })
.collect();
let runs = picture.split('.').filter(|p| !p.is_empty()).count();
println!("{label}, {runs} run{}:", if runs == 1 { "" } else { "s" });
println!("{picture}");
}
}
By rows, each order’s values sit together, as in the CSV file, so the two columns come in one short run per order and a reader must skip past everything between them. By columns, each column’s values sit together, as in a Parquet file, so the two columns are one run: one read.
Change wanted and run it again. Add columns, and the row layout keeps one run per order until
every column is wanted; the column layout needs one run for each group of neighbouring columns.
Then keep a single order, rows = rows[3:4], and the trade reverses: by rows the order is one
run, and by columns it is a piece of every column. Reading a row back out of columns is called
reconstructing it, and its cost grows with the number of columns. This is the trade Parquet
makes, and the reason it is not a database format.
Why analytical queries favour columns#
An analytical query looks like this:
SELECT country, SUM(amount_cents)
FROM sales
WHERE order_date >= '2026-01-04'
GROUP BY countryIt touches three columns out of five and every row. Real tables have hundreds of columns, and a query typically touches a handful. Three things follow from a column layout, and this chapter has measured the first:
- Projection. A reader fetches only the columns a query names.
- Compression. A column holds values of one type that often repeat or change slowly: the
countrycolumn above repeats a few codes. Similar values side by side encode and compress far better than interleaved rows (ch05, ch07). - Vectorised processing. A processor is fastest running one operation over a run of values of one type. A column is already that run.
The cost is the lookup of one order. Fetching one row means visiting every column, and changing one value means rewriting encoded, compressed blocks. Parquet accepts that: it is a format for data written once and read many times.
Parquet adds the rest of its structure around the column layout. It splits a table into row groups, so that a writer never holds a whole table in memory. It keeps types, encodings and the position of every column chunk in the footer, which is why both libraries read the footer before anything else. ch02 opens a file from its last byte and finds the footer for itself: first by hand, in a few lines of Python or Rust, then with the book’s reader, then with pyarrow.
What this cannot tell you#
How the files compress. The Parquet file is smaller than the CSV file, as well as cheaper to read from, because pyarrow encoded and compressed each column on its own. The two layouts above only reorder values, so they hold the same bytes. ch05 and ch07 show what a column’s encoding and compression do.
Other CSV readers. Some readers split a CSV file among workers, or skip the fields a query does not name while they parse. Each still reads every byte, because only reading a line finds where the next one begins.
What your storage charges. The counting files read from a local disk, where a request costs little. A local disk, an object store and a memory cache have very different request costs, and the best way to fetch changes with them. The shape of the result does not: a column layout turns a scan into a few large reads.
Key takeaways#
- A CSV reader reads every byte, whatever columns you want. Each line holds every column, so reaching the next row means reading past the ones you do not need.
- A Parquet reader reads the footer and the columns it wants. On a table of any size that is a fraction of the file; on a tiny table the footer costs more than the data.
- A layout decides which bytes are next to each other. Row and column layouts can hold identical bytes in a different order.
- A column scan is one range in a column layout and one range per row in a row layout. That is the whole of projection’s advantage, and it grows with the table.
- A whole-row lookup is the reverse. Columns must be visited one by one to reconstruct a row.
- Parquet is built for analytical reads. It trades cheap row access and in-place updates for cheap scans of a few columns.
Problems#
Two, both for reasoning. No test grades them: there is no code to write until the next chapter.
1.1 A wider table. No test. Imagine the sales table with hundreds of columns and millions of
rows, and a query that reads three of its columns for every row. For each layout, say how many
separate ranges the query needs, and how the bytes it moves compare with the size of the table,
both when it fetches range by range and when it fetches the whole object. Check the shape of
your answer by changing wanted in the layouts step. A good answer says that the row layout needs a
range per row, or else the whole object, while the column layout needs at most one range per
column. It also says that the column layout moves about three columns’ worth of bytes, however
many rows there are.
1.2 Your own queries. No test: the workload is yours. Take a table you query often and list the five most frequent queries against it. For each, write down how many of the table’s columns it reads and what fraction of the rows. A good answer sorts the queries into scans and lookups and says which layout suits the mix. If most of them are lookups of whole rows, a columnar format is the wrong tool for that table, and the answer should say so.
Where to go next#
- The column-store idea predates Parquet by decades. Stonebraker and others’ 2005 paper C-Store: A Column-oriented DBMS is a clear statement of the case.
- Parquet’s handling of nested data comes from Google’s Dremel paper, which ch04 builds from.
- ch02 opens a real Parquet file.
Built from commit c32f48e.