| Library | Installed | Cold import | Pulls in |
|---|---|---|---|
| rugo 0.4.43 | 7.3 MB | 12 ms | nothing |
| duckdb 1.5.6 | 46.0 MB | 30 ms | nothing |
| fastparquet 2026.9.0 | 69.6 MB | 532 ms | pandas, numpy, cramjam, fsspec |
| pyarrow 26.0.0 | 126.7 MB | 38 ms | nothing |
| polars 2.0.0 | 170.5 MB | 161 ms | polars-runtime-32 |
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. macOS 26.6 on Apple silicon (arm64),
CPython 3.13, measured 2026-10-10. 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
macOS arm64 wheel is 2.9 MB compressed and 7.3 MB
installed. Footprint also varies by platform: rugo's
Linux x86_64 wheel, the one AWS Lambda uses, is 4.3 MB
compressed and larger once installed.
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 duration, and every cold start pays for the imports. Rugo's cold import is roughly 2.5× to 13× faster than DuckDB, PyArrow or Polars — 12 ms against 30, 38 and 161 — and it adds up fast at scale-out.
At 17× smaller than PyArrow and 23× 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 — and Parquet reads peak at up to 2.6× less memory than PyArrow.
The wheel bundles everything it needs — Draken, the
columnar substrate, ships inside.
pip install rugo is the entire
dependency story.
Raw Parquet decode throughput. PyArrow has had over a decade of optimisation poured into it, and rugo doesn't try to out-decode it. It wins where serverless and embedded workloads actually hurt: a package 17× smaller, a cold start 3× faster, and up to 2.6× less peak memory.
Rugo wins by decoding less — up to 23× faster than PyArrow on selective JSONL reads. The more columns and rows a query lets rugo skip, the bigger the win, and even when it has to read everything it is still 2.6× faster.
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 | ~76 ms | ~1.24 s | 16× faster |
| WHERE ~10% pass | ~72 ms | ~1.65 s | 23× faster |
| WHERE ~1% pass | ~70 ms | ~1.55 s | 22× faster |
| SELECT * | ~471 ms | ~1.25 s | 2.6× faster |
Best of 5 runs, rugo 0.4.43 against PyArrow 26.0.0 (multithreaded), CPython 3.13 on an Apple-silicon Mac, measured 2026-10-10. Even the last row — no projection, no filter, nothing to skip — is a 2.6× win.
On CSV, rugo goes toe-to-toe with PyArrow — within a few of milliseconds on every query shape, and dead level once a predicate drops rows before the typed column build. All from a package a fraction of the size.
| Query shape | Rugo | PyArrow | Ratio |
|---|---|---|---|
| SELECT * | ~25 ms | ~22 ms | ~3 ms behind |
| SELECT 2 cols | ~12 ms | ~10 ms | ~2 ms behind |
| WHERE P90 (~10% pass) | ~11 ms | ~11 ms | parity |
| WHERE P99 (~1% pass) | ~10 ms | ~10 ms | parity |
Same machine and versions as the JSONL table, best of 5 runs. The wider the file and the more selective the query, the better rugo does — every column it skips and every row it drops is work it never pays for.
Against the two Python Avro readers: fastavro, and
avro, the Apache project's reference
implementation.
Rugo reads Avro only — it doesn't write it. Rugo returns typed columns while the others return one Python dict per record, so rugo is also timed with every column converted to Python values: that's the like-for-like column if you want Python objects at the end.
| Query shape | Rugo | Rugo → Python | fastavro | avro (Apache) |
|---|---|---|---|---|
| Every field | ~116 ms | ~213 ms | ~912 ms | ~5.81 s |
| 3 of 20 fields | ~81 ms | ~88 ms | ~712 ms | ~3.48 s |
Best of 5 runs, each reader on one thread, decoding the same
in-memory file: rugo 0.5.0, fastavro 1.13.1, avro 1.12.2,
CPython 3.13 on an Apple-silicon Mac, measured 2026-10-11.
That's 7.8× faster than fastavro and 50× faster than
avro reading every field, and still 4.3× and
27× once rugo's columns are turned into Python values.
fastavro and avro skip fields through a reader
schema; rugo is given columns. They are
smaller installs — 3.1 MB and 0.5 MB against rugo's
8.1 MB, which covers four formats. The file is written by
fastavro, with an event-stream mix of types: scalars,
strings, nullable unions, enums, timestamps, decimals and
nested records, arrays and maps. Reproduce it with
benchmarks/measure_avro.py.
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, and reads Avro. The API is the same shape across all four: 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)
from rugo import avro # Same shape — reader only; rugo doesn't write Avro with avro.read_avro("events.avro", columns=["id", "device.os"]) as reader: for morsel in reader: print(morsel.column("device.os").to_pylist()) meta = avro.read_metadata("events.avro") # writer schema + codec
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
Each format has a notebook you can run in your browser, on the same 4,630 space launches: Parquet, CSV, JSONL and Avro (Google Colab).
A complete worked example on a real dataset is available in Google Colab or as a notebook on GitHub.
Every format supports column projection; Parquet, CSV and JSONL also filter rows with predicates. Writers produce standard output readable by any compliant tool. Avro is reader-only — rugo doesn't write it.
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, GZIP, LZ4 · 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 cut what is decoded and keep peak memory low.
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)
read_avro(source, columns, reader_schema) → context manager, Morsels of whole Avro blocks (up to 65,536 rows each)source: str | bytescolumns: list[str] = None"device.os") reads one field out of a nested record as a plain columnreader_schema: dict | str = Noneread_metadata(source) → AvroMetadatasource: str | bytes
Decode:
null, deflate, snappy, zstandard codecs · all primitive types, enum, fixed, date, time, timestamp-millis/-micros, decimal (up to 38 digits), arrays of scalars · records, maps and arrays of nested values arrive as JSON text · unions of ["null", T]
Avro carries no statistics, so
there's no predicates argument — filter
the morsels you get back. Avro timings are in
Performance.
from rugo import avro # Writer schema and codec, from the header — no record decoded meta = avro.read_metadata("launches.avro") print(meta.codec, meta.columns) # Projected read — dotted names reach into nested records with avro.read_avro( "launches.avro", columns=["Company", "Rocket.Name"], ) as reader: for morsel in reader: print(morsel.column("Rocket.Name").to_pylist()) # Read through a reader schema — drop, add (with defaults) or widen fields schema = {"type": "record", "name": "Launch", "fields": [ {"name": "Mission", "type": "string"}, {"name": "Source", "type": "string", "default": "archive"}, ]} with avro.read_avro("launches.avro", reader_schema=schema) as reader: for morsel in reader: ...
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.
How to use rugo, one notebook per format: read a file, filter on read, load it into pandas, write and convert (Avro: read only). Runs in your browser.
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+.