prism v0.1.0
Prism
A DataFrame library for Crystal, built directly on Apache Arrow.
Prism is a column-oriented table over Arrow::RecordBatch, with an inspectable expression language, grouped aggregation, Parquet/CSV/IPC interchange, and a lazy query plan that pushes projections and predicates into the Parquet reader — so a large file can be filtered without being loaded.
It is not a wrapper around another dataframe. Every boundary type is an arrow-cr type: Prism::DataFrame holds Array(Arrow::RecordBatch) and one Arrow::Schema, and #to_arrow hands back an Arrow::RecordBatch. There is no second schema dialect to keep in sync.
require "prism"
include Prism::DSL
sales = Prism.read_parquet("data/sales.parquet")
sales
.filter { (col("status") == "completed") & (col("price") > 10.0) }
.group_by("region")
.aggregate(revenue: (col("price") * col("qty")).sum, orders: col("order_id").count)
.sort_by("revenue", order: :desc)
Why Prism
- Arrow all the way down. No pandas/Polars-style copy layer. Columns are Arrow arrays, kernels are Arrow kernels, and Parquet reads decode only the columns and row groups a query needs.
- Expressions are values.
col("price") * col("qty")is data you can inspect, alias, compose and type-check before any row is read.explainshows the plan, so an optimization that stops firing fails a spec instead of silently getting slower. - A lazy planner that does real work. Predicate pushdown (including
RowFilterabsorption and conjunct splitting), projection pruning through joins and aggregations, slice pushdown, and a window pinned toSort/GroupBy/Distinct. Lazy results are asserted equal to the eager pipeline, so the two cannot drift. - O(n) keys, not sort-based guesses.
arrow-rowencodes key columns into canonical bytes, which makesdistinct,n_uniqueand joins hash lookups instead of sorts — and makesNaNkeys match while-0.0/0.0collapse, matching Polars'canonical_f64. - Honest about copies. Element-wise verbs reuse buffers;
filter,sort,joinandgroup_byallocate because the output is a different row set.#to_arrowcopies when the frame is chunked, and the docs say exactly when. - Typed columns when you want them.
define_schemagenerates compile-time accessors, sotyped.select(&.salary)with a misspelt field is a build error. - Nested data, not a flat-only API. List, fixed-size-list, struct and map columns materialize as arrays and hashes the way Polars'
to_listdoes, and they work asdistinct/n_unique/group_bykeys through the samearrow-rowencoding as scalar keys.
Installation
# shard.yml
dependencies:
prism:
github: dsisnero/prism
shards install
Requires Crystal 1.22.2 or newer. arrow-cr and parquet-cr come with it.
Quick start
require "prism"
include Prism::DSL # enables bare col(...) / lit(...)
orders = Prism.from_columns({
order_id: Arrow::Int64Array.from_values([1_i64, 2_i64, 3_i64]),
region: Arrow::StringArray.from_nullable(["east", "west", nil]),
price: Arrow::Float64Array.from_nullable([10.0, 20.0, 30.0]),
qty: Arrow::Int64Array.from_values([1_i64, 2_i64, 3_i64]),
})
orders.shape # => {rows: 3, columns: 4}
orders.column_names # => ["order_id", "region", "price", "qty"]
orders["price"] # => Prism::Series
puts orders.head(2) # aligned table with dtypes and nulls
Mind the parentheses. & binds tighter than == in Crystal, so (col("a") == 1) & (col("b") == 2) is required. col("a").eq(1).and(col("b").eq(2)) is the precedence-safe spelling, and ! cannot be overloaded — negation is .not.
Lazy queries and pushdown
plan = Prism.scan_parquet("data/sales.parquet")
.filter(col("qty") > 2)
.select("region")
.explain
# Plan (optimized):
# Select [region]
# ScanParquet "data/sales.parquet"
# projection: [region, qty]
# predicate: gt(col:qty,lit:2)
rows = Prism.scan_parquet("data/sales.parquet")
.filter(col("qty") > 2)
.select("region")
.collect
df.lazy is the in-memory entry (Prism.scan_parquet reads from a file), verbs build plan nodes, and collect is the only execution point.
Compile-time typed columns
Prism.define_schema UserSchema, id: Int64, name: String, active: Bool, salary: Float64
typed = UserSchema.from_arrow(batch)
typed.filter(&.active).select(&.salary) # a misspelt field is a compile error
Interoperability
batch = Arrow::RecordBatch.from_columns({id: ids, price: prices})
df = Prism.from_arrow(batch)
df.to_arrow == batch # schema, nullability and values preserved
Performance, measured
make bench on one 1e6-row frame (Apple M4, Crystal 1.22.2, best of five). Full table and caveats: docs/benchmarks.md.
| Path | Time |
|---|---|
filter on half the rows |
48 ms |
select / to_arrow (one chunk) |
0 ms — same buffers |
sort_by one key |
202 ms |
sort_by + head(10) |
130 ms — gathers only the window |
group_by 4 keys / 1e5 keys |
220 ms / 381 ms |
distinct / n_unique on 1e5 keys |
54 ms / 48 ms — arrow-row keys |
| join 1e6 → 1e5 | 219 ms |
Parquet read all / 2 of 4 columns / limit 100 |
117 ms / 74 ms / 3 ms |
| Lazy scan+filter+select vs eager | 119 ms vs 142 ms |
Documentation
| Architecture | Chunking, zero-copy rules, expressions, grouping, joins, IO, memory |
| Benchmarks | Scenarios, numbers, and what they decide |
| Testing | Spec layout, oracles, compile-time and example specs |
| Development | Dependencies, layout, environment notes |
| Coding guidelines | Style and review expectations |
| PR workflow | Branch, gates, commit shape |
| Resuming work | Start here after a break |
| Changelog | What changed, release by release |
| Implementation plan | Phase status and recorded design decisions |
Examples
Runnable versions of the snippets above live in examples/:
make examples # quickstart + Parquet pushdown; prints tables
crystal run examples/quickstart.cr # in-memory: expressions, group_by, join
crystal run examples/parquet.cr # write/read, projection + predicate pushdown
crystal run examples/viewer.cr # interactive table browser; `q` quits
The viewer needs the development dependencies (make install). Every example is compiled or run by spec/examples_spec.cr (PRISM_EXAMPLE_SPECS=1), so a README snippet that stops working fails a spec.
Status
v0.1.0 is the first release, with decimal arithmetic and nested materialization landed since: the API in this README is implemented, tested (360 specs plus opt-in compile and example suites) and measured. Deliberately out of scope: no index/row labels, no pivot/melt, no window functions, no SQL or string-expression parser, no multi-threaded engine, and no async/object store IO. Interval columns are the one dtype that still cannot materialize. The plan's deferred table lists every known gap with the phase that would close it.
Contributing
- Read
plans/plan.mdand pick the next unchecked item (or an item from the deferred table). - Follow
docs/pr-workflow.md: one feature, one atomic commit, gates green. make lint && make testbefore opening a pull request;make examplesandmake benchif you touched those paths.
Acknowledgements
The design borrows one axis from each of: Polars (expression engine, lazy plan, pass ordering), Kotlin DataFrame (compile-time schemas), RedAmber (block-first verbs over Arrow), Arquero (verb vocabulary) and Explorer (dplyr verbs over a lazy backend). What was adopted from each — and what was deliberately not — is in plan §4.1.
License
MIT. See LICENSE.
prism
- 0
- 0
- 0
- 0
- 5
- about 3 hours ago
- November 2, 2026
MIT License
Mon, 02 Nov 2026 05:56:40 GMT