Parquet, CSV and JSONL in 7.3 MB. No PyArrow.

Read and write Parquet, CSV, and JSONL. Read Avro.
No PyArrow. No Pandas. No NumPy.

What it costs to read a Parquet file in Python

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
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. 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.

Performance doesn't only come from decoding faster.

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.

⚡

Faster cold starts

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.

📦

Smaller packages & images

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.

🔍

Lower memory, more selectivity

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.

🔒

No surprise dependencies

The wheel bundles everything it needs — Draken, the columnar substrate, ships inside. pip install rugo is the entire dependency story.

What rugo doesn't chase

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.

The gap is projection, not decode speed.

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.

JSONL — 1M rows, 110 cols, 2.0 GB

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
How this was measured

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.

CSV — wide file, 50 cols, 200k rows, 83 MB

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
How this was measured

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.

Avro — 200k rows, 20 fields, deflate, 21.6 MB

Against the two Python Avro readers: fastavro, and avro, the Apache project's reference implementation.

Why there are two rugo columns

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
How this was measured

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.

A different niche, not a smaller PyArrow.

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.

🧮

SQL engines

📊

Embedded analytics

⚡

Serverless data processing

🤖

AI / RAG preprocessing

📦

Containers

⌨️

CLI tools

🪶

Low-memory data applications

One install, four formats.

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.

Parquet, CSV, JSONL — read and write. Avro — read.

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 group
source: str | bytes
filename or buffer
columns: list[str] = None
columns to read; None = all
predicates: list[tuple] = None
(col, op, val) — ops: == != < <= > >= in "not in". Row-group pruning then row-level filter.
read_metadata(source) → ParquetMetadata
source: str | bytes
reads footer only; no column decode
write_parquet(morsel, compression, bloom_filters, max_rows_per_row_group) → bytes
compression: str
"zstd" or "none"
bloom_filters: bool | list[str]
True = all eligible cols; False = none; list = named cols only
max_rows_per_row_group: int
pass 0 for a single row group
open_parquet_writer(sink, compression, bloom_filters) → context manager, constant-memory streaming write
sink: callable
called with each chunk of bytes as row groups are written
writer.write_row_group(morsel)
one parquet row group per call; same schema across calls

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.

Where the Parquet advantage comes from

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)

Run the Parquet notebook in Colab → · download .ipynb

read_csv(source, columns, predicates, delimiter, has_header) → context manager, one Morsel
source: str | bytes
filename or buffer
columns: list[str] = None
columns to read; None = all
predicates: list[tuple] = None
(col, op, val) — ops: == != < <= > >=
delimiter: str = ","
single character; use "\t" for TSV
has_header: bool = True
first row is column names
read_metadata(source) → CsvMetadata
source: str | bytes
scans full file for row count; infers schema from sample
write_csv(morsel, delimiter, header) → bytes (RFC 4180)
delimiter: str = ","
header: bool = True
include column name row

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)

Run the CSV notebook in Colab → · download .ipynb

read_jsonl(source, columns, predicates, explicit_schema, infer_schema) → context manager, one Morsel
source: str | bytes
filename or buffer
columns: list[str] = None
columns to read; None = all
predicates: list[tuple] = None
(col, op, val) — ops: == != < <= > >=
explicit_schema: dict = None
override inferred types per column
infer_schema: bool = True
sample rows to determine types
read_metadata(source) → JsonlMetadata
source: str | bytes
scans full file; infers schema from sample
write_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)

Run the JSONL notebook in Colab → · download .ipynb

read_avro(source, columns, reader_schema) → context manager, Morsels of whole Avro blocks (up to 65,536 rows each)
source: str | bytes
filename or buffer — an Avro object container file
columns: list[str] = None
fields to read; None = all. A dotted name ("device.os") reads one field out of a nested record as a plain column
reader_schema: dict | str = None
the schema to read the file as: fields matched by name (or by field id, as Iceberg writes them), missing fields take their default, int→long→float→double widen
read_metadata(source) → AvroMetadata
source: str | bytes
reads the header only: writer schema, codec, metadata map

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]

Reader only — rugo doesn't write Avro

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: ...

Run the Avro notebook in Colab → · download .ipynb

Vectors and Morsels.

Rugo speaks Draken — a bundled columnar substrate that ships inside the wheel. There are two objects you will encounter.

Vector

A single typed column (similar to an Arrow Array).

to_pylist() → list  ·  materialise as a Python list
is_null() → Vector[bool]  ·  null mask, one bool per row
null_bitmap() → bytes  ·  raw 1-bit-per-row null bitmap
in_list(values) → Vector[bool]  ·  membership test
between(lower, upper) → Vector[bool]  ·  inclusive range test
sum() / min() / max() → scalar  ·  typed reductions; null-aware
take(indices) → Vector  ·  gather rows by index list
to_arrow() → pyarrow.Array  ·  interop; requires PyArrow at runtime
type property  ·  Draken type tag (e.g. DRAKEN_VARCHAR)
len(vec) → int  ·  row count
vec = morsel.column("name")
values = vec.to_pylist()  # ["Mercury", "Venus", …]

Morsel

A batch of rows across columns (similar to an Arrow RecordBatch).

column(name: str) → Vector  ·  return a single column by name
column_names property → list[bytes]  ·  ordered column names
column_types property → list  ·  Draken type per column
schema property → list[dict]  ·  name + type per column
select(col_names: list) → Morsel  ·  project to a subset of columns
rename(new_names: list) → Morsel  ·  rename columns in order
filter_mask(mask: Vector[bool]) → Morsel  ·  keep rows where mask is true
take(indices) → Morsel  ·  gather rows by index list
slice(offset, length) → Morsel  ·  contiguous row window
combine(morsels) classmethod → Morsel  ·  concatenate a list of Morsels
from_vectors(col_names, col_vecs) classmethod → Morsel  ·  construct from column name/vector pairs
to_arrow() → pyarrow.Table  ·  interop; requires PyArrow at runtime
len(morsel) → int  ·  row count
num_columns property → int  ·  column count
nbytes property → int  ·  approximate memory footprint in bytes
for 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.

Where to go next.

📖

Full documentation

Complete API reference, all options, limitations, and design notes.

🚀

Example notebook

A complete workflow on a real dataset: schema inspection, filtered streaming, aggregation, and format conversion.

Run in Colab → View on GitHub
🧪

Try each format

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.

Parquet → CSV → JSONL → Avro →
💻

Source & examples

Rugo is developed inside opteryx-core. Source, examples, and issue tracking are all there.

📦

PyPI

Pre-built wheels for Linux x86-64/aarch64, macOS arm64. Python 3.11+.