| Library | Installed | Cold import | Pulls in |
|---|---|---|---|
| rugo 0.4.30 | 11.6 MB | 30 ms | nothing |
| duckdb 1.5.5 | 58.6 MB | 111 ms | nothing |
| fastparquet 2026.5.0 | 147.3 MB | 435 ms | pandas, numpy, cramjam, fsspec |
| pyarrow 25.0.1 | 153.7 MB | 88 ms | nothing |
| polars 1.43.2 | 213.8 MB | 186 ms | polars-runtime-32 |
How this was measured. Installed size
is everything pip install <package>
puts into site-packages, transitive
dependencies included, with a fresh virtualenv per
library so no library is measured in an environment
another has touched. Debian 12, linux x86_64, CPython
3.12.12, measured 2026-08-14. Cold import is the
wall-clock cost of importing each library's Parquet
entry point in a fresh interpreter, over and above bare
interpreter startup, best of 15 runs.
The wheel sizes shown on PyPI are compressed downloads
and are smaller than any of these numbers — rugo's
x86_64 wheel is 4.4 MB compressed and 11.6 MB installed.
Footprint also varies by platform: rugo's macOS arm64
wheel is 3.1 MB compressed.
Traditional libraries optimise decoding speed. Rugo optimises reading less data in the first place — pushing column projection and predicate pushdown ahead of decode, so data you don't want is never touched. Extracted from years of work on the Opteryx SQL engine.
Often it comes from avoiding decoding data you don't need in the first place. Rugo was built as the file layer for the Opteryx SQL engine, where the goals were simple: read as little data as possible, hold as little in memory as possible. Column projection and row-group pruning happen before any decoding — so the columns and rows a query doesn't need are never paid for. The result is a library that gets faster the more selective your query is, stays tiny on disk, and carries no PyArrow, Pandas, or NumPy into your environment.
AWS Lambda and GCP Cloud Functions bill by memory and package size. A 30 ms cold import versus PyArrow's 88 ms, DuckDB's 111 ms or Polars' 186 ms adds up fast at scale-out.
At 13× smaller than PyArrow and 18× smaller than Polars, rugo shrinks Lambda deployment packages and container images, speeds up deploys, and leaves almost all of AWS Lambda's 250 MB unzipped limit for your own code.
Column projection and row-group pruning are first-class citizens. Skip the columns you don't need. Skip the row groups that can't match. The more selective the query, the bigger rugo's advantage.
The wheel bundles everything it needs — Draken, the
columnar substrate, ships inside.
pip install rugo is the entire
dependency story.
Where PyArrow is faster: full-table scans with no filtering. PyArrow's Parquet engine has had over a decade of optimisation poured into raw decode throughput — Rugo doesn't compete there. It competes on how little it has to decode in the first place.
Rugo does not decode faster than PyArrow. It decodes less. That means the numbers below are not a single speedup figure — they move with how selective the read is, and on an unselective read they converge.
This is the case that shows the architecture. PyArrow reads and decodes every column on every query regardless of projection or filter, so asking for one column costs it the same as asking for all 110.
| Query shape | Rugo | PyArrow | Ratio |
|---|---|---|---|
| SELECT 1 col | ~166 ms | ~1.53 s | 9.2× faster |
| WHERE ~10% pass | ~160 ms | ~1.72 s | 10.8× faster |
| WHERE ~1% pass | ~160 ms | ~1.72 s | 10.8× faster |
| SELECT * | ~1.40 s | ~1.52 s | 1.1× faster |
Best of 3 runs, PyArrow multithreaded. Note the last row: with no projection and no filter the advantage nearly vanishes, which is the honest shape of this design.
CSV is the weaker case, and the table shows it. Rugo is at parity on a full scan and at parity on projection; the gap only opens once a predicate eliminates rows before the typed column build.
| Query shape | Rugo | PyArrow | Ratio |
|---|---|---|---|
| SELECT * | ~23 ms | ~21 ms | 10% slower |
| SELECT 2 cols | ~8 ms | ~8 ms | parity |
| WHERE P90 (~10% pass) | ~11 ms | ~22 ms | 2.0× faster |
| WHERE P99 (~1% pass) | ~9 ms | ~21 ms | 2.3× faster |
On narrow CSV files PyArrow is faster across the board. The crossover is driven by how many columns can be skipped and how many rows are eliminated before the typed column build — a narrow file gives Rugo nothing to skip.
Rugo is the file layer for applications that read data selectively rather than scan it wholesale — places where footprint, cold-start time, and memory matter as much as throughput.
Rugo reads and writes Parquet, CSV, and JSONL. The API is the same shape across all three: pass a path or bytes, get columnar data back.
pip install rugo
from rugo import parquet # Read — context manager, yields Morsels with parquet.read_parquet("planets.parquet", columns=["id", "name"], predicates=[("id", ">", 4)]) as reader: for morsel in reader: print(morsel.column("name").to_pylist()) # Metadata — no column decode meta = parquet.read_metadata("planets.parquet") print(meta.num_rows, [c.name for c in meta.schema_columns]) # Write data = parquet.write_parquet(morsel, compression="zstd")
from rugo import csv # Same shape — context manager, yields Morsels with csv.read_csv("data.csv", columns=["name"], predicates=[("age", ">", 30)]) as reader: for morsel in reader: print(morsel.column("name").to_pylist()) meta = csv.read_metadata("data.csv") data = csv.write_csv(morsel)
from rugo import jsonl # Same shape again with jsonl.read_jsonl("events.jsonl", columns=["id", "status"], predicates=[("status", "==", "active")]) as reader: for morsel in reader: print(len(morsel), "rows matched") meta = jsonl.read_metadata("events.jsonl") data = jsonl.write_jsonl(morsel)
The same install puts a rugo command on your
PATH — the reader and writer, driven from the
shell. Inspect, convert, and reshape files without writing
any Python.
# Look at a file rugo info events.parquet # rows, columns, size, format rugo schema events.parquet # column names, types, nullability rugo preview -n 5 events.parquet # first rows as a table rugo describe events.parquet # per-column null counts, min/max
# Reshape and move data between formats rugo convert events.parquet events.jsonl # format inferred from extensions rugo merge part-*.parquet all.parquet # concatenate schema-identical files rugo split --rows 100000 big.parquet # chunk into smaller files rugo diff before.parquet after.parquet # schema differences
# Every verb takes --json, so it composes with jq and shell pipelines rugo count --json events.parquet | jq .num_rows
A complete worked example on a real dataset is available in Google Colab or as a notebook on GitHub.
All three formats support column projection and predicate filtering. Writers produce standard output readable by any compliant tool.
read_parquet(source, columns, predicates) → context manager, one Morsel per row groupsource: str | bytescolumns: list[str] = Nonepredicates: list[tuple] = None(col, op, val) — ops: == != < <= > >= in "not in". Row-group pruning then row-level filter.read_metadata(source) → ParquetMetadatasource: str | byteswrite_parquet(morsel, compression, bloom_filters, max_rows_per_row_group) → bytescompression: str"zstd" or "none"bloom_filters: bool | list[str]max_rows_per_row_group: int0 for a single row groupopen_parquet_writer(sink, compression, bloom_filters) → context manager, constant-memory streaming writesink: callablewriter.write_row_group(morsel)Decode: int32/64, float32/64, boolean, byte_array · UNCOMPRESSED, SNAPPY, ZSTD · PLAIN, RLE_DICTIONARY, DELTA_BINARY_PACKED, DELTA_BYTE_ARRAY
Write: INT8–64, FLOAT32/64, BOOL, VARCHAR, DATE32, TIME, TIMESTAMP64, INTERVAL, DECIMAL, ARRAY — ZSTD or uncompressed, per-column min/max stats, optional bloom filters.
Rugo's Parquet advantage is reading less, not raw scan throughput. Row-group pruning and column projection reduce what is decoded; PyArrow's bulk scan is faster when all columns and rows are needed.
from rugo import parquet # Metadata only — no column decode meta = parquet.read_metadata("planets.parquet") print(meta.num_rows) print([c.name for c in meta.schema_columns]) # Selective streaming read with parquet.read_parquet( "planets.parquet", columns=["id", "name"], predicates=[("id", ">", 4)], ) as reader: for morsel in reader: names = morsel.column("name").to_pylist() # Write (ZSTD default) data = parquet.write_parquet(morsel) with open("out.parquet", "wb") as f: f.write(data) # Stream batches at constant memory — one row group per call with open("out.parquet", "wb") as f: with parquet.open_parquet_writer(f.write) as writer: for batch in batches: writer.write_row_group(batch)
read_csv(source, columns, predicates, delimiter, has_header) → context manager, one Morselsource: str | bytescolumns: list[str] = Nonepredicates: list[tuple] = None(col, op, val) — ops: == != < <= > >=delimiter: str = ",""\t" for TSVhas_header: bool = Trueread_metadata(source) → CsvMetadatasource: str | byteswrite_csv(morsel, delimiter, header) → bytes (RFC 4180)delimiter: str = ","header: bool = True
Type inference: int64 → float64 → VARCHAR → null.
CSV timings are in Performance.
from rugo import csv # Schema without reading all data meta = csv.read_metadata("data.csv") print(meta.num_rows, [c["name"] for c in meta.schema_columns]) # Projected + filtered read with csv.read_csv( "data.csv", columns=["name", "score"], predicates=[("age", ">", 30)], ) as reader: for morsel in reader: print(morsel.column("name").to_pylist()) # TSV variant with csv.read_csv("data.tsv", delimiter="\t") as reader: for morsel in reader: ... # Write csv_bytes = csv.write_csv(morsel)
read_jsonl(source, columns, predicates, explicit_schema, infer_schema) → context manager, one Morselsource: str | bytescolumns: list[str] = Nonepredicates: list[tuple] = None(col, op, val) — ops: == != < <= > >=explicit_schema: dict = Noneinfer_schema: bool = Trueread_metadata(source) → JsonlMetadatasource: str | byteswrite_jsonl(morsel) → bytes
Inferred types: int64, float64, bool, str, bytes, null, array[T].
JSONL timings are in Performance.
from rugo import jsonl # Schema without reading all data meta = jsonl.read_metadata("events.jsonl") print(meta.num_rows, [c["name"] for c in meta.schema_columns]) # Projected + filtered read with jsonl.read_jsonl( "events.jsonl", columns=["id", "status"], predicates=[("status", "==", "active")], ) as reader: for morsel in reader: print(len(morsel), "rows matched") # Write a Morsel to JSONL bytes jsonl_bytes = jsonl.write_jsonl(morsel)
Rugo speaks Draken — a bundled columnar substrate that ships inside the wheel. There are two objects you will encounter.
A single typed column (similar to an Arrow Array).
to_pylist() → list · materialise as a Python listis_null() → Vector[bool] · null mask, one bool per rownull_bitmap() → bytes · raw 1-bit-per-row null bitmapin_list(values) → Vector[bool] · membership testbetween(lower, upper) → Vector[bool] · inclusive range testsum() / min() / max() → scalar · typed reductions; null-awaretake(indices) → Vector · gather rows by index listto_arrow() → pyarrow.Array · interop; requires PyArrow at runtimetype property · Draken type tag (e.g. DRAKEN_VARCHAR)len(vec) → int · row countvec = morsel.column("name") values = vec.to_pylist() # ["Mercury", "Venus", …]
A batch of rows across columns (similar to an Arrow RecordBatch).
column(name: str) → Vector · return a single column by namecolumn_names property → list[bytes] · ordered column namescolumn_types property → list · Draken type per columnschema property → list[dict] · name + type per columnselect(col_names: list) → Morsel · project to a subset of columnsrename(new_names: list) → Morsel · rename columns in orderfilter_mask(mask: Vector[bool]) → Morsel · keep rows where mask is truetake(indices) → Morsel · gather rows by index listslice(offset, length) → Morsel · contiguous row windowcombine(morsels) classmethod → Morsel · concatenate a list of Morselsfrom_vectors(col_names, col_vecs) classmethod → Morsel · construct from column name/vector pairsto_arrow() → pyarrow.Table · interop; requires PyArrow at runtimelen(morsel) → int · row countnum_columns property → int · column countnbytes property → int · approximate memory footprint in bytesfor morsel in reader: print(len(morsel), "rows") vec = morsel.column("id")
A read → write round-trip works naturally:
read_parquet yields Morsels,
write_parquet / write_csv /
write_jsonl all consume a Morsel.
from rugo import parquet from rugo.csv import write_csv from rugo.jsonl import write_jsonl with parquet.read_parquet("planets.parquet") as reader: for morsel in reader: csv_bytes = write_csv(morsel) jsonl_bytes = write_jsonl(morsel) pq_bytes = parquet.write_parquet(morsel)
Rugo isn't designed to decode data faster.
It's designed to avoid decoding data you don't
need.
Rugo was born out of Opteryx's need for a fast, low-memory Parquet reader — thin by design, and opinionated about what not to load.
Complete API reference, all options, limitations, and design notes.
A complete workflow on a real dataset: schema inspection, filtered streaming, aggregation, and format conversion.
Rugo is developed inside opteryx-core.
Source, examples, and issue tracking are all there.
Pre-built wheels for Linux x86-64/aarch64, macOS arm64. Python 3.11+.