Thin · light · opinionated for low resource usage

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

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.

7 ms
cold import (PyArrow: 27 ms)
7.7 MB
installed footprint (PyArrow: 126 MB)
zero
runtime dependencies

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.

Metric Rugo PyArrow
Installed footprint 7.7 MB 126 MB
Runtime dependencies zero Arrow C++ runtime
Cold import time 7 ms 27 ms
Schema read (footer only) 0.02 ms 0.03 ms

Measured with Rugo 0.4.10 and PyArrow 25.0.0 on Python 3.14.5t, Apple M-series (macOS arm64 wheels). Import times on a cold process (first load off disk), best of 10. Footprint is the on-disk size of the installed package; neither library pulls a runtime dependency.

Faster cold starts

AWS Lambda and GCP Cloud Functions bill by memory and package size. A 7 ms cold import versus PyArrow's 27 ms adds up fast at scale-out.

📦

Smaller packages & images

At 16× smaller than PyArrow, rugo shrinks Lambda deployment packages and container images, speeds up deploys, and stays well clear of AWS Lambda's 250 MB unzipped layer limit.

🔍

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.

🔒

No surprise dependencies

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.

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, three formats.

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.

Parquet, CSV, JSONL — read and write.

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

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

Performance vs PyArrow — wide file (50 cols, 200k rows, 55 MB)

Query shapeRugoPyArrow
SELECT *~26 ms~17 ms
SELECT 2 cols~9 ms~7 ms
WHERE P90 (~10% pass)~13 ms~27 ms
WHERE P99 (~1% pass)~10 ms~23 ms

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

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

Performance vs PyArrow — 1M rows, 105 cols, 2.3 GB (best of 5 runs, PyArrow multithreaded)

Query shapeRugoPyArrow
SELECT *~1.09 s~1.38 s
SELECT 1 col~215 ms~1.4 s
WHERE ~10% pass~146 ms~1.84 s
WHERE ~1% pass~145 ms~1.83 s

Advantage grows with selectivity — PyArrow reads and decodes every column on every query, regardless of projection or filter. On a wide, real-world row shape, even SELECT * favors Rugo.

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)

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.