Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
285ff96
feat: multi-engine backends prototype with a first DuckDB adapter
Jul 13, 2026
4f32b1e
perf: DuckDB pushdown via a pyarrow Dataset subclass (15-40x)
Jul 13, 2026
524abb3
feat: production-harden the DuckDB pushdown adapter
Jul 13, 2026
6ea9499
perf: whole-partition repeat/tile coordinate fast path in the pivot
Jul 13, 2026
469332b
docs: open rasters with lock=False for parallel tile reads
Jul 13, 2026
11a9ea8
feat: engine-neutral xql.arrow_dataset() — Polars works for free
Jul 13, 2026
8f59fb0
feat: fragments API — DataFusion register_dataset and Dask for free
Jul 13, 2026
eac735a
perf: affine scatter and grid reshape in the eager round-trip
Jul 13, 2026
acd4a7c
feat: engine-neutral materialize() and pyramid() caching helpers
Jul 13, 2026
abfaa95
docs: performance guide
Jul 13, 2026
f9919b6
fix: four adversarial-review findings
Jul 13, 2026
de5263a
fix: honor the batch_size scanner kwarg instead of silently ignoring it
Jul 14, 2026
a8b2ca0
feat: count_rows fast path via chunk arithmetic + strictness analysis
Jul 14, 2026
57df8f6
feat: opt-in coalescing of consecutive chunk reads (coalesce_rows)
Jul 14, 2026
8da7279
fix: share one pre-started prefetch pool across scans
Jul 14, 2026
18c40de
feat: lazy chunked round-trip beyond DataFusion (Polars; DuckDB eager)
Jul 14, 2026
a5bf765
docs: lazy round-trip engine matrix and the scan memory contract
Jul 14, 2026
610843f
bench: ARCO-ERA5 out-of-core benchmark with plan-shape assertions
Jul 14, 2026
bcba93a
feat: GeoArrow point-geometry columns at registration (geometry=)
Jul 14, 2026
3ef15f8
fix: exact float value-list windows on Polars (upstream is_in bug)
Jul 14, 2026
925a5f4
feat: max_result_bytes guard on the eager round-trip
Jul 14, 2026
85a8683
perf: hierarchical strictness — no more 4096-survivor cap, cross-dim …
Jul 14, 2026
a24d4e4
test: fix cross-dim refinement test (own 2D-chunked grid, numpy oracle)
Jul 14, 2026
55191e3
feat: prefetch_bytes — byte-budgeted admission for the prefetch pool
Jul 14, 2026
4f8582e
feat: xql.bbox_conjuncts — the pruning half of geometry predicates
Jul 14, 2026
e49b775
fix: bbox_conjuncts docstring terminated itself (triple quotes in exa…
Jul 14, 2026
728d424
feat: spill= — chunked reconstruction for DuckDB relations and one-sh…
Jul 14, 2026
64f8949
docs: spill= in the engine matrix; DuckDB/one-shot chunked now served
Jul 14, 2026
7312f09
docs: Behaviors & limitations page with decision diagrams
Jul 14, 2026
a233cbb
style: align with repo conventions (ruff-format, AGENTS.md, mypy)
Jul 14, 2026
30ab7b8
docs: engine behavior matrix; put sections where they belong
Jul 14, 2026
28e57cb
docs: separate how-it-works from known issues; Diataxis-style nav
Jul 14, 2026
c2fc218
docs: engine filter tabs on the Known issues page
Jul 14, 2026
f34081a
docs: give materialize and pyramid distinct explanations
Jul 14, 2026
3bc53f1
docs: say plainly that materialize is a thin CTAS wrapper
Jul 14, 2026
49f9775
refactor: remove materialize(); document the CTAS recipe instead
Jul 14, 2026
8ab2fda
docs: explain pyramid with the actual table it builds
Jul 14, 2026
488dcef
refactor: remove pyramid(); the recipe is plain SQL now
Jul 14, 2026
3274f08
refactor: remove the pyramid recipe entirely; make round-trip advice …
Jul 14, 2026
2aace35
docs: bring the DataFusion-era framing up to the multi-engine reality
Jul 14, 2026
821d80a
docs: per-engine notes section in the performance guide
Jul 14, 2026
8b1cac6
bench: per-engine matrix (DuckDB/Polars/DataFusion/xarray) on ARCO-ERA5
Jul 14, 2026
7547df5
docs: add engine-benchmark section to geospatial results
Jul 14, 2026
8c07db9
docs: machine results as tabs (n2-standard-8 / n2-standard-16)
Jul 14, 2026
c112448
docs: drop the interim engine-matrix section
Jul 14, 2026
85698c4
fix: handle ChunkedArray from pa.array in the pivot's batch builders
Jul 14, 2026
a40001e
bench+docs: run the geospatial suite across engines and machine sizes
Jul 14, 2026
64685b7
docs: single-machine engine table, restore native 07-09 numbers
Jul 14, 2026
4d68944
refactor: dead-code and comment sweep over the branch
Jul 14, 2026
426bd62
fix: gate range windows on whole-coordinate monotonicity
Jul 14, 2026
e1879c5
fix: force all prefetch pool threads to start at construction
Jul 14, 2026
b087a7f
fix: enforce max_result_bytes on LazyFrame and to_arrow_table results
Jul 14, 2026
7e5d7fc
fix: pin polars floor and guard collect(engine=) at runtime
Jul 14, 2026
c660401
fix: guard int32 offset overflow in the WKB point encoder
Jul 14, 2026
42814b1
fix: flatten float value-list windows with any_horizontal
Jul 14, 2026
f4a3814
fix: shut the shared prefetch pool down when the dataset dies
Jul 14, 2026
0f26502
fix: stop DuckDBHandle's runner thread when the handle dies
Jul 14, 2026
a2a3155
perf: vectorize the dense-size check's distinct counts
Jul 14, 2026
453b6ca
docs: fix the three broken links
Jul 14, 2026
26145b0
docs: fix stale alxmrs links and site-breaking relative links
Jul 14, 2026
9efe479
docs: put the DataFusion tab first in per-engine sections
Jul 14, 2026
0fda0d5
feat: add a polars extra to mirror the duckdb one
Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Where to start?

Please check out the [issues tab](https://github.com/alxmrs/xarray-sql/issues).
Please check out the [issues tab](https://github.com/xqlsystems/xarray-sql/issues).
Let's have a discussion over there before proceeding with any changes. Great
minds think alike -- someone may have already created an issue related to your
inquiry. If there's a bug, please let us know.
Expand Down
74 changes: 49 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

_Query [Xarray](https://xarray.dev/) with SQL_

[![ci](https://github.com/alxmrs/xarray-sql/actions/workflows/ci.yml/badge.svg)](https://github.com/alxmrs/xarray-sql/actions/workflows/ci.yml)
[![lint](https://github.com/alxmrs/xarray-sql/actions/workflows/lint.yml/badge.svg)](https://github.com/alxmrs/xarray-sql/actions/workflows/lint.yml)
[![ci-build](https://github.com/alxmrs/xarray-sql/actions/workflows/ci-build.yml/badge.svg)](https://github.com/alxmrs/xarray-sql/actions/workflows/ci-build.yml)
[![ci-rust](https://github.com/alxmrs/xarray-sql/actions/workflows/ci-rust.yml/badge.svg)](https://github.com/alxmrs/xarray-sql/actions/workflows/ci-rust.yml)
[![ci](https://github.com/xqlsystems/xarray-sql/actions/workflows/ci.yml/badge.svg)](https://github.com/xqlsystems/xarray-sql/actions/workflows/ci.yml)
[![lint](https://github.com/xqlsystems/xarray-sql/actions/workflows/lint.yml/badge.svg)](https://github.com/xqlsystems/xarray-sql/actions/workflows/lint.yml)
[![ci-build](https://github.com/xqlsystems/xarray-sql/actions/workflows/ci-build.yml/badge.svg)](https://github.com/xqlsystems/xarray-sql/actions/workflows/ci-build.yml)
[![ci-rust](https://github.com/xqlsystems/xarray-sql/actions/workflows/ci-rust.yml/badge.svg)](https://github.com/xqlsystems/xarray-sql/actions/workflows/ci-rust.yml)

```shell
pip install xarray-sql
Expand All @@ -15,7 +15,11 @@ pip install xarray-sql

This is an experiment to provide a SQL interface for array datasets.
Succinctly, we "pivot" Xarray Datasets to treat them like tables so we can run
SQL queries against them.
SQL queries against them — on the query engine of your choice. xarray-sql
translates data, not queries: it registers a lazy Dataset as a table on
DataFusion (built in), DuckDB, or Polars, and turns any engine's Arrow result
back into a labeled Dataset. Dialects, geometry functions, and optimizers stay
with the engine.

## Quickstart

Expand Down Expand Up @@ -58,6 +62,21 @@ clim_ds["air"].plot() # in a script, call matplotlib.pyplot.show() to display
That's the round trip — Xarray in, SQL in the middle, Xarray (and a plot) back
out.

The same Dataset registers on other engines with one call — DuckDB gets a
native lazy table with predicate pushdown, Polars scans the same object:

```python
import duckdb

con = duckdb.connect()
xql.register(con, 'air', ds, chunks=dict(time=100))
rel = con.sql('SELECT time, AVG("air") AS air FROM air GROUP BY time ORDER BY time')
xql.to_dataset(rel, template=ds) # any engine's Arrow result round-trips
```

See [Engines](https://xqlsystems.github.io/xarray-sql/engines/) for the support matrix, DuckDB/Polars details,
and the lazy chunked round-trip.

## A bigger example: ARCO-ERA5

The same interface scales to cloud-native datasets with hundreds of variables,
Expand Down Expand Up @@ -130,15 +149,15 @@ result = ctx.sql('''
# | 775 | -2.3064649711534457 |
# +-------+----------------------+

# `latitude`/`longitude` are inferred from the registered table's surviving
# dims; `template` is kept only to recover metadata (attrs, encoding).
ctx.sql('''
SELECT latitude, longitude, AVG("2m_temperature") - 273.15 AS avg_c
FROM era5.surface
WHERE time BETWEEN TIMESTAMP '2020-01-01'
AND TIMESTAMP '2020-01-01 05:00:00'
GROUP BY latitude, longitude
ORDER BY latitude DESC, longitude
# `latitude`/`longitude` are inferred from the registered table's surviving
# dims; `template` is kept only to recover metadata (attrs, encoding).
''').to_dataset(template=ds)
# <xarray.Dataset> Size: 8MB
# Dimensions: (latitude: 721, longitude: 1440)
Expand All @@ -155,7 +174,7 @@ ctx.sql('''
```

_(A runnable version of this example lives at
[`perf_tests/era5_temp_profile.py`](perf_tests/era5_temp_profile.py).)_
[`perf_tests/era5_temp_profile.py`](https://github.com/xqlsystems/xarray-sql/blob/main/perf_tests/era5_temp_profile.py).)_

## Why build this?

Expand Down Expand Up @@ -188,6 +207,9 @@ pure DataFusion and PyArrow, but works with the same principle!
_2026 update_: Instead of `from_map()`, we create a way to translate Xarray chunks
into Arrow RecordBatches. We pass a Python callback into a DataFusion `TableProvider`
that lets the DB engine translate the underlying Dataset arrays into DataFusion partitions.
The same chunks-to-batches translation is also exposed as a
`pyarrow.dataset.Dataset` with predicate and projection pushdown, which is how
DuckDB and Polars consume registered Datasets with no engine-specific code.
Ultimately, the initial insight of the `pivot()` function -- that any ndarray can be
translated into a 2D table -- underlies this performant query mechanism.

Expand Down Expand Up @@ -217,24 +239,26 @@ against an xarray/array reference** to floating-point tolerance:
Every case matches its array reference. The headline finding: these operations
are not really "array" operations at all — they are `GROUP BY`, `JOIN`, window
functions, and `CASE` in disguise, and a query engine runs them at scale. See
[`benchmarks/geospatial/`](benchmarks/geospatial/) and the write-up,
[Geospatial operations are relational operations](docs/geospatial.md).
[`benchmarks/geospatial/`](https://github.com/xqlsystems/xarray-sql/tree/main/benchmarks/geospatial/) and the write-up,
[Geospatial operations are relational operations](https://xqlsystems.github.io/xarray-sql/geospatial/).

## Why does this work?

Underneath Xarray, Dask, and Pandas, there are NumPy arrays. These are paged in
chunks and represented contiguously in memory. It is only a matter of metadata
that breaks them up into ndarrays. `pivot()`, which uses `to_dataframe()`,
just changes this metadata (via a `ravel()`/`reshape()`), back into a column
amenable to a DataFrame. We take advantage of this light weight metadata change to
make chunked information scannable by a DB engine (DataFusion).
amenable to a DataFrame. We take advantage of this lightweight metadata change to
make chunked information scannable by a DB engine (DataFusion, DuckDB, Polars —
anything that speaks Arrow).

## What are the current limitations?

TBD, DataFusion provides a whole new world! Currently, we're looking for
The sharp edges we know about — per engine and fundamental — are cataloged in
[Known issues & limitations](https://xqlsystems.github.io/xarray-sql/limitations/). Currently, we're looking for
early users – "tire kickers", if you will. We'd love your input to shape the direction of this
project! Please, give this a try and [file issues](https://github.com/alxmrs/xarray-sql/issues) as
you see fit. Check out our [contributing guide](CONTRIBUTING.md), too 😉.
project! Please, give this a try and [file issues](https://github.com/xqlsystems/xarray-sql/issues) as
you see fit. Check out our [contributing guide](https://xqlsystems.github.io/xarray-sql/contributing/), too 😉.

## What would a deeper integration look like?

Expand All @@ -247,7 +271,7 @@ a [virtual](https://fsspec.github.io/kerchunk/)
filesystem for parquet that would internally map to Zarr. Raster-backed virtual
parquet would open up integrations to numerous tools like dask, pyarrow, duckdb,
and BigQuery. More thoughts on this
in [#4](https://github.com/alxmrs/xarray-sql/issues/4).
in [#4](https://github.com/xqlsystems/xarray-sql/issues/4).

_2025 update_: Something like this is being built across a few projects! The ones I know about are:

Expand All @@ -257,18 +281,18 @@ _2025 update_: Something like this is being built across a few projects! The one
_2026 update_: A colleague and I are experimenting with native Zarr RDBMS engines. Check out:

- [Zarr-Datafusion](https://lib.rs/crates/zarr-datafusion)
- [DuckDB-Zarr](https://github.com/alxmrs/duckdb-zarr)
- [DuckDB-Zarr](https://github.com/xqlsystems/duckdb-zarr)

## Roadmap

- [x] ~Lazy evaluation via the pyarrow Dataset interface [#93](https://github.com/alxmrs/xarray-sql/issues/93).~ _Implemented in [#100](https://github.com/alxmrs/xarray-sql/pull/100)_
- [x] Support proper parallelism via proper partition handling on the rust/datafusion side. [#106](https://github.com/alxmrs/xarray-sql/issues/106)
- [x] Support core datafusion optimizations to scan less data, like [104](https://github.com/alxmrs/xarray-sql/issues/104), ...
- [x] Translate a single Zarr to a collection of tables [#85](https://github.com/alxmrs/xarray-sql/issues/85).
- [ ] Distributed beyond a single node through the DataFusion integration with Ray Datasets [#68](https://github.com/alxmrs/xarray-sql/issues/68) or Apache Ballista [#98](https://github.com/alxmrs/xarray-sql/issues/98).
- [ ] Demo: calculate Sea Surface Temperature from 1940 - Present in SQL [#36](https://github.com/alxmrs/xarray-sql/issues/36).
- [ ] Provide an option to integrate DataFusion directly to Zarr via Rust [#4](https://github.com/alxmrs/xarray-sql/issues/4).
- [ ] (To be formally announced eventually): The 100 Trillion Row Challenge [#34](https://github.com/alxmrs/xarray-sql/issues/34).
- [x] ~Lazy evaluation via the pyarrow Dataset interface [#93](https://github.com/xqlsystems/xarray-sql/issues/93).~ _Implemented in [#100](https://github.com/xqlsystems/xarray-sql/pull/100)_
- [x] Support proper parallelism via proper partition handling on the rust/datafusion side. [#106](https://github.com/xqlsystems/xarray-sql/issues/106)
- [x] Support core datafusion optimizations to scan less data, like [#104](https://github.com/xqlsystems/xarray-sql/issues/104), ...
- [x] Translate a single Zarr to a collection of tables [#85](https://github.com/xqlsystems/xarray-sql/issues/85).
- [ ] Distributed beyond a single node through the DataFusion integration with Ray Datasets [#68](https://github.com/xqlsystems/xarray-sql/issues/68) or Apache Ballista [#98](https://github.com/xqlsystems/xarray-sql/issues/98).
- [ ] Demo: calculate Sea Surface Temperature from 1940 - Present in SQL [#36](https://github.com/xqlsystems/xarray-sql/issues/36).
- [ ] Provide an option to integrate DataFusion directly to Zarr via Rust [#4](https://github.com/xqlsystems/xarray-sql/issues/4).
- [ ] (To be formally announced eventually): The 100 Trillion Row Challenge [#34](https://github.com/xqlsystems/xarray-sql/issues/34).

## Sponsors & Contributors

Expand Down
89 changes: 89 additions & 0 deletions benchmarks/duckdb_pushdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Benchmark: DuckDB adapter v1 (stream) vs v2 (pushdown) vs ceilings.

Usage: .venv-duckdb/bin/python bench_v2.py
"""

import time

import duckdb
import numpy as np
import pandas as pd
import pyarrow.dataset as pads
import xarray as xr

import xarray_sql as xql
from xarray_sql.backends.duckdb import XarrayArrowStream

np.random.seed(0)
N_TIME, N_LAT, N_LON = 1000, 100, 100 # 10M rows
ds = xr.Dataset(
{
"temperature": (
["time", "lat", "lon"],
np.random.rand(N_TIME, N_LAT, N_LON),
),
"humidity": (
["time", "lat", "lon"],
np.random.rand(N_TIME, N_LAT, N_LON),
),
},
coords={
"time": pd.date_range("2020-01-01", periods=N_TIME, freq="h"),
"lat": np.linspace(-90, 90, N_LAT),
"lon": np.linspace(-180, 180, N_LON),
},
).chunk({"time": 50}) # 20 partitions

con = duckdb.connect()

QUERIES = {
"full AVG scan": "SELECT AVG(temperature) FROM {t}",
"1pct time filter": (
"SELECT AVG(temperature) FROM {t} WHERE time < '2020-01-01 10:00:00'"
),
"bbox filter": (
"SELECT AVG(temperature) FROM {t} "
"WHERE lat BETWEEN 0 AND 10 AND lon BETWEEN 0 AND 20"
),
"projection (1 of 2 vars)": "SELECT AVG(humidity) FROM {t}",
"count only": "SELECT COUNT(*) FROM {t}",
}


def bench(table, label, n=3):
print(f"\n== {label} ==")
for qname, q in QUERIES.items():
sql = q.format(t=table)
times = []
for _ in range(n):
t0 = time.perf_counter()
r = con.sql(sql).fetchall()
times.append(time.perf_counter() - t0)
print(f" {qname:28s} {min(times):8.3f}s -> {r[0][0]:.6g}")


# v1: re-scannable stream (registered via the stream wrapper explicitly)
con.register("t_v1", XarrayArrowStream(ds))
bench("t_v1", "v1 stream (no pushdown)")

# v2: default register() — pushdown path (once implemented)
xql.register(con, "t_v2", ds)
bench("t_v2", "v2 register() [pushdown]")

# ceiling: materialized pa.Table via pyarrow.dataset
table = xql.read_xarray(ds).read_all()
con.register("t_ceiling", pads.dataset(table))
bench("t_ceiling", "ceiling: in-memory pyarrow.dataset")

# reference: DataFusion engine on the same dataset
ctx = xql.XarrayContext()
xql.register(ctx, "t_df", ds)
print("\n== DataFusion reference ==")
for qname, q in QUERIES.items():
sql = q.format(t="t_df")
times = []
for _ in range(3):
t0 = time.perf_counter()
r = ctx.sql(sql).to_pandas()
times.append(time.perf_counter() - t0)
print(f" {qname:28s} {min(times):8.3f}s -> {float(r.iloc[0, 0]):.6g}")
Loading