Data: Parquet and SQL with DataFusion
DataFusion is a query engine you embed in your own program: you give it Parquet or CSV files, and it answers SQL. The crate docs call it “an extensible query engine written in Rust that uses Apache Arrow as its in-memory format”. Its FAQ says how its authors see the neighbours: DuckDB is mostly used directly as a database, while DataFusion is “designed to be embedded”; Polars is also written in Rust and uses the Arrow memory model, but has fewer extension points. That is the project describing itself, not a comparison I ran. Polars’ own docs say it uses the Arrow columnar format too, and the DataFusion introduction page lists InfluxDB among its known users.
RememberRegister a file as a table, then ask questions in SQL or with the DataFrame API.
sales-report/ ├── examples/ two more programs, further down │ ├── explain.rs │ └── results.rs ├── src/ │ └── main.rs the program on this page ├── Cargo.lock created by Cargo └── Cargo.toml dependencies go here
[package]name = "sales-report"version = "0.1.0"edition = "2024"
[dependencies]datafusion = "55.2.0"tokio = { version = "1.54.1", features = ["full"] }use std::sync::Arc;
use datafusion::arrow::array::{Float64Array, Int64Array, StringArray};use datafusion::arrow::datatypes::{DataType, Field, Schema};use datafusion::arrow::record_batch::RecordBatch;use datafusion::parquet::arrow::ArrowWriter;use datafusion::prelude::*;
#[tokio::main]async fn main() -> datafusion::error::Result<()> { // 1. Build a small table in memory as Arrow columns, then save it as Parquet. let schema = Arc::new(Schema::new(vec![ Field::new("city", DataType::Utf8, false), Field::new("month", DataType::Int64, false), Field::new("sales", DataType::Float64, false), ])); let batch = RecordBatch::try_new( schema.clone(), vec![ Arc::new(StringArray::from(vec![ "Pune", "Pune", "Delhi", "Delhi", "Chennai", ])), Arc::new(Int64Array::from(vec![1, 2, 1, 2, 1])), Arc::new(Float64Array::from(vec![120.0, 135.5, 210.0, 190.25, 99.0])), ], )?; let path = std::env::temp_dir().join("sales.parquet"); let mut writer = ArrowWriter::try_new(std::fs::File::create(&path)?, schema, None)?; writer.write(&batch)?; writer.close()?;
// 2. Register the file as a table and ask a question in SQL. let ctx = SessionContext::new(); ctx.register_parquet( "sales", path.to_str().unwrap(), ParquetReadOptions::default(), ) .await?; ctx.sql("SELECT city, SUM(sales) AS total FROM sales GROUP BY city ORDER BY total DESC") .await? .show() .await?;
// 3. A similar question with the DataFrame API. Both queries are sorted, so the // rows come out in the same order every time. ctx.table("sales") .await? .filter(col("month").eq(lit(2)))? .select(vec![col("city"), col("sales")])? .sort(vec![col("sales").sort(false, true)])? .show() .await?; Ok(())}+---------+--------+ | city | total | +---------+--------+ | Delhi | 400.25 | | Pune | 255.5 | | Chennai | 99.0 | +---------+--------+ +-------+--------+ | city | sales | +-------+--------+ | Delhi | 190.25 | | Pune | 135.5 | +-------+--------+
Both queries end in a sort, so the rows come out in the same order on every run. This page is about DataFusion 55.2.0, as pinned in Cargo.lock.
- Arrow is the in-memory format. The arrow-rs docs describe a
RecordBatchas “a two-dimensional batch of column-oriented data with a defined schema”. The program builds one from three arrays. - Parquet is the file format. Its project page calls it “an open source, column-oriented data file format designed for efficient data storage and retrieval”.
ArrowWriterwrote the file andregister_parquetmade it a table namedsales. - Two ways to ask: a SQL string, or DataFrame calls (
filter,select,sort). TheDataFramedocs say most DataFrame methods only build up a plan (the steps DataFusion will take; What the plan says shows one), and thatcollectruns it “using the same DataFusion planning and execution process used to execute SQL”. - It is async, and runs on Tokio. That is why
mainis#[tokio::main]and the calls that do the work (register_parquet,sql,show) end in.await;filter,selectandsortonly build the plan, so they do not. The crate docs say DataFusion runs each plan on multiple cores “using a Tokio Runtime as a thread pool”, with the number of partitions (pieces of the data that can be processed side by side) set bytarget_partitions, which “defaults to the number of CPU cores”. For Tokio itself see the Tokio guide; for the idea of a future see Async Basics. - Use DataFusion’s copies of Arrow and Parquet (
datafusion::arrow,datafusion::parquet), as the program does. The crate docs list both as re-exports. The section on adding your ownarrowcrate shows what goes wrong otherwise. - The first build is big. The lock file lists 286 packages, including this program:
$ cargo tree --depth 1 sales-report v0.1.0 (/home/you/sales-report) ├── datafusion v55.2.0 └── tokio v1.54.1 $ cargo tree -i arrow --depth 0 arrow v59.3.1 $ grep -c '^name = ' Cargo.lock 286
I did not time the build. After the examples on this page were built, the debug build folder for this project measured 6.8 GB with du on this VM (once, so read it as “several gigabytes”).
What the plan says
Section titled “What the plan says”A plan is the list of steps the engine will run; the logical plan says what to compute, the physical plan says how, and the optimizer rewrites the first before the second is made (Reading explain plans describes the lines). Whether DataFusion skips the columns and row groups a query does not need is something you can check. examples/explain.rs writes the same five rows with two rows per row group, asks the same question in SQL and with DataFrame calls, and prints the plans. A row group is, in the words of the Parquet concepts page, “a logical horizontal partitioning of the data into rows”.
use std::sync::Arc;
use datafusion::arrow::array::{Float64Array, Int64Array, StringArray};use datafusion::arrow::datatypes::{DataType, Field, Schema};use datafusion::arrow::record_batch::RecordBatch;use datafusion::parquet::arrow::ArrowWriter;use datafusion::parquet::file::properties::WriterProperties;use datafusion::physical_plan::{ExecutionPlan, collect, displayable};use datafusion::prelude::*;
#[tokio::main]async fn main() -> datafusion::error::Result<()> { // The same five rows as the main program, but written two rows per Parquet row group. let schema = Arc::new(Schema::new(vec![ Field::new("city", DataType::Utf8, false), Field::new("month", DataType::Int64, false), Field::new("sales", DataType::Float64, false), ])); let batch = RecordBatch::try_new( schema.clone(), vec![ Arc::new(StringArray::from(vec![ "Pune", "Pune", "Delhi", "Delhi", "Chennai", ])), Arc::new(Int64Array::from(vec![1, 2, 1, 2, 1])), Arc::new(Float64Array::from(vec![120.0, 135.5, 210.0, 190.25, 99.0])), ], )?; let path = std::env::temp_dir().join("sales_groups.parquet"); let props = WriterProperties::builder() .set_max_row_group_row_count(Some(2)) .build(); let mut writer = ArrowWriter::try_new(std::fs::File::create(&path)?, schema, Some(props))?; writer.write(&batch)?; writer.close()?;
// One partition, so the plan does not depend on how many CPU cores this machine has // (the default is one partition per core). let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1)); ctx.register_parquet( "sales", path.to_str().unwrap(), ParquetReadOptions::default(), ) .await?;
// The same question asked two ways. // `cargo run --example explain -- 100` changes the number (200 if you give none). let limit: f64 = std::env::args() .nth(1) .map_or(200.0, |a| a.parse().expect("a number")); let from_sql = ctx .sql(&format!( "SELECT city, sales FROM sales WHERE sales > {limit}" )) .await?; let from_api = ctx .table("sales") .await? .filter(col("sales").gt(lit(limit)))? .select(vec![col("city"), col("sales")])?; println!( "-- as written (SQL)\n{}", from_sql.logical_plan().display_indent() ); println!( "-- as written (DataFrame)\n{}", from_api.logical_plan().display_indent() );
// The optimizer rewrites both. The unoptimized plans differ (Int64(200) against // Float64(200)); the optimized ones are equal. let sql_plan = from_sql.clone().into_optimized_plan()?; let api_plan = from_api.into_optimized_plan()?; println!("-- optimized\n{}", sql_plan.display_indent()); println!("optimized plans equal: {}", sql_plan == api_plan);
// Run the physical plan, then ask it what the Parquet reader did. let physical = from_sql.create_physical_plan().await?; // The plan prints the temp folder of the machine that runs it; show a placeholder // instead, so the page does not depend on that path (the plan drops the leading `/`). let shown = displayable(physical.as_ref()).indent(false).to_string().replace( path.to_str().unwrap().trim_start_matches('/'), "<temp dir>/sales_groups.parquet", ); println!("-- physical\n{shown}"); let rows: usize = collect(physical.clone(), ctx.task_ctx()) .await? .iter() .map(|b| b.num_rows()) .sum(); println!("rows returned: {rows}"); print_metrics(physical.as_ref()); Ok(())}
fn print_metrics(node: &dyn ExecutionPlan) { if let Some(v) = node .metrics() .and_then(|set| set.sum_by_name("row_groups_pruned_statistics")) { println!("{}: row_groups_pruned_statistics = {v}", node.name()); } for child in node.children() { print_metrics(child.as_ref()); }}-- as written (SQL)
Projection: sales.city, sales.sales
Filter: sales.sales > Int64(200)
TableScan: sales
-- as written (DataFrame)
Projection: sales.city, sales.sales
Filter: sales.sales > Float64(200)
TableScan: sales
-- optimized
Filter: sales.sales > Float64(200)
TableScan: sales projection=[city, sales], partial_filters=[sales.sales > Float64(200)]
optimized plans equal: true
-- physical
FilterExec: sales@1 > 200
DataSourceExec: file_groups={1 group: [[<temp dir>/sales_groups.parquet]]}, projection=[city, sales], file_type=parquet, predicate=sales@2 > 200, pruning_predicate=sales_null_count@1 != row_count@2 AND sales_max@0 > 200, required_guarantees=[]
rows returned: 1
DataSourceExec: row_groups_pruned_statistics = 3 total → 1 matched$ cargo run --example explain -- 100 | tail -2rows returned: 4 DataSourceExec: row_groups_pruned_statistics = 3 total → 2 matched -> 2 fully matched
What the run showed:
- The two queries start as different plans (
Int64(200)againstFloat64(200)), and after the optimizer rewrites them the plans are equal. That is for this query only; I did not test others. TableScansaysprojection=[city, sales]: themonthcolumn is not in the scan’s projection.- The scan carries a
pruning_predicatethat comparessales_max, a statistic of the column, with 200, and the metric printedrow_groups_pruned_statistics = 3 total → 1 matched: two of the three row groups were ruled out by their statistics. - The example sets
with_target_partitions(1)so the plan does not depend on how many cores the machine has. When I ran it by hand without that line on this 8-core VM, the physical plan had an extraRepartitionExecstep with 8 partitions (that run is not one of the sessions on this page). - The physical plan names the file by its temp folder, which differs per machine, so the example replaces that part with
<temp dir>before printing. - The file is five rows, so this shows the mechanism and says nothing about speed. I measured no times.
Results as data, and mistakes
Section titled “Results as data, and mistakes”examples/results.rs reads a CSV file instead, with register_csv. A CSV file has no column types, so DataFusion inferred them from the data; the first lines of the output show what it chose. collect returns the answer as Arrow arrays you can read from code, and mistakes in SQL come back as Err values:
use datafusion::arrow::array::AsArray;use datafusion::arrow::datatypes::Float64Type;use datafusion::prelude::*;
#[tokio::main]async fn main() -> datafusion::error::Result<()> { // A CSV file this time: DataFusion reads the header and guesses the column types. let path = std::env::temp_dir().join("sales.csv"); std::fs::write( &path, "city,month,sales\nPune,1,120.0\nPune,2,135.5\nDelhi,1,210.0\nDelhi,2,190.25\nChennai,1,99.0\n", )?;
let ctx = SessionContext::new(); ctx.register_csv("sales", path.to_str().unwrap(), CsvReadOptions::new()) .await?;
for field in ctx.table("sales").await?.schema().fields() { println!("column {} has type {}", field.name(), field.data_type()); }
// collect() gives the result back as Arrow record batches, so you can use it in code. let batches = ctx .sql("SELECT city, SUM(sales) AS total FROM sales GROUP BY city ORDER BY total DESC") .await? .collect() .await?; for batch in &batches { let city = batch.column_by_name("city").unwrap().as_string::<i32>(); let total = batch .column_by_name("total") .unwrap() .as_primitive::<Float64Type>(); for i in 0..batch.num_rows() { println!("{} -> {:.2}", city.value(i), total.value(i)); } }
// Mistakes come back as errors, not panics. for sql in [ "SELECT * FROM sale", "SELECT cty FROM sales", "SELEC city FROM sales", ] { match ctx.sql(sql).await { Ok(_) => println!("{sql}: ok"), Err(e) => println!("{sql}\n Error: {e}"), } } Ok(())}column city has type Utf8
column month has type Int64
column sales has type Float64
Delhi -> 400.25
Pune -> 255.50
Chennai -> 99.00
SELECT * FROM sale
Error: Error during planning: table 'datafusion.public.sale' not found
SELECT cty FROM sales
Error: Schema error: No field named cty. Did you mean 'sales.city'?
Valid fields are sales.city, sales.month, sales.sales.
SELEC city FROM sales
Error: SQL error: ParserError("Expected: an SQL statement, found: SELEC at Line: 1, Column: 1")Adding your own arrow crate
Section titled “Adding your own arrow crate”cargo add arrow is a natural thing to type. On 25 October 2026 the newest release on crates.io was 60.0.0, while DataFusion 55.2.0 depends on arrow ^59.2.0 (docs.rs dependency list) and the lock file has 59.3.1 (the tree output above prints arrow v59.3.1). Those are Arrow’s version numbers, not DataFusion’s: DataFusion is 55.2.0. Two versions of a crate are two different crates to the compiler, so a RecordBatch from one is not a RecordBatch from the other. The session below builds a shorter program than the one above, not the main program: it has arrow = "=60.0.0" added to Cargo.toml, takes its imports from arrow instead of datafusion::arrow, and hands one column to register_batch:
[package]name = "sales-report"version = "0.1.0"edition = "2024"
[dependencies]datafusion = "55.2.0"tokio = { version = "1.54.1", features = ["full"] }arrow = "=60.0.0"use std::sync::Arc;
use arrow::array::Int64Array;use arrow::datatypes::{DataType, Field, Schema};use arrow::record_batch::RecordBatch;use datafusion::prelude::*;
#[tokio::main]async fn main() -> datafusion::error::Result<()> { let schema = Arc::new(Schema::new(vec![Field::new("month", DataType::Int64, false)])); let batch = RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(vec![1, 2]))]).unwrap();
let ctx = SessionContext::new(); ctx.register_batch("months", batch)?; ctx.sql("SELECT month FROM months").await?.show().await?; Ok(())}error[E0308]: mismatched types
--> src/main.rs:14:34
|
14 | ctx.register_batch("months", batch)?;
| -------------- ^^^^^ expected `datafusion::arrow::array::RecordBatch`, found `arrow::array::RecordBatch`
| |
| arguments to this method are incorrect
... (notes pointing into library source code trimmed)When I ran cargo build by hand, the full message also had a note saying “there are multiple different versions of crate arrow_array in the dependency graph”. The page trims notes that point into library sources. The fix is the one the program already uses: import from datafusion::arrow, or choose the arrow version DataFusion uses (cargo tree -i arrow --depth 0 above shows it).
The same job in your language
Section titled “The same job in your language”Only Python ran for this page. The other rows only name tools, and I did not run them. A name here is not a recommendation or a popularity claim, and I did not check that each tool does both Parquet and SQL: the notes say only what the linked page says.
| Language | Tools named, and what the linked page says | Ran here |
|---|---|---|
| Go | DuckDB’s Go client (uses cgo and needs a C compiler, per DuckDB’s docs); arrow-go, the Go implementation of Arrow, which also has Parquet | no |
| Python | pandas, Polars, DuckDB, PyArrow, DataFusion’s own Python bindings | yes, below |
| TypeScript | DuckDB-Wasm and DuckDB’s Node.js client, both in DuckDB’s client list; Apache Arrow JS, for Arrow data (its page does not mention Parquet or SQL) | no |
| Java | DuckDB’s JDBC driver, in DuckDB’s client list; Apache Spark (named only) | no |
| Kotlin | Kotlin DataFrame (its page lists SQL databases as a data source and does not mention Parquet) | no |
| C# | DuckDB.NET, a .NET client listed in DuckDB’s client overview; Microsoft.Data.Analysis, a DataFrame type (its page mentions neither Parquet nor SQL) | no |
| C | the DuckDB C API | no |
| C++ | Arrow C++ (with its Acero engine); DuckDB’s C++ API | no |
The runner image has Python but no pip, so setup.sh downloads pip and installs the packages from requirements.txt into a scratch folder. This session needs internet access and cannot be reproduced offline: with the network switched off, setup.sh failed at its first step (curl could not resolve bootstrap.pypa.io). Only the five packages in requirements.txt are pinned; get-pip.py is fetched unpinned and run as downloaded, and the packages’ own dependencies are not pinned either, so a later run may install different versions (the script prints the five it got). On your own machine, a virtual environment and pip install -r requirements.txt do the same job. compare.py writes the same five rows to Parquet and asks each library for the total per city:
import duckdbimport datafusionimport pandas as pdimport polars as plimport pyarrow as paimport pyarrow.parquet as pq
# The same five rows as the Rust program, written to a Parquet file by PyArrow.pq.write_table( pa.table({ "city": ["Pune", "Pune", "Delhi", "Delhi", "Chennai"], "month": [1, 2, 1, 2, 1], "sales": [120.0, 135.5, 210.0, 190.25, 99.0], }), "sales.parquet",)
# Total sales per city, biggest first, with each library.results = {}
pdf = pd.read_parquet("sales.parquet").groupby("city")["sales"].sum().sort_values(ascending=False)results["pandas"] = list(pdf.items())
plf = pl.read_parquet("sales.parquet").group_by("city").agg(pl.col("sales").sum().alias("total")).sort("total", descending=True)results["polars"] = plf.rows()
results["duckdb"] = duckdb.sql( "SELECT city, SUM(sales) AS total FROM 'sales.parquet' GROUP BY city ORDER BY total DESC").fetchall()
ctx = datafusion.SessionContext()ctx.register_parquet("sales", "sales.parquet")batches = ctx.sql("SELECT city, SUM(sales) AS total FROM sales GROUP BY city ORDER BY total DESC").collect()results["datafusion"] = [row for b in batches for row in zip(b.column("city").to_pylist(), b.column("total").to_pylist())]
for name, rows in results.items(): print(f"{name:<10}", [(c, float(t)) for c, t in rows])print("all agree:", len({str([(c, float(t)) for c, t in r]) for r in results.values()}) == 1)print("versions:", ", ".join(f"{m.__name__} {m.__version__}" for m in (pd, pl, duckdb, pa, datafusion)))#!/bin/bash# The runner image has Python but no pip, so fetch pip, then install the pinned# packages into a scratch folder (nothing is installed system-wide).set -ecd "$(dirname "$0")"{ curl -sS https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py python3 /tmp/get-pip.py -q --break-system-packages --target /tmp/pylibs PYTHONPATH=/tmp/pylibs python3 -m pip install -q --target /tmp/pylibs -r requirements.txt} > /tmp/pip.log 2>&1 || { cat /tmp/pip.log; exit 1; }echo installedpandas==3.1.0polars==2.0.0duckdb==2.0.0pyarrow==26.0.0datafusion==55.0.0installed$ python3 compare.py
pandas [('Delhi', 400.25), ('Pune', 255.5), ('Chennai', 99.0)]
polars [('Delhi', 400.25), ('Pune', 255.5), ('Chennai', 99.0)]
duckdb [('Delhi', 400.25), ('Pune', 255.5), ('Chennai', 99.0)]
datafusion [('Delhi', 400.25), ('Pune', 255.5), ('Chennai', 99.0)]
all agree: True
versions: pandas 3.1.0, polars 2.0.0, duckdb 2.0.0, pyarrow 26.0.0, datafusion 55.0.0All four gave the same three rows. The Python datafusion package is DataFusion’s own engine behind a Python API; its version number (55.0.0) matches the Rust crate’s major version here, but I did not check that the two builds contain the same code. Nothing in this run measures speed.
Try it yourself
Section titled “Try it yourself”-
Change the filter.
cargo run --example explain -- 100usessales > 100. The second command in the plan panel above ran it (withtail -2to keep only the end): 4 rows come back, and the Parquet metric line now reads3 total → 2 matched -> 2 fully matched(the line also reports how many row groups matched fully), because the third row group’s largest value is 99. -
Make three mistakes in SQL. The
resultssession sent a misspelled table, a misspelled column and a misspelled keyword. The column error suggestedsales.city; the keyword error is aParserError. All three wereErrvalues and the program kept going. -
Add
arrowto the main program. Addarrow = "=60.0.0"toCargo.tomland change the threeuse datafusion::arrow::...lines ofsrc/main.rstouse arrow::.... This is the whole program, not the shorter one above, so it does not give the single error shown there: Cargo reported three errors, twomismatched types(E0308, where the schema goes toArrowWriter::try_newand where&batchgoes towriter.write) and one E0277, because?cannot turn the newarrow::error::ArrowErrorinto aDataFusionError:$ cargo builderror[E0308]: mismatched types --> src/main.rs:28:74 | 28 | let mut writer = ArrowWriter::try_new(std::fs::File::create(&path)?, schema, None)?; | -------------------- ^^^^^^ expected `datafusion::arrow::datatypes::Schema`, found `arrow::datatypes::Schema` | | | arguments to this function are incorrect ... (notes pointing into library source code trimmed) error[E0308]: mismatched types --> src/main.rs:29:18 | 29 | writer.write(&batch)?; | ----- ^^^^^^ expected `datafusion::arrow::array::RecordBatch`, found `arrow::array::RecordBatch` | | | arguments to this method are incorrect ... (notes pointing into library source code trimmed) error[E0277]: `?` couldn't convert the error to `DataFusionError` --> src/main.rs:26:6 | 17 | let batch = RecordBatch::try_new( | _________________- 18 | | schema.clone(), 19 | | vec![ 20 | | Arc::new(StringArray::from(vec![ ... | 25 | | ], 26 | | )?; | | -^ the trait `std::convert::From<arrow::error::ArrowError>` is not implemented for `DataFusionError` | |_____| | this can't be annotated with `?` because it has type `Result<_, arrow::error::ArrowError>` | = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait = help: `DataFusionError` implements trait `std::convert::From<T>`: std::convert::From<&Arc<DataFusionError>> std::convert::From<Box<dyn std::error::Error + std::marker::Send + Sync>> std::convert::From<BuilderError> std::convert::From<datafusion::arrow::error::ArrowError> std::convert::From<datafusion::logical_expr::sqlparser::parser::ParserError> std::convert::From<datafusion::object_store::Error> std::convert::From<datafusion::object_store::path::Error> std::convert::From<datafusion::parquet::errors::ParquetError> and 2 others Some errors have detailed explanations: E0277, E0308.
Read more
Section titled “Read more”- DataFusion user guide and its FAQ, and Reading explain plans for what the plan lines mean.
datafusion55.2.0 on docs.rs: the architecture notes, including the Tokio section quoted above.- Apache Arrow and Apache Parquet.
- In this course: Async Basics and the Tokio guide.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.