Skip to content

Query a table with DataFusion

Use DataFusion when your application already depends on it or when you want to query a Delta table with SQL. This quickstart loads one table, registers it by name, and runs a query that returns a small result.

Before you start

Add the DataFusion dependencies. You will also need the path to a Delta table that your application can read.

Register and query the table

use datafusion::prelude::SessionContext;
use delta_arrow_reader::{
    DeltaTableBuilder,
    datafusion::{ScanOptions, register_table},
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let context = SessionContext::new();
    let table = DeltaTableBuilder::new("/tmp/example-delta-table")
        .load_table()
        .await?;

    register_table(
        &context,
        "orders",
        table,
        ScanOptions::default(),
    )?;

    let batches = context
        .sql("SELECT * FROM orders LIMIT 100")
        .await?
        .collect()
        .await?;
    println!("batches={}", batches.len());
    Ok(())
}

load_table selects one Delta table version and reads its Arrow schema. register_table makes that loaded table available in DataFusion as orders. Registration does not open any Parquet files. DataFusion plans the read and opens those files when collect runs the query. The LIMIT keeps this first result small.

Reuse scan metadata across SQL queries

If the registered table will serve several SQL queries, cache its reusable scan metadata before registration:

use datafusion::prelude::SessionContext;
use delta_arrow_reader::{
    DeltaTableBuilder, WarmupMode,
    datafusion::{ScanOptions, register_table},
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let context = SessionContext::new();
    let table = DeltaTableBuilder::new("/tmp/example-delta-table")
        .with_warmup(WarmupMode::QueryPlanning)
        .load_table()
        .await?;

    register_table(
        &context,
        "orders",
        table,
        ScanOptions::default(),
    )?;

    let batches = context
        .sql("SELECT * FROM orders LIMIT 100")
        .await?
        .collect()
        .await?;
    println!("batches={}", batches.len());
    Ok(())
}

WarmupMode::QueryPlanning builds the cache while load_table loads the table. register_table uses that same loaded table and does not build another cache. All SQL queries through the registered provider can therefore reuse the cached metadata without replaying the Delta log or checkpoint. Each query still applies its own projection and predicate and reads its own Parquet footers and data.

Use query-planning warmup for a named table that will be queried repeatedly. Keep the default WarmupMode::None when you will query it only once or occasionally. The crate does not maintain a table-name registry or choose a mode automatically.

A registered provider stays on the Delta version that it loaded. Refreshing a provider does not modify the DataFusion catalog entry that points to the old provider.

Refresh a registered provider

Keep the concrete DeltaTableProvider when a long-running service needs to refresh its registration:

use std::sync::Arc;

use datafusion::prelude::SessionContext;
use delta_arrow_reader::{
    DeltaTableBuilder,
    datafusion::{DeltaTableProvider, ScanOptions},
};

async fn refresh_orders(
    context: &SessionContext,
    provider: &mut DeltaTableProvider,
) -> Result<(), Box<dyn std::error::Error>> {
    let refreshed = provider.refresh().await?;
    let previous = context.deregister_table("orders")?;

    if let Err(error) = context.register_table("orders", Arc::new(refreshed.clone())) {
        if let Some(previous) = previous {
            let _ = context.register_table("orders", previous)?;
        }
        return Err(error.into());
    }

    *provider = refreshed;
    Ok(())
}

# async fn setup() -> Result<(), Box<dyn std::error::Error>> {
let context = SessionContext::new();
let table = DeltaTableBuilder::new("/tmp/example-delta-table")
    .load_table()
    .await?;
let mut provider = DeltaTableProvider::try_new(table, ScanOptions::default())?;
let _ = context.register_table("orders", Arc::new(provider.clone()))?;
refresh_orders(&context, &mut provider).await?;
# Ok(())
# }

The returned provider keeps the existing scan options and range-read estimates. It also rebuilds the DataFusion schema if the Delta schema changed. The original provider remains usable, so queries that already hold it continue against the old snapshot.

DataFusion uses separate deregistration and registration calls, so there is a short interval with no orders registration. If that gap matters, pause new query planning while the two calls run. The example restores the previous provider if the new registration fails.

DeltaTableProvider::refresh does not poll in the background. The application chooses when to refresh and when to replace its registration.

The Delta metadata lifecycle follows this cache across several queries. For the rest of the read path, see how the reader works. The Rust API reference documents the available scan options and metrics.