Technical Documentation: Eventual-Inc/Daft
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`benchmarking/ai/README.md`)
AI Benchmarks
This repository contains performance benchmarks comparing different data processing engines (Daft, Ray Data, and Spark) across various AI and multimodal data processing workloads.
Overview
The benchmarks cover four different workload types, each designed to test different aspects of multimodal data processing:
- Audio Transcription - Transcribing audio files
- Document Embedding - Generating embeddings for PDF documents
- Image Classification - Classify images
- Video Object Detection - Detect objects in videos
Performance Results Summary
| Workload | Data Size | Daft | Ray Data | Spark |
|---|---|---|---|---|
| Audio Transcription | 113,800 audio files | 6m 22s | 29m 20s | 25m 46s |
| Document Embedding | 10,000 PDFs | 1m 54s | 14m 32s | 8m 4s |
| Image Classification | 803,580 images | 4m 23s | 23m 30s | 45m 7s |
| Video Object Detection | 1,000 videos | 11m 46s | 25m 54s | 3h 36m |
Overview (`benchmarking/asof_join/README.md`)
Summary
Adds a self-contained benchmarking suite for Daft's join_asof operation:
data_generation.py— generates reproducible left/right parquet datasets at three scales (small,medium,large) with clustered timestamps and Zipf-skewed entity distribution, written tobenchmarking/data/asof_join/benchmark.py— runs a single asof-join using Daft's native or Ray runner, wrapped in a memray memory tracker, and prints a JSON result with wall time and memray output pathcluster.yaml— Ray cluster config for AWS (1m7i.largehead + 4r7i.4xlargeworkers) for distributed runs against S3
How to run
From inside benchmarking/asof_join/:
1. Generate data (one-time)
python data_generation.py --scale small
# or --scale medium / --scale large / --all2. Run locally (native runner)
python benchmark.py --scale small
# Output: asof_join_memray.bin + JSON result on stdout3. Inspect memory profile
memray flamegraph asof_join_memray.binRun on a Ray cluster
Before running: update
DATA_ROOTto your S3 bucket and uncommentdaft.set_runner_ray()inbenchmark.py. Also update the S3 bucket and IAM settings incluster.yaml.
Spin up the cluster:
ray up benchmarking/asof_join/cluster.yamlForward the dashboard in one terminal:
ray dashboard benchmarking/asof_join/cluster.yamlSubmit the job in another (after updating DATA_ROOT and daft.set_runner_ray() in benchmark.py):
ray job submit --address "http://localhost:8265" --working-dir benchmarking/asof_join -- python benchmark.py --scale smallTear down when done:
ray down benchmarking/asof_join/cluster.yamlOverview (`benchmarking/lerobot/README.md`)
LeRobot video decode: per-frame → per-shard
The daft.datasets.lerobot reader decoded video frames with a per-row UDF that
re-opened the MP4 shard for every frame. Because av.open() on a remote shard
re-reads and parses the container index over the network, decoding N frames
re-opened the shard N times, paying that cost each time - so cost scaled ~linearly
at ~3s/frame (the slope of the sweep below).
This directory holds the benchmarks that diagnosed it and the fix that makes the
decode batched: rows sharing a shard within a batch are grouped so each shard is
opened once per batch, instead of once per frame.
The fix: batched decode
_decode_lerobot_video_timestamp in daft/datasets/lerobot.py is now a@daft.func.batch UDF. Within each batch it groups rows by shard path, opens each
shard once, and does a single forward decode assigning the closest frame to every
requested timestamp. Output is byte-identical to the old per-row decode.
Original vs batched (rows 1→10)
sweep.py times an end-to-end lerobot.read(...).limit(n).collect() with
frame decoding for n = 1..10 rows of a remote test dataset (pepijn223/egodex-test),
run once per reader revision (merge-base vs this branch) - the original grows linearly
to ~34s; the batched version stays flat at ~4s (all 10 frames share one shard → one
open). All benchmarks in this directory were run on the same machine, an Apple
M4 Max (36 GB).
| rows | original | batched |
|---|---|---|
| 1 | 4.2s | 4.4s |
| 8 | 25.0s | 3.9s |
| 10 | 34.4s | 3.9s |
8-frame output hashes matched exactly (sha 80bdb30c…) between versions.
Beyond this test dataset, the fix was validated on six public LeRobot v3 datasets
spanning av1/h264/mp4v, 5-30 fps, 128x128-1280x720, and 1-3 cameras - pixel-identical
output everywhere, 4-13x faster - plus a full-dataset decode and a 100-frame
comparison. See real_datasets.md.
Downstream workload: hand tracking
hand_tracking.py times the
daft-physical-ai demo
workload end to end: decode 12 remote frames of pepijn223/egodex-test and run
MediaPipe hand tracking as a Daft UDF. run_hand_tracking.sh
runs it once per reader revision (the original pinned to the parent of the #7184
merge commit) and checks the detected hands match. Measured 2026-07-14 on an
Apple M4 Max (36 GB, macOS 15.6), remote reads over hf://:
44.8s → 9.8s, identical detections.
Running
python sweep.py --label batched # rows 1..10 sweep + chart
./run_hand_tracking.sh # hand-tracking workload A/B + chartOverview (`benchmarking/parquet/README.md`)
Parquet Benchmarks
Goals:
- Find Parquet features that Daft underperforms on
- Compare Daft against other frameworks
Setup
Create a new virtual environment and install the dependencies
python -m venv .venv
source .venv/bin/activate
pip install -r benchmark-requirements.txtNow, install the version of Daft you wish to use for benchmarking (either a released wheel, or if you want, a local build)
pip install daftRunning the benchmarks:
Goal 1: Find Parquet features that Daft underperforms on
pytest benchmarking/parquet/ -m benchmark --benchmark-group-by=group -k daftGoal 2: Compare Daft against other frameworks
pytest benchmarking/parquet/ -m benchmark --benchmark-group-by=param:pathCheck peak memory usage
Ensure that you have pytest-memray installed.
pytest benchmarking/parquet/ -m benchmark --memrayOverview (`benchmarking/vllm/README.md`)
vLLM Benchmarks
This directory contains benchmarks for running batch inference with vLLM.
Setup
- Install the dependencies:
vllmdaftray
- Run the
generate_data.ipynbnotebook to generate the datasets. - Update
config.pywith the correct dataset path and other parameters. - Run the benchmark scripts on a Ray cluster.
Benchmarks
naive-batch.py: Simple batch inference using a Daft batch function and vLLM'sLLMclass.naive-batch-sorted.py: Same asnaive-batch.py, but with the prompts sorted.continuous-batch.py: Continuous batching using thevllm-prefix-cachingprovider, with prefix routing disabled.continuous-batch-sorted.py: Same ascontinuous-batch.py, but with the prompts sorted.prefix-bucketing.py: Both continuous batching and prefix bucketing using thevllm-prefix-cachingprovider.ray-data.py: Ray Data batching usingray.data.llm.build_llm_processor.
Overview (`docs/plugins/README.md`)
Daft MkDocs Plugins
Custom MkDocs plugins for the Daft documentation site.
nav-hide-children
A plugin that allows you to conditionally hide navigation children based on configured paths.
Configuration
Add to your mkdocs.yml:
plugins:
- nav-hide-children:
hide_children:
- ["Python API", "Functions"] # Hide children of Python API > Functions
- ["SQL Reference"] # Hide children of SQL ReferenceUsage
The plugin automatically injects an attribute hide_children into navigation objects based on the config paths, which can be used in Jinja2 templates to conditionally hide navigation elements.
Overview (`examples/dvector/README.md`)
dvector
Vector distance functions for Daft, implemented as a native extension.
Functions
| Function | Description | Input | Output |
|---|---|---|---|
l2_distance(a, b) |
Euclidean distance | float vectors | Float64 |
inner_product(a, b) |
Negative inner product (pgvector convention) | float vectors | Float64 |
cosine_distance(a, b) |
Cosine distance (null for zero-norm) | float vectors | Float64 |
l1_distance(a, b) |
Manhattan distance | float vectors | Float64 |
hamming_distance(a, b) |
Count of differing positions | boolean vectors | UInt32 |
jaccard_distance(a, b) |
1 - intersection/union (null if union is empty) | boolean vectors | Float64 |
Vectors can be FixedSizeList, List, or LargeList columns. Float vectors support Float32 and Float64 elements.
Install
Requires a Rust toolchain and setuptools-rust.
Usage
import daft
import dvector
from dvector import l2_distance
# Load the extension into the active session
daft.load_extension(dvector)
df = daft.from_pydict({
"a": [[1.0, 2.0, 3.0], [0.0, 0.0, 0.0]],
"b": [[4.0, 5.0, 6.0], [1.0, 1.0, 1.0]],
})
df.select(l2_distance(daft.col("a"), daft.col("b"))).collect()
# ╭───────────╮
# │ result │
# │ --- │
# │ Float64 │
# ╞═══════════╡
# │ 5.196152 │
# ├╌╌╌╌╌╌╌╌╌╌╌┤
# │ 1.732051 │
# ╰───────────╯Development
# Build and install with the native library
uv pip install -e .
# Run tests
pytest -v tests/Overview (`examples/hello/README.md`)
Hello Native Extension
A minimal Daft native extension example. See the extension guide for details.
Quick start
# Install the extension in the project .venv
uv pip install -e .
# Run the tests!
pytest -vOverview (`examples/hello_cpp/README.md`)
Hello C++ Native Extension
A minimal Daft native extension written in pure C++ using the Apache Arrow C++ library.
This is the C++ counterpart of the hello Rust example. It implements a single greet_cpp scalar function that prepends "Hello, " to every string in a column.
Prerequisites
- CMake >= 3.20
- A C++17 compiler
- Apache Arrow C++ (with headers)
Install Arrow C++
macOS (Homebrew):
brew install apache-arrowUbuntu / Debian:
sudo apt install -y libarrow-devConda:
conda install -c conda-forge arrow-cppDevelopment
Build the shared library
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build buildThe CMAKE_EXPORT_COMPILE_COMMANDS flag generates build/compile_commands.json for clangd / LSP support. Symlink it to the project root if your editor needs it:
ln -sf build/compile_commands.json .Install as a Python package
uv pip install -e .This uses scikit-build-core to build the C++ library and install it into the Python package.
Run tests
pytest -v tests/Usage
import daft
import hello_cpp
from hello_cpp import greet
from daft import col
from daft.session import Session
sess = Session()
sess.load_extension(hello_cpp)
df = daft.from_pydict({"name": ["John", "Paul", "George", None]})
with sess:
df.select(greet(col("name"))).show()
# ╭──────────────────╮
# │ greet_cpp │
# │ --- │
# │ Utf8 │
# ╞══════════════════╡
# │ Hello, John! │
# ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
# │ Hello, Paul! │
# ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
# │ Hello, George! │
# ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
# │ None │
# ╰──────────────────╯Overview (`src/daft-dashboard/README.md`)
daft-dashboard
Requirements
- Node.js ≥22
- npm ≥11.10.0 — required for the
min-release-agesupply-chain setting infrontend/.npmrcto take effect. Older npm versions silently ignore it, leaving localnpm installunprotected. Upgrade withnpm install npm@latest.
Build
The frontend (Next.js, under frontend/) is statically embedded into the Rust binary at compile time via include_dir! (see src/assets.rs). There is no separate dev server — any frontend change requires make build (or make build-release) from the repo root to take effect.
Architecture
- Backend: Axum server (
src/) exposes SSE streams and REST endpoints under/client/. - Frontend: Next.js app (
frontend/) compiled to static assets, embedded in the binary. - State:
DashboardState(insrc/state.rs) holds query info in aDashMap(in memory, currently no persistence).
Overview (`src/daft-ext-macros/README.md`)
daft-ext-macros
Proc macros for Daft extensions.
Most users should depend on daft-ext instead.
License
Apache-2.0
Overview (`src/daft-ext/README.md`)
daft-ext
Extension SDK for building custom Daft extensions.
Daft is a distributed query engine for large-scale data processing. This crate provides the types, traits, and macros needed to build extensions that plug into Daft's execution engine.
Usage
[dependencies]
daft-ext = "0.1"License
Apache-2.0
Overview (`src/daft-scan/README.md`)
daft-scan
Defines the ScanOperator trait, ScanTask struct, and built-in operator implementations (GlobScanOperator, AnonymousScanOperator). Also owns scan-related primitives: Pushdowns, PartitionField, Sharder, and predicate rewriting for partition pruning.
Overview (`tutorials/flyte/README.md`)
Using Daft with Flyte
Daft works well with the Flyte workflow engine and its Ray plugin.
Using from Flyte task
To run Daft in a Flyte task, use Daft as if you were using Daft in a local machine!
Using Daft from a Ray Flyte task
To run Daft in a Ray Flyte task and connect to a Ray cluster, use Daft as if ray.init() has already been called.
That is to say, run Daft with: daft.set_runner_ray().
See: app.py for an example.
Setup
To run this locally, perform Flyte local setup as detailed here: https://github.com/jeevb/flyte-examples/tree/main/ray
Overview (`benchmarking/ai/audio_transcription/README.md`)
Audio Transcription Benchmark
Transcribes 113,800 audio files using Whisper-tiny model. Processes audio resampling, feature extraction, and speech-to-text inference.
Input Dataset: Common Voice 17 (S3 parquet format)
Output Format: Parquet with transcriptions and metadata
Cluster: 8 worker nodes using g6.xlarge instances
Benchmark Date: September 22, 2024
Framework Versions: Daft 0.6.2, Ray Data 2.49.2, AWS EMR Spark 7.10.0
Performance Results
| Engine | Runtime |
|---|---|
| Daft | 6m 22s |
| Ray Data | 29m 20s |
| Spark | 25m 46s |
Overview (`benchmarking/ai/document_embedding/README.md`)
Document Embedding Benchmark
Generates embeddings for 10,000 PDF documents. Extracts text, chunks content, and creates 384-dimensional embeddings using sentence-transformers/all-MiniLM-L6-v2 model across distributed nodes.
Input Dataset: Digital Corpora PDF metadata (S3 parquet format)
Output Format: Parquet with embeddings, text chunks, and metadata
Cluster: 8 worker nodes using g6.xlarge instances
Benchmark Date: September 22, 2024
Framework Versions: Daft 0.6.2, Ray Data 2.49.2, AWS EMR Spark 7.10.0
Performance Results
| Engine | Runtime |
|---|---|
| Daft | 1m 54s |
| Ray Data | 14m 32s |
| Spark | 8m 4s |
Overview (`benchmarking/ai/image_classification/README.md`)
Image Classification Benchmark
Classifies 803,580 rows (80,358 unique images, each repeated 10x) using ResNet18 model. Downloads images, applies preprocessing transforms, and runs inference to predict ImageNet labels across distributed GPU nodes.
Input Dataset: ImageNet benchmark dataset (S3 parquet format)
Output Format: Parquet with image URLs and predicted labels
Cluster: 8 worker nodes using g6.xlarge instances
Benchmark Date: September 22, 2024
Framework Versions: Daft 0.6.2, Ray Data 2.49.2, AWS EMR Spark 7.10.0
Performance Results
| Engine | Runtime |
|---|---|
| Daft | 4m 23s |
| Ray Data | 23m 30s |
| Spark | 45m 7s |
Overview (`benchmarking/ai/video_object_detection/README.md`)
Video Object Detection Benchmark
Detects objects in 1,000 videos using YOLO11n model. Extracts frames, runs object detection, and crops detected objects across distributed GPU nodes.
Input Dataset: Hollywood2 video dataset (S3 binary files)
Output Format: Parquet with object detections, bounding boxes, and cropped images
Cluster: 8 worker nodes using g6.xlarge instances
Benchmark Date: September 22, 2024
Framework Versions: Daft 0.6.2, Ray Data 2.49.2, AWS EMR Spark 7.10.0
Performance Results
| Engine | Runtime |
|---|---|
| Daft | 11m 46s |
| Ray Data | 25m 54s |
| Spark | 3h 36m |
Overview (`k8s/charts/quickstart/README.md`)
Daft Quickstart Helm Chart
A Helm chart for running Daft data processing workloads on Kubernetes, with support for both simple single-node execution and distributed Ray-based processing.
Features
- Two deployment modes: Simple mode for quick jobs, distributed mode for large-scale processing
- Two execution methods: Script injection for rapid prototyping, custom images for managing complex dependencies
- No operator required: Uses native Kubernetes resources only
Prerequisites
- Kubernetes 1.19+
- Helm 3.x
- kubectl configured to access your cluster
Quick Start
Simple Mode (Default)
Run a quick Daft job using the native runner:
# Create a simple Python script
cat > my_script.py <<'EOF'
# /// script
# dependencies = ["daft"]
# ///
import daft
df = daft.from_pydict({
"name": ["Alice", "Bob", "Charlie"],
"age": [25, 30, 35]
})
df.filter(df["age"] > 25).show()
EOF
# Deploy
helm install my-job oci://ghcr.io/eventual-inc/daft/quickstart \
--set-file job.script=my_script.py
# View logs
kubectl logs -f job/my-job-quickstart-job
# Cleanup
helm uninstall my-jobDistributed Mode
Run a Daft job on a Ray cluster with multiple workers:
# Create a simple Python script with Ray
cat > my_script.py <<'EOF'
# /// script
# dependencies = ["daft", "ray[client]==2.46.0"]
# ///
import daft
import ray
ray.init(runtime_env={"pip": ["daft"]})
df = daft.from_pydict({
"name": ["Alice", "Bob", "Charlie"],
"age": [25, 30, 35]
})
df.filter(df["age"] > 25).show()
EOF
# Deploy with Ray cluster
helm install distributed-job oci://ghcr.io/eventual-inc/daft/quickstart \
--set distributed=true \
--set worker.replicas=3 \
--set-file job.script=my_script.py
# Access Ray Dashboard
kubectl port-forward service/distributed-job-quickstart-head 8265:8265
# Open http://localhost:8265
# Cleanup
helm uninstall distributed-jobUsing a custom image
This chart also supports running Daft jobs from custom images. This is useful in scenarios where more complex dependencies are required.
helm install custom-image-job oci://ghcr.io/eventual-inc/daft/quickstart \
--set image=my-image:tag \
--set "job.command={python,my_script.py}"Installation
See values.yaml for detailed descriptions of all supported parameters.
Monitoring
View Job Status
# Get job status
kubectl get jobs -l app.kubernetes.io/instance=my-release
# View job logs
kubectl logs -f job/my-release-quickstart-job
# Check pod status
kubectl get pods -l app.kubernetes.io/instance=my-releaseAccess Ray Dashboard (Distributed Mode)
# Port forward to access both Ray Dashboard and Grafana
kubectl port-forward service/my-release-quickstart-head 8265:8265 3000:3000
# Open http://localhost:8265 in browser
# Grafana panels will be embedded directly in the Ray Dashboard
# Optionally open http://localhost:3000 to access Grafana directly
# Default credentials: admin/adminCheck Ray Cluster Status (Distributed Mode)
# Get cluster status
kubectl exec deployment/my-release-quickstart-head -- ray status
# View cluster resources
kubectl exec deployment/my-release-quickstart-head -- python -c "import ray; ray.init(); print(ray.cluster_resources())"Scaling
Manual Scaling (Distributed Mode)
# Scale workers using kubectl
kubectl scale deployment my-release-quickstart-worker --replicas=10Troubleshooting
Job Not Starting
# Check job events
kubectl describe job my-release-quickstart-job
# Check pod events
kubectl describe pod -l app.kubernetes.io/component=job
# View pod logs
kubectl logs -l app.kubernetes.io/component=jobWorkers Not Connecting (Distributed Mode)
# Check head service
kubectl get service -l ray.io/node-type=head
# Check head logs
kubectl logs deployment/my-release-quickstart-head
# Check worker logs
kubectl logs deployment/my-release-quickstart-workerOut of Memory Errors
Increase memory limits in values:
job:
resources:
limits:
memory: "16Gi" # Increase as needed
head:
resources:
limits:
memory: "32Gi" # Increase as needed
worker:
resources:
limits:
memory: "32Gi" # Increase as neededScript Not Found Error
Ensure you're using --set-file (not --set):
# Correct
helm install my-job oci://ghcr.io/eventual-inc/daft/quickstart --set-file job.script=script.py
# Incorrect
helm install my-job oci://ghcr.io/eventual-inc/daft/quickstart --set job.script=script.pyRay Module Not Found (Distributed Mode)
Your custom image must include ray[default]:
# requirements.txt
daft
ray[default]Uninstalling
helm uninstall my-releaseThis removes all Kubernetes resources created by the chart.
License
Apache 2.0
Contributing
Contributions are welcome! Please see the Daft repository for contribution guidelines.
Support
- Issues: GitHub Issues
- Documentation: docs.daft.ai
- Community: Daft Discussions
Overview (`tests/integration/gravitino/README.md`)
Gravitino Integration Tests
These tests exercise Daft's Gravitino connector end-to-end. To run them:
Start the local Gravitino service:
bashcd tests/integration/gravitino/docker-compose docker compose up -dExport the connection settings (if you use the compose file above the defaults already match):
bashexport GRAVITINO_ENDPOINT=${GRAVITINO_ENDPOINT:-http://127.0.0.1:8090} export GRAVITINO_METALAKE=${GRAVITINO_METALAKE:-metalake_demo}Run the tests:
bashDAFT_RUNNER=native pytest tests/integration/gravitino -v -m integration
Test Files
test_gravitino_fileset.py- Tests filesets with local file:// storagetest_gravitino_fileset_s3.py- Tests filesets with S3 storage (using SeaweedFS)test_gravitino_table.py- Tests table catalog operations
S3 Storage Tests
The S3 fileset tests (test_gravitino_fileset_s3.py) test Gravitino filesets backed by S3 storage.
Current Status
The docker-compose setup includes:
- SeaweedFS: S3-compatible storage on port 9000
- Update fileset.conf: To run with S3, set a property in
/root/gravitino/catalogs/fileset/conf/fileset.conf - Test Infrastructure: Complete test suite for S3 filesets
Running S3 Tests with External Gravitino
If you have access to a Gravitino deployment with S3 filesets already configured, you can test against it:
# Point to an existing S3-backed fileset
export GRAVITINO_TEST_DIR="gvfs://fileset/<catalog>/<schema>/<fileset>/"
export GRAVITINO_TEST_FILE="gvfs://fileset/<catalog>/<schema>/<fileset>/file.parquet"
export GRAVITINO_ENDPOINT="http://your-gravitino-server:8090"
# Run specific tests
DAFT_RUNNER=native pytest tests/integration/gravitino/test_gravitino_fileset_s3.py::test_read_existing_s3_fileset -v -m integrationTest Coverage
The S3 test suite includes:
- Basic read operations from S3-backed filesets
- Glob patterns and file listing
- Partitioned data handling
- Error handling for missing files and empty directories
Optional Configuration
Optionally, configure GRAVITINO_TEST_FILE and GRAVITINO_TEST_DIR to point at a fileset that exists in
your Gravitino deployment so that the gvfs:// IO tests can read concrete data.
The catalog integration tests provision temporary catalog/schema/fileset via the Gravitino REST API and write
test data to either local storage or SeaweedFS. The tests clean up both the server-side metadata and storage when finished.
Overview (`tools/observability/opentelemetry/README.md`)
OpenTelemetry
This directory contains the code for running the OpenTelemetry collector and exporter.
The docker compose file located in this directory will start the following services:
- OpenTelemetry Collector - an agent for collecting and exporting opentelemetry data
- Jaeger - an open source distributed tracing system
- Prometheus - an open source metrics monitoring system
Running the collector
To start the services, navigate to this directory and run the following command:
docker compose upExecuting a job with opentelemetry enabled
The opentelemetry collector exposes an OTLP gRPC endpoint on port 4317.
To execute a job with opentelemetry enabled, set the DAFT_DEV_OTEL_EXPORTER_OTLP_ENDPOINT environment variable to grpc://localhost:4317 and run the job.
Example:
DAFT_DEV_OTEL_EXPORTER_OTLP_ENDPOINT=grpc://localhost:4317 python tools/observability/opentelemetry/example.pyViewing the metrics
Traces (Jaeger)
To view the traces on the Jaeger UI, navigate to http://localhost:16686/ and select the daft service from the dropdown menu.
Metrics (Prometheus)
To view the metrics on the Prometheus UI, navigate to http://localhost:9090/.
To query for all metrics provided by Daft, try the following PromQL query:
{service_name='daft'}Index (`docs/index.md`)
Introduction
Daft is a high-performance data engine designed for AI and multimodal workloads, providing simple, reliable data processing for images, audio, video, and structured data at any scale.
READ ANYTHING
Daft reads raw unstructured and multimodal data collected from application systems
Object storage (AWS S3, GCS, R2)Event bus (Kafka)Data lake (Iceberg, Deltalake)
EXPENSIVE TRANSFORMATIONS
Build efficient Daft data pipelines involving heavy transformations
GPU modelsUser-provided Python codeExternal LLM APIs
WRITE
Daft lands data into specialized data systems for downstream use cases
Search (full-text search and vector DBs)Applications (SQL/NoSQL databases)Analytics (data warehouses)Model training (S3 object storage)Why Daft?
:octicons-image-24: Unified multimodal data processing
While traditional dataframes struggle with anything beyond tables, Daft natively handles tables, images, text, and embeddings through a single Python API. No more stitching together specialized tools for different data types.
:material-language-python: Python-native, no JVM required
Built for modern AI/ML workflows with Python at its core and Rust under the hood. Skip the JVM complexity, version conflicts, and memory tuning to achieve 20x faster start times—get the performance without the Java tax.
:fontawesome-solid-laptop: Seamless scaling, from laptop to cluster
Start local, scale global—without changing a line of code. Daft's Rust-powered engine delivers blazing performance on a single machine and effortlessly extends to distributed clusters when you need more horsepower.
Key Features
Native multimodal processing: Process any data type—from structured tables to unstructured text and rich media—with native support for images, audio, video, and embeddings in a single, unified framework.
Built-in AI operations: Transform data with AI natively: run LLM prompts with structured outputs, generate embeddings, and classify images or text using models from OpenAI, Transformers, or your own custom providers, all optimized for batch processing.
Rust-powered performance: Experience breakthrough speed with our Rust foundation delivering vectorized execution and non-blocking I/O that processes the same queries with 5x less memory while consistently outperforming industry standards by an order of magnitude.
Universal data connectivity: Access data anywhere it lives—cloud storage (S3, Azure, GCS, Hugging Face), modern table formats (Apache Iceberg, Delta Lake, Apache Hudi), or enterprise catalogs (Unity Catalog, AWS Glue)—all with zero configuration.
Push your code to your data: Bring your Python functions directly to your data with zero-copy UDFs powered by Apache Arrow, eliminating data movement overhead and accelerating processing speeds.
Out of the box reliability: Deploy with confidence—intelligent memory management prevents OOM errors while sensible defaults eliminate configuration headaches, letting you focus on results, not infrastructure.
!!! tip "Looking to get started with Daft ASAP?"
If you are ready to jump into code, take a look at these resources:
1. [Quickstart](quickstart.md): Itching to run some Daft code? Hit the ground running with our 10 minute quickstart.
2. [Examples](examples/index.md): See Daft in action with use cases across text, images, audio, and more.
3. [API Documentation](api/index.md): Searchable documentation and reference material to Daft's public API.Contribute to Daft
If you're interested in hands-on learning about Daft internals and would like to contribute to our project, join us on GitHub 🚀
Take a look at the many issues tagged with good first issue in our repo. If there are any that interest you, feel free to chime in on the issue itself or join us in our Distributed Data Slack Community and send us a message in #daft-dev. Daft team members will be happy to assign any issue to you and provide any guidance if needed!
Quickstart (`docs/quickstart.md`)
Quickstart
Daft is the best multimodal data processing engine that allows you to load data from anywhere, transform it with a powerful DataFrame API and AI functions, and store it in your destination of choice. In this quickstart, you'll see what this looks like in practice with a realistic e-commerce data workflow.
Requirements
Daft requires Python 3.10 or higher.
Install Daft
You can install Daft using pip. Run the following command in your terminal or notebook:
pip install -U "daft[openai]" # Includes OpenAI extras needed for this quickstartAdditionally, install these packages for image processing (used later in this quickstart):
pip install numpy pillowLoad Your Data
Let's start by loading an e-commerce dataset from Hugging Face. This dataset contains 10,000+ Amazon products from diverse categories including electronics, toys, home goods, and more. Each product includes details like names, prices, descriptions, technical specifications, and product images.
import daft
df_original = daft.read_huggingface("calmgoose/amazon-product-data-2020")!!! note "Load from anywhere"
Daft can load data from many sources including [S3](connectors/aws.md), [Iceberg](connectors/iceberg.md), [Delta Lake](connectors/delta_lake.md), [Hudi](connectors/hudi.md), and [more](connectors/index.md). We're using Hugging Face here as a demonstration.Inspect Your Data
Now let's take a look at what we loaded. You can inspect the DataFrame by simply printing it:
df_original╭─────────┬──────────────┬──────────┬────────────┬──────────┬─────────────┬──────────────────╮
│ Uniq Id ┆ Product Name ┆ Category ┆ … ┆ Variants ┆ Product Url ┆ Is Amazon Seller │
│ --- ┆ --- ┆ --- ┆ ┆ --- ┆ --- ┆ --- │
│ String ┆ String ┆ String ┆ (9 hidden) ┆ String ┆ String ┆ String │
╰─────────┴──────────────┴──────────┴────────────┴──────────┴─────────────┴──────────────────╯
(No data to display: Dataframe not materialized, use .collect() to materialize)You see the above output because Daft is lazy by default - it displays the schema (column names and types) but doesn't actually load or process your data until you explicitly tell it to. This allows Daft to optimize your entire workflow before executing anything.
To actually view your data, you have two options:
Option 1: Preview with .show() - View the first few rows:
df_original.show(2)/* Detailed source-code truncated for AI context efficiency. */This materializes and displays just the first 2 rows, which is perfect for quickly inspecting your data without loading the entire dataset.
Option 2: Materialize with .collect() - Load the entire dataset:
# df_original.collect()This would materialize the entire DataFrame (all 10,000+ rows in this case) into memory. Use .collect() when you need to work with the full dataset in memory.
Working with a Smaller Dataset
For quick experimentation, let's create a smaller, simplified version of the dataframe with just the essential columns:
# Select only the columns we need and limit to 5 rows for faster iteration
df = df_original.select("Product Name", "About Product", "Image").limit(5)Now we have a manageable dataset of 5 products with just the product name, description, and image URLs. This simplified dataset lets us explore Daft's features without the overhead of unnecessary columns.
Downloading Images
Let's extract and download product images. The Image column contains pipe-separated URLs. We'll extract the first URL and download it:
# Extract the first image URL from the pipe-separated list
# The pattern captures everything before the first pipe or the entire string if no pipe
df = df.with_column(
"first_image_url",
daft.functions.regexp_extract(
df["Image"],
r"^([^|]+)", # Extract everything before the first pipe
1 # Get the first capture group
)
)
# Download the image data
df = df.with_column(
"image_data",
daft.functions.download(df["first_image_url"], on_error="null")
)
# Decode images for visual display (in Jupyter notebooks, this shows actual images!)
df = df.with_column(
"image",
daft.functions.decode_image(df["image_data"], on_error="null")
)
# Check what we have - in Jupyter notebooks, the 'image' column shows actual images!
df.select("Product Name", "first_image_url", "image_data", "image").show(3)╭────────────────────────────────┬────────────────────────────────┬────────────────────────────────┬──────────────╮
│ Product Name ┆ first_image_url ┆ image_data ┆ image │
│ --- ┆ --- ┆ --- ┆ --- │
│ String ┆ String ┆ Binary ┆ Image[MIXED] │
╞════════════════════════════════╪════════════════════════════════╪════════════════════════════════╪══════════════╡
│ DB Longboards CoreFlex Crossb… ┆ https://images-na.ssl-images-… ┆ b"\xff\xd8\xff\xe0\x00\x10JFI… ┆ <Image> │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Electronic Snap Circuits Mini… ┆ https://images-na.ssl-images-… ┆ b"\xff\xd8\xff\xe0\x00\x10JFI… ┆ <Image> │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 3Doodler Create Flexy 3D Prin… ┆ https://images-na.ssl-images-… ┆ b"\xff\xd8\xff\xe0\x00\x10JFI… ┆ <Image> │
╰────────────────────────────────┴────────────────────────────────┴────────────────────────────────┴──────────────╯
(Showing first 3 rows)!!! note "Visual Display in Notebooks"
In Jupyter notebooks, the `image` column will display actual thumbnail images instead of `` text.This demonstrates Daft's multimodal capabilities:
- Native regex support: Use
regexp_extract()to parse structured text with Rust-powered regex - URL handling: Download content directly with
daft.functions.download() - Image decoding: Convert binary data to images with
decode_image()for visual display
The decoded images are now ready for further processing.
Batch AI Inference on Images
Let's use AI to analyze product materials at scale. Daft automatically parallelizes AI operations across your local machine's cores, making it efficient to process multiple images concurrently.
Let's suppose you want to create a new column that shows if each product is made of wood or not. This might be useful for, for example, a filtering feature on your website.
If you're running this in Google Colab or Jupyter, run the following cell to set your OpenAI API key. In Colab, first add your key to Secrets (🔑 icon in the left sidebar) with the name OPENAI_API_KEY. In Jupyter, you'll be prompted to enter your key and the input will be hidden.
import os
try:
from google.colab import userdata
os.environ["OPENAI_API_KEY"] = userdata.get("OPENAI_API_KEY")
except ImportError:
from getpass import getpass
if "OPENAI_API_KEY" not in os.environ:
os.environ["OPENAI_API_KEY"] = getpass("Enter your OpenAI API key: ")from pydantic import BaseModel, Field
from daft.functions import prompt
# Define a simple structured output model
class WoodAnalysis(BaseModel):
is_wooden: bool = Field(description="Whether the product appears to be made of wood")
# Run AI inference on each image - Daft automatically batches and parallelizes this
df = df.with_column(
"wood_analysis",
prompt(
["Is this product made of wood? Look at the material.", df["image"]],
return_format=WoodAnalysis,
model="gpt-4o-mini", # Using mini for cost-efficiency
provider="openai",
# api_key="your-key-here", # Use OPENAI_API_KEY env var, or uncomment to set manually
)
)
# Extract the boolean value from the structured output
# The result is a struct, so we extract the 'is_wooden' field
df = df.with_column(
"is_wooden",
df["wood_analysis"]["is_wooden"]
)
# Materialize the dataframe to compute all transformations
df = df.collect()
# View results
df.select("Product Name", "image", "is_wooden").show()╭────────────────────────────────┬──────────────┬───────────╮
│ Product Name ┆ image ┆ is_wooden │
│ --- ┆ --- ┆ --- │
│ String ┆ Image[MIXED] ┆ Bool │
╞════════════════════════════════╪══════════════╪═══════════╡
│ DB Longboards CoreFlex Crossb… ┆ <Image> ┆ true │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ Electronic Snap Circuits Mini… ┆ <Image> ┆ false │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ Guillow Airplane Design Studio ┆ <Image> ┆ false │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ Woodstock- Collage 500 pc Puz… ┆ <Image> ┆ false │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┤
│ 3Doodler Create Flexy 3D Prin… ┆ <Image> ┆ false │
╰────────────────────────────────┴──────────────┴───────────╯
(Showing first 5 of 5 rows)The AI analyzes each product image to determine if it's made of wood. Notice that the longboard is identified as wooden (true), while the electronic circuits, design studio, puzzle, and 3D printing filament are identified as not wooden (false).
!!! note "Improving Accuracy"
Looking at the actual product data, the longboard is made of bamboo and fiberglass, not wood. However, this is exactly what a human might categorize from the image alone! To improve accuracy, you could feed additional context to the AI like the product name, category, and description alongside the image. This example demonstrates how to get started with image-based analysis.Expanding the Analysis
Now, suppose you're satisfied with the results from your small subset and want to scale up. Instead of analyzing just 5 products, let's run the same analysis on 100 products to get more meaningful insights:
/* Detailed source-code truncated for AI context efficiency. */Out of 100 products analyzed:
- 4 are made of wood
- 96 are not made of wood
- Percentage of wooden products: 4.0%!!! note "Results May Vary"
AI models are non-deterministic, so you may see slightly different numbers when running this analysis.Storing Your Results
After processing your data, you'll often want to save it for later use. Let's store our analyzed dataset as Parquet files:
# Write the analyzed data to local Parquet files
df_large.write_parquet("product_analysis", write_mode="overwrite")This writes your data to the product_analysis/ directory. Daft automatically handles file naming using UUIDs to prevent conflicts. The write_mode="overwrite" parameter ensures that any existing data in the directory is replaced.
!!! note "Write anywhere"
Just like reading, Daft can write data to many destinations including [S3](connectors/aws.md), [Iceberg](connectors/iceberg.md), [Delta Lake](connectors/delta_lake.md), and [more](connectors/index.md).Loading Your Stored Data
Let's verify the stored data by loading it back from those Parquet files:
# Read the data back from Parquet files
df_loaded = daft.read_parquet("product_analysis/*.parquet")
# Verify the data loaded correctly
df_loaded.show(5)/* Detailed source-code truncated for AI context efficiency. */What's Next?
Now that you have a basic sense of Daft's functionality and features, here are some more resources to help you get the most out of Daft:
!!! tip "Scaling Further"
This same pipeline can process thousands or millions of products by leveraging Daft's distributed computing capabilities. Check out our [distributed computing guide](distributed/index.md) to run this analysis at scale on Ray or Kubernetes clusters.Work with your favorite table and catalog formats:
- Apache Hudi
- Apache Iceberg
- AWS Glue
- AWS S3 Tables
- Delta Lake
- Hugging Face Datasets
- Unity Catalog
- Apache Gravitino
Explore our Examples to see Daft in action:
- :material-image-edit: MNIST Digit Classification
- :octicons-search-16: Running LLMs on the Red Pajamas Dataset
- :material-image-search: Querying Images with UDFs
- :material-image-sync: Image Generation on GPUs
- :material-window-closed-variant: Window Functions in Daft
Ai Functions Overview (`docs/ai-functions/overview.md`)
AI Functions Overview
Daft is purpose-built for scaling multimodal AI workloads. AI Functions make it easy to run models on data whether you're generating embeddings, classifying text, or prompting language models. Daft's built-in Image and File abstractions simplify data handling whether it's on your laptop or in the cloud.
The current list of AI functions includes:
prompt- Generate text completions from language modelsembed_text- Create vector embeddings from textembed_image- Create vector embeddings from imagesclassify_text- Zero-shot text classificationclassify_image- Zero-shot image classification
For more detailed information on the Providers, see AI Providers Overview. If you'd like to contribute a new AI function or expand provider support, check out Contributing New AI Functions & Providers.
See Also
- Working with Text - Text processing and chunking strategies
- Working with Images - Image processing and transformations
- Embeddings - Vector representations for RAG and search
- Custom Code with
@daft.func- Write custom Python functions for specialized processing - Batch Inference - End-to-end batch inference patterns
- Checkpointing - Resume expensive AI pipelines without redoing completed work
Api Index (`docs/api/index.md`)
Daft API Documentation
Welcome to Daft Python API Documentation. For Daft User Guide, head to User Guide.
-
Functions and interfaces for building AI applications with daft. This includes providers
and various model inference protocols such as text embedding. -
Variety of approaches to creating a DataFrame from reading various data sources like in-memory data, files, data catalogs, and integrations and writing to various data sources.
-
Available DataFrame methods that are enqueued in the DataFrame's internal query plan and executed when Execution DataFrame methods are called.
-
Simple, performant, and responsible ways to access useful datasets like Common Crawl and DROID.
-
Expressions allow you to express some computation that needs to happen in a DataFrame.
-
Daft provides a set of built-in operations that can be applied to DataFrame columns.
-
User-Defined Functions (UDFs) are a mechanism to run Python code on the data that lives in a DataFrame.
-
Window functions allow you to perform calculations across a set of rows that are related to the current row.
-
Sessions enable you to attach catalogs, tables, and create temporary objects which are accessible through both the Python and SQL APIs.
-
Daft integrates with various catalog implementations using its Catalog and Table interfaces to manage catalog objects like tables and namespaces.
-
Daft can display your DataFrame's schema without materializing it by performing intelligent sampling of your data to determine appropriate schema.
-
Daft provides simple DataTypes that are ubiquituous in many DataFrames such as numbers, strings, dates, tensors, and images.
-
When performing aggregations such as sum, mean and count, Daft enables you to group data by certain keys and aggregate within those keys.
-
Series expose methods which invoke high-performance kernels for manipulation of a column of data.
-
Configure the execution backend, Daft in various ways during execution, and how Daft interacts with storage.
Architecture Index (`docs/architecture/index.md`)
Daft Architecture
Daft is a high-performance data engine designed for any modality and any scale. At a high level, a Daft query moves through 3 main stages: Planning, Optimization, and Execution.
Planning
Users express their workloads via the Python DataFrame API or the SQL interface. These APIs are lazy: operations construct a plan of execution, but they do not read or transform data yet. Internally, the workload is represented as a LogicalPlan: a tree of operators that describe what work should happen. Example operators include Source, Project, Filter, GroupBy, and Join.
Within each operator, especially the Project operator, there is a collection of expressions that define how data should be transformed. When users apply native functions (like col("a") + 1) or custom Python UDFs (such as col("b").apply(my_udf)), these are represented internally as nodes in an expression tree. Each node in the tree can be a column reference, a literal, a function call, or a UDF, and complex expressions are built by chaining these nodes together.
For example, consider the following user code:
import daft
from daft import col
df = daft.from_pydict({"a": [1, 2, 3]})
result = df.select((col("a") + 1).alias("a_plus_one"))
result.explain()Internally, this becomes a simple expression attached to the Project operator:
* Project: col(a) + lit(1) as a_plus_one
|
* Source:
| Number of partitions = 1
| Output schema = a#Int64Optimization
Executing the logical plan as written would be correct but often inefficient. Daft applies both rule-based and cost-based optimization once execution is requested.
- Rule-based pass. Classical rewrite rules execute first: filter, projection, limit, and aggregation pushdowns; projection folding and splitting; pruning redundant repartitions; expression simplification; and subquery unnesting. These transform the plan structure without needing runtime statistics.
- Cost-based pass. Joins are reordered using a brute-force enumerator that evaluates the cheapest ordering based on available statistics calculated from sources.
- Multimodal awareness. Expensive projections such as Python UDFs, model inference, URL downloads, image decoding, and similar operators are isolated into dedicated logical nodes. They are intentionally not pushed into scans so that execution can batch, schedule, and backpressure them independently. Such operations are executed as late as correctness permits (after joins, aggregations, etc.) to reduce wasted work on discarded rows.
This hybrid optimizer lets users focus on declarative queries while still obtaining efficient plans tailored to multimodal workloads.
Execution
Execution is triggered by a materializing operation such as collect(), show(), to_arrow(), and write methods such as write_parquet().
Once optimization completes, the plan is executed by either the native runner (single-machine) or the Ray runner (distributed).
Native Runner (aka Swordfish)
The native runner is a streaming execution engine implemented in Rust, using the Tokio runtime for async I/O and multithreaded operations.
Given a plan, the native runner constructs a graph of operators, where each operator corresponds to a node in the plan. Operators send data up the graph via async channels.
Once the graph is constructed, the runner initiates execution, which typically follows this pattern:
- Source operators read data from local files or object stores and emit them in batches.
- Intermediate operators (e.g. Project, Filter, UDF) receive batches of data from previous operators, immediately transform them, and send them to the next operator.
- Sinks receive and accumulate batches of data. Streaming sinks (e.g. Limit) can emit early, while blocking sinks (e.g. Aggregate, Sort) only emit results once all input has been processed.
Operators determine their own parallelism and batching, and the engine schedules work across operators on a threadpool (Tokio).
Ray Runner (aka Flotilla)
The Ray runner is a distributed execution engine built on top of Ray.
A Ray cluster contains a single head node, and all other nodes are worker nodes. On each worker node, Daft launches a single Ray actor that hosts an instance of the Swordfish engine.
The Flotilla scheduler runs on the head node and distributes tasks across workers, monitors progress, and orchestrates data movement when needed. Each task corresponds to a partition of the DataFrame, and tasks are assigned based on data locality and worker load, so computation runs close to the data while keeping cluster resources balanced. The number of partitions typically corresponds to the number of input files.
!!! tip "Controlling partitioning"
See our [Partitioning and Batching guide](../optimization/partitioning.md) to learn how to control the number and distribution of partitions in your DataFrameEach worker receives tasks and executes them using the Swordfish streaming engine with full access to the machine's resources. Outputs of the tasks are written to Ray's object store, and can be moved between workers for global operations like groupbys and joins.
Benchmarks Index (`docs/benchmarks/index.md`)
Benchmarks
AI Benchmarks
!!! info "Powered by Flotilla"
These benchmarks showcase [Flotilla](https://www.daft.ai/blog/introducing-flotilla-simplifying-multimodal-data-processing-at-scale), Daft's distributed engine optimized for multimodal data processing.
[Daft vs Spark vs Ray Data: Benchmarks for Multimodal AI Workloads](https://www.daft.ai/blog/benchmarks-for-multimodal-ai-workloads) - Full methodology and technical deep-dive.Modern AI workloads demand simple, fast, and scalable data engines. Daft is purpose-built for these workloads, excelling where traditional big data frameworks fall short.
Our benchmarks show how Daft uniquely delivers on the core needs of AI data processing:
- Process any modality: Audio, video, images, and documents as first class citizens.
- Scale from 1 to 1000: Easily scale across machines without rewriting code or configs.
- Run models over data: Seamlessly use models to embed, classify, and generate data.
Setup
The AI benchmarks cover four different workload types, each designed to test different use cases across modalities, and at large scale. Each benchmark runs on AWS g6.xlarge instances with 8 worker nodes.
The complete benchmark code is available in our repository:
- Audio Transcription - Transcribing 113,800 audio files using OpenAI Whisper-tiny model
- Document Embedding - Generating embeddings for 10,000 PDF documents using sentence-transformers
- Image Classification - Classifying 803,580 images using ResNet18 model
- Video Object Detection - Detecting objects in 1,000 videos using YOLO11n model
Each benchmark includes implementations for Daft, Ray Data, and EMR Spark, along with cluster configurations and dependencies.
Benchmark Date: September 22, 2025
Engine Versions: Daft 0.6.2, Ray Data 2.49.2, AWS EMR Spark 7.10.0
Results
<script type="text/javascript">window.PlotlyConfig = {MathJaxConfig: 'local'};</script>
<script charset="utf-8" src="https://cdn.plot.ly/plotly-2.20.0.min.js"></script>
<script type="text/javascript">
window.PLOTLYENV = window.PLOTLYENV || {};
if (document.getElementById("ai-benchmarks-chart")) {
Plotly.newPlot(
"ai-benchmarks-chart",
[
{
"marker": {"color": "rgba(255, 0, 255, 1)"},
"name": "Daft",
"x": ["Audio Transcription", "Document Embedding", "Image Classification", "Video Object Detection"],
"y": [6.37, 1.9, 4.38, 11.77],
"type": "bar",
"textposition": "inside"
},
{
"hovertext": ["4.6x Slower", "7.6x Slower", "5.4x Slower", "2.2x Slower"],
"marker": {"color": "rgba(0, 102, 255, 0.75)"},
"name": "Ray Data",
"x": ["Audio Transcription", "Document Embedding", "Image Classification", "Video Object Detection"],
"y": [29.33, 14.53, 23.5, 25.9],
"type": "bar",
"textposition": "inside"
},
{
"hovertext": ["4.0x Slower", "4.2x Slower", "10.3x Slower", "18.4x Slower"],
"marker": {"color": "rgba(226,90,28, 0.75)"},
"name": "Spark",
"x": ["Audio Transcription", "Document Embedding", "Image Classification", "Video Object Detection"],
"y": [25.77, 8.07, 45.12, 216.0],
"type": "bar",
"textposition": "inside"
}
],
{
"title": {"text": "AI Benchmarks - Performance Comparison (lower is better)"},
"yaxis": {
"title": {"text": "Time (minutes, log scale)"},
"type": "log",
"tickmode": "array",
"tickvals": [1, 2, 5, 10, 20, 40, 60, 220],
"ticktext": ["1", "2", "5", "10", "20", "40", "60", "220"]
},
"xaxis": {"title": {"text": "Workload"}},
"uniformtext": {"minsize": 8, "mode": "hide"}
},
{"displayModeBar": false, "responsive": true}
);
}
</script>| Daft | Ray Data | EMR Spark | |
|---|---|---|---|
| Audio Transcription | 6m 22s | 29m 20s (4.6x slower) | 25m 46s (4.0x slower) |
| Document Embedding | 1m 54s | 14m 32s (7.6x slower) | 8m 4s (4.2x slower) |
| Image Classification | 4m 23s | 23m 30s (5.4x slower) | 45m 7s (10.3x slower) |
| Video Object Detection | 11m 46s | 25m 54s (2.2x slower) | 3h 36m (18.4x slower) |
Logs
Complete execution logs for all AI benchmark runs are available for transparency and reproducibility:
| Workload | Daft | Ray Data | Spark |
|---|---|---|---|
| Audio Transcription | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/audio_transcription/daft.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/audio_transcription/ray_data.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/audio_transcription/spark.txt |
| Document Embedding | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/document_embedding/daft.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/document_embedding/ray_data.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/document_embedding/spark.txt |
| Image Classification | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/image_classification/daft.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/image_classification/ray_data.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/image_classification/spark.txt |
| Video Object Detection | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/video_object_detection/daft.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/video_object_detection/ray_data.txt | s3://daft-oss-public-data/benchmarking/logs/ai_benchmarking_logs/video_object_detection/spark.txt |
TPC-H Benchmarks
Here we compare Daft against some popular Distributed Dataframes such as Spark, Modin, and Dask on the TPC-H benchmark. Our goal for this benchmark is to demonstrate that Daft is able to meet the following development goals:
- Solid out of the box performance: great performance without having to tune esoteric flags or configurations specific to this workload
- Reliable out-of-core execution: highly performant and reliable processing on larger-than-memory datasets, without developer intervention and Out-Of-Memory (OOM) errors
- Ease of use: getting up and running should be easy on cloud infrastructure for an individual developer or in an enterprise cloud setting
A great stress test for Daft is the TPC-H benchmark, which is a standard benchmark for analytical query engines. This benchmark helps ensure that while Daft makes it very easy to work with multimodal data, it can also do a great job at larger scales (terabytes) of more traditional tabular analytical workloads.
Setup
The basic setup for our benchmarks are as follows:
- We run questions 1 to 10 of the TPC-H benchmarks using Daft and other commonly used Python Distributed Dataframes.
- The data for the queries are stored and retrieved from AWS S3 as partitioned Apache Parquet files, which is typical of enterprise workloads. No on disk/in-memory caching was performed.
- We run each framework on a cluster of AWS i3.2xlarge instances that each have:
- 8 vCPUs
- 61G of memory
- 1900G of NVMe SSD space
The frameworks that we benchmark against are Spark, Modin, and Dask. We chose these comparable Dataframes as they are the most commonly referenced frameworks for running large scale distributed analytical queries in Python.
For benchmarking against Spark, we use AWS EMR which is a hosted Spark service. For other benchmarks, we host our own Ray and Dask clusters on Kubernetes. Please refer to the section on our Detailed Benchmarking Setup for additional information.
Results
!!! success "Highlights"
1. Out of all the benchmarked frameworks, **only Daft and EMR Spark are able to run terabyte scale queries reliably** on out-of-the-box configurations.
2. **Daft is consistently much faster** (3.3x faster than EMR Spark, 7.7x faster than Dask Dataframes, and 44.4x faster than Modin).!!! note "Note"
We were unable to obtain full results for Modin due to cluster OOMs, errors and timeouts (one hour limit per question attempt). Similarly, Dask was unable to provide comparable results for the terabyte scale benchmark. It is possible that these frameworks may perform and function better with additional tuning and configuration. Logs for all the runs are provided in a public AWS S3 bucket.100 Scale Factor
First we run TPC-H 100 Scale Factor (around 100GB) benchmark on 4 i3.2xlarge worker instances. In total, these instances add up to 244GB of cluster memory which will require the Dataframe library to perform disk spilling and out-of-core processing for certain questions that have a large join or sort.
<script type="text/javascript">window.PlotlyConfig = {MathJaxConfig: 'local'};</script>
<script charset="utf-8" src="https://cdn.plot.ly/plotly-2.20.0.min.js"></script>
<script type="text/javascript">
window.PLOTLYENV = window.PLOTLYENV || {};
if (document.getElementById("78330a19-a541-460b-bd9f-217b9d4cd137")) {
Plotly.newPlot(
"78330a19-a541-460b-bd9f-217b9d4cd137",
[
{
"marker": {"color": "rgba(255, 0, 255, 1)"},
"name": "Daft",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [1.0666666666666667, 0.7666666666666667, 0.9833333333333333, 1.05, 1.9666666666666666, 0.6333333333333333, 1.1666666666666667, 2.25, 2.183333333333333, 1.0166666666666666],
"type": "bar",
"textposition": "inside"
},
{
"hovertext": ["5.6x Slower", "1.1x Slower", "5.1x Slower", "2.8x Slower", "2.0x Slower", "9.7x Slower", "4.3x Slower", "2.0x Slower", "2.3x Slower", "4.8x Slower"],
"marker": {"color": "rgba(226,90,28, 0.75)"},
"name": "Spark",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [5.991666666666666, 0.8716666666666666, 4.996666666666667, 2.955, 3.8583333333333334, 6.135000000000001, 4.985, 4.428333333333333, 5.051666666666667, 4.863333333333333],
"type": "bar",
"textposition": "inside"
},
{
"hovertext": ["4.2x Slower", "1.4x Slower", "6.9x Slower", "13.0x Slower", "8.2x Slower", "6.1x Slower", "6.8x Slower", "3.6x Slower", "11.8x Slower", "12.1x Slower"],
"marker": {"color": "rgba(255,193,30, 0.75)"},
"name": "Dask",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [4.456666666666666, 1.0983333333333334, 6.748333333333333, 13.615, 16.215, 3.8366666666666664, 7.96, 8.148333333333333, 25.790000000000003, 12.306666666666667],
"type": "bar",
"textposition": "inside"
},
{
"hovertext": ["29.1x Slower", "12.5x Slower", "nanx Slower", "48.6x Slower", "nanx Slower", "87.7x Slower", "nanx Slower", "nanx Slower", "nanx Slower", "52.7x Slower"],
"marker": {"color": "rgba(0,173,233, 0.75)"},
"name": "Modin",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [31.066666666666666, 9.616666666666667, null, 51.05, null, 55.53333333333333, null, null, null, 53.6],
"type": "bar",
"textposition": "inside"
}
],
{
"title": {"text": "TPCH 100 Scale Factor - 4 Nodes (lower is better)"},
"yaxis": {"title": {"text": "Time (minutes)"}},
"xaxis": {"title": {"text": "TPCH Question"}},
"uniformtext": {"minsize": 8, "mode": "hide"}
},
{"displayModeBar": false, "responsive": true}
);
}
</script>| Dataframe | Questions Completed | Total Time (seconds) | Relative to Daft |
|---|---|---|---|
| Daft | 10/10 | 785 | 1.0x |
| Spark | 10/10 | 2648 | 3.3x |
| Dask | 10/10 | 6010 | 7.7x |
| Modin | 5/10 | Did not finish | 44.4x* |
* Only for queries that completed.
From the results we see that Daft, Spark, and Dask are able to complete all the questions and Modin completes less than half. We also see that Daft is 3.3x faster than Spark and 7.7x faster than Dask including S3 IO. We expect these speed-ups to be much larger if the data is loaded in memory instead of cloud storage, which we will show in future benchmarks.
1000 Scale Factor
Next we scale up the data size by 10x while keeping the cluster size the same. Since we only have 244GB of memory and 1TB+ of tabular data, the DataFrame library will be required to perform disk spilling and out-of-core processing for all questions at nearly all stages of the query.
<script type="text/javascript">window.PlotlyConfig = {MathJaxConfig: 'local'};</script>
<script charset="utf-8" src="https://cdn.plot.ly/plotly-2.20.0.min.js"></script>
<script type="text/javascript">
window.PLOTLYENV = window.PLOTLYENV || {};
if (document.getElementById("2e3c4bff-c808-4722-8664-d4c63ee41e55")) {
Plotly.newPlot(
"2e3c4bff-c808-4722-8664-d4c63ee41e55",
[
{
"marker": {"color": "rgba(255, 0, 255, 1)"},
"name": "Daft",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [4.85, 9.766666666666667, 12.933333333333334, 11.233333333333333, 17.616666666666667, 2.7, 15.15, 18.5, 22.833333333333332, 13.983333333333333],
"type": "bar",
"textposition": "inside"
},
{
"hovertext": ["12.1x Slower", "0.9x Slower", "3.8x Slower", "2.9x Slower", "2.1x Slower", "22.3x Slower", "3.5x Slower", "2.7x Slower", "2.6x Slower", "3.4x Slower"],
"marker": {"color": "rgba(226,90,28, 0.75)"},
"name": "Spark",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [58.625, 8.591666666666667, 48.559999999999995, 32.88666666666667, 36.98166666666667, 60.11333333333334, 52.34, 49.475, 58.26166666666666, 46.85333333333333],
"type": "bar",
"textposition": "inside"
},
{
"hovertext": ["8.7x Slower", "2.1x Slower", "nanx Slower", "nanx Slower", "nanx Slower", "13.7x Slower", "nanx Slower", "nanx Slower", "nanx Slower", "nanx Slower"],
"marker": {"color": "rgba(255,193,30, 0.75)"},
"name": "Dask",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [42.37166666666667, 20.926666666666666, null, null, null, 36.968333333333334, null, null, null, null],
"type": "bar",
"textposition": "inside"
}
],
{
"title": {"text": "TPCH 1000 Scale Factor - 4 Nodes (lower is better)"},
"yaxis": {"title": {"text": "Time (minutes)"}},
"xaxis": {"title": {"text": "TPCH Question"}},
"uniformtext": {"minsize": 8, "mode": "hide"}
},
{"displayModeBar": false, "responsive": true}
);
}
</script>| Dataframe | Questions Completed | Total Time (seconds) | Relative to Daft |
|---|---|---|---|
| Daft | 10/10 | 7774 | 1.0x |
| Spark | 10/10 | 27161 | 3.5x |
| Dask | 3/10 | Did not finish | 5.8x* |
| Modin | 0/10 | Did not finish | No data |
* Only for queries that completed.
From the results we see that only Daft and Spark are able to complete all the questions. Dask completes less than a third and Modin is unable to complete any due to OOMs and cluster crashes. Since we can only compare to Spark here, we see that Daft is 3.5x faster including S3 IO. This shows that Daft and Spark are the only Dataframes in this comparison capable of processing data larger than memory, with Daft standing out as the significantly faster option.
1000 Scale Factor - Node Count Ablation
Finally, we compare how Daft performs on varying size clusters on the terabyte scale dataset. We run the same Daft TPC-H questions on the same dataset as the previous section but sweep the worker node count.
<script type="text/javascript">window.PlotlyConfig = {MathJaxConfig: 'local'};</script>
<script charset="utf-8" src="https://cdn.plot.ly/plotly-2.20.0.min.js"></script>
<script type="text/javascript">
window.PLOTLYENV = window.PLOTLYENV || {};
if (document.getElementById("8da53ffa-b330-43c6-b32b-a84051abed03")) {
Plotly.newPlot(
"8da53ffa-b330-43c6-b32b-a84051abed03",
[
{
"name": "1 Node",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [18.466666666666665, 34.7, 49.516666666666666, 37.583333333333336, 67.01666666666667, 12.133333333333333, 56.18333333333333, 68.68333333333334, 92.1, 57.63333333333333],
"type": "bar",
"textposition": "inside"
},
{
"name": "4 Node",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [4.85, 9.766666666666667, 12.933333333333334, 11.233333333333333, 17.616666666666667, 2.7, 15.15, 18.5, 22.833333333333332, 13.983333333333333],
"type": "bar",
"textposition": "inside"
},
{
"name": "8 Node",
"x": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10"],
"y": [2.6, 5.933333333333334, 6.583333333333333, 5.083333333333333, 10.2, 1.5, 7.95, 9.733333333333333, 16.666666666666668, 7.183333333333334],
"type": "bar",
"textposition": "inside"
}
],
{
"title": {"text": "TPCH 1000 Scale Factor - Node Count vs Daft Query Time"},
"yaxis": {"title": {"text": "Time (minutes)"}},
"xaxis": {"title": {"text": "TPCH Question"}},
"uniformtext": {"minsize": 8, "mode": "hide"}
},
{"displayModeBar": false, "responsive": true}
);
}
</script>We note two interesting results here:
- Daft can process 1TB+ of analytical data on a single 61GB instance without being distributed (16x more data than memory).
- Daft query times scale linearly with the number of nodes (e.g. 4 nodes being 4 times faster than a single node). This allows for faster queries while maintaining the same compute cost!
Detailed Benchmarking Setup
Benchmarking Code
Our benchmarking scripts and code can be found in the distributed-query-benchmarks GitHub repository.
- TPC-H queries for Daft were written by us.
- TPC-H queries for SparkSQL was adapted from this repository.
- TPC-H queries for Dask and Modin were adapted from these repositories for questions Q1-7 and Q8-10.
Infrastructure
Our infrastructure runs on an EKS Kubernetes cluster.
- Driver Instance: i3.2xlarge
- Worker Instance: i3.2xlarge
- Number of Workers: 1/4/8
- Networking: All instances colocated in the same Availability Zone in the AWS us-west-2 region
Data
Data for the benchmark was stored in AWS S3.
No node-level caching was performed, and data is read directly from AWS S3 on every attempt to simulate realistic workloads.
- Storage: AWS S3 Bucket
- Format: Parquet
- Region: us-west-2
- File Layout: Each table is split into 32 (for the 100SF benchmark) or 512 (for the 1000SF benchmark) separate Parquet files. Parquet files for a given table have their paths prefixed with that table’s name, and are laid out in a flat folder structure under that prefix. Frameworks are instructed to read Parquet files from that prefix.
- Data Generation: TPC-H data was generated using the utilities found in the open-sourced Daft repository. This data is also available on request if you wish to reproduce any results!
Cluster Setup
Dask and Ray
To help us run the Distributed Dataframe libraries, we used Kubernetes for deploying Dask and Ray clusters.
The configuration files for these setups can be found in our open source benchmarking repository.
Our benchmarks for Daft and Modin were run on a KubeRay cluster, and our benchmarks for Dask was run on a Dask-on-Kubernetes cluster. Both projects are owned and maintained officially by the creators of these libraries as one of the main methods of deploying.
Spark
For benchmarking Spark we used AWS EMR, the official managed Spark solution provided by AWS. For more details on our setup and approach, please consult our Spark benchmarks README.
Logs
| Dataframe | Scale Factor | Nodes | Links |
|---|---|---|---|
| Daft | 1000 | 8 | 1. s3://daft-oss-public-data/benchmarking/logs/daft.0_1_3.1tb.8-i32xlarge.log |
| Daft | 1000 | 4 | 1. s3://daft-oss-public-data/benchmarking/logs/daft.0_1_3.1tb.4-i32xlarge.log |
| Daft | 1000 | 1 | 1. s3://daft-oss-public-data/benchmarking/logs/daft.1tb.1.i3-2xlarge.part1.log 2. s3://daft-oss-public-data/benchmarking/logs/daft.1tb.1.i3-2xlarge.part2.log |
| Daft | 100 | 4 | 1. s3://daft-oss-public-data/benchmarking/logs/daft.0_1_3.100gb.4-i32xlarge.log |
| Spark | 1000 | 4 | 1. s3://daft-oss-public-data/benchmarking/logs/emr-spark.6_10_0.1tb.4-i32xlarge.log |
| Spark | 100 | 4 | 1. s3://daft-oss-public-data/benchmarking/logs/emr-spark.6_10_0.100gb.4-i32xlarge.log.gz |
| Dask (failed, multiple retries) | 1000 | 16 | 1. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.1tb.16-i32xlarge.0.log 2. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.1tb.16-i32xlarge.1.log 3. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.1tb.16-i32xlarge.2.log 4. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.1tb.16-i32xlarge.3.log |
| Dask (failed, multiple retries) | 1000 | 4 | 1. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.1tb.4-i32xlarge.q126.log |
| Dask (multiple retries) | 100 | 4 | 1. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.100gb.4-i32xlarge.0.log 2. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.100gb.4-i32xlarge.0.log 3. s3://daft-oss-public-data/benchmarking/logs/dask.2023_5_0.100gb.4-i32xlarge.1.log |
| Modin (failed, multiple retries) | 1000 | 16 | 1. s3://daft-oss-public-data/benchmarking/logs/modin.0_20_1.1tb.16-i32xlarge.0.log 2. s3://daft-oss-public-data/benchmarking/logs/modin.0_20_1.1tb.16-i32xlarge.1.log |
| Modin (failed, multiple retries) | 100 | 4 | 1. s3://daft-oss-public-data/benchmarking/logs/modin.0_20_1.100gb.4-i32xlarge.log |
Connectors Index (`docs/connectors/index.md`)
Data Connectors
Daft reads from and writes to a wide range of data sources — object storage, open table formats, catalogs, databases, files, and more. Please see Daft Connectors API docs for API details.
Object Storage
Daft natively supports reading and writing data to major cloud object storage providers:
| Provider | URL Protocols | Configuration |
|---|---|---|
| AWS S3 | s3:// |
[S3Config][daft.io.S3Config] |
| Azure Blob Storage | az://, abfs:// |
[AzureConfig][daft.io.AzureConfig] |
| Google Cloud Storage | gs://, gcs:// |
[GCSConfig][daft.io.GCSConfig] |
| Tencent Cloud COS | cos://, cosn:// |
[CosConfig][daft.io.CosConfig] |
| GooseFS | goosefs:// |
[GooseFSConfig][daft.io.GooseFSConfig] |
Table Formats
Daft reads and writes the major open table formats.
Apache Hudi
| Function | Description |
|---|---|
[read_hudi][daft.io.read_hudi] |
Read a Hudi table into a DataFrame |
See also Apache Hudi for detailed integration.
Apache Iceberg
| Function | Description |
|---|---|
[read_iceberg][daft.io.read_iceberg] |
Read an Iceberg table into a DataFrame |
[write_iceberg][daft.dataframe.DataFrame.write_iceberg] |
Write a DataFrame to an Iceberg table |
See also Apache Iceberg for detailed integration.
Delta Lake
| Function | Description |
|---|---|
[read_deltalake][daft.io.read_deltalake] |
Read a Delta Lake table into a DataFrame |
[write_deltalake][daft.dataframe.DataFrame.write_deltalake] |
Write a DataFrame to a Delta Lake table |
See also Delta Lake for detailed integration.
Apache Paimon
| Function | Description |
|---|---|
[read_paimon][daft.io.read_paimon] |
Read a Paimon table into a DataFrame |
[write_paimon][daft.dataframe.DataFrame.write_paimon] |
Write a DataFrame to a Paimon table |
See also Apache Paimon for detailed integration.
Lance
| Function | Description |
|---|---|
[read_lance][daft.io.read_lance] |
Read a Lance dataset into a DataFrame |
[write_lance][daft.dataframe.DataFrame.write_lance] |
Write a DataFrame to a Lance dataset |
See also Lance for detailed integration.
Catalogs
!!! warning "Warning"
These APIs are early in their development. Please feel free to [open feature requests and file issues](https://github.com/Eventual-Inc/Daft/issues/new/choose). We'd love to hear what you would like, thank you! 🤘Catalogs are a centralized place to organize and govern your data. A catalog is often responsible for creating objects such as tables and namespaces, managing transactions, and access control. Most importantly, the catalog abstracts away physical storage details, letting you focus on the logical structure of your data without worrying about file formats, partitioning schemes, or storage locations.
Daft integrates with these catalog implementations:
| Catalog | Description |
|---|---|
| AWS Glue | AWS Glue Data Catalog |
| AWS S3 Tables | Amazon S3 Tables catalog |
| Apache Gravitino | Apache Gravitino metadata lake |
| Unity Catalog (Databricks) | Databricks Unity Catalog |
Daft integrates with these via its Catalog and Table interfaces. These are high-level APIs to manage catalog objects (tables and namespaces), while also making it easy to leverage Daft's existing daft.read_ and df.write_ APIs for open table formats like Apache Iceberg and Delta Lake.
Example
!!! note "Note"
These examples use the Iceberg Catalog from the [Daft Sessions](../configuration/sessions-usage.md) tutorial.import daft
from daft import Catalog
# iceberg_catalog from the 'Sessions' tutorial
iceberg_catalog = load_catalog(...)
# create a daft catalog from the pyiceberg catalog instance
catalog = Catalog.from_iceberg(iceberg_catalog)
# verify
catalog
"""
Catalog('default')
"""
# we can read as a dataframe
catalog.read_table("example.tbl").schema()
"""
╭─────────────┬─────────╮
│ column_name ┆ type │
╞═════════════╪═════════╡
│ x ┆ Boolean │
├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ y ┆ Int64 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ z ┆ Utf8 │
╰─────────────┴─────────╯
"""
# give a dataframe...
df = daft.from_pylist([{ "x": False, "y": -1, "z": "xyz" }])
# we can write to tables
catalog.write_table("example.tbl", df, mode="append")
# we can get also get table instances
t = catalog.get_table("example.tbl")
# see 'Working with Tables' for what we can do!
t
"""
Table('tbl')
"""Usage
This section covers detailed usage of the current APIs with some code snippets.
Working with Catalogs
The Catalog interface allows you to perform catalog actions like get_table and list_tables.
Example
import daft
from daft import Catalog, Table
# create a catalog from a pyiceberg catalog object
_ = Catalog.from_iceberg(pyiceberg_catalog)
# create a catalog from a unity catalog object
_ = Catalog.from_unity(unity_catalog)
# we can register various types as tables, note that all are equivalent
example_dict = { "x": [ 1, 2, 3 ] }
example_df = daft.from_pydict(example_dict)
example_table = Table.from_df("temp", example_df)
# create a catalog from a pydict mapping names to tables
catalog = Catalog.from_pydict(
{
"R": example_dict,
"S": example_df,
"T": example_table,
}
)
# list available tables
# Note: Pattern syntax is catalog-dependent. Native/memory and Postgres catalogs use SQL LIKE syntax (%, _).
# Other catalogs like Iceberg use prefix matching.
catalog.list_tables(pattern=None)
"""
['R', 'S', 'T']
"""
# get a table by name
table_t = catalog.get_table("T")
#
table_t.show()
"""
╭───────╮
│ x │
│ --- │
│ Int64 │
╞═══════╡
│ 1 │
├╌╌╌╌╌╌╌┤
│ 2 │
├╌╌╌╌╌╌╌┤
│ 3 │
╰───────╯
"""Working with Tables
The Table interface is a bridge from catalogs to dataframes. We can read tables into dataframes, and we can write dataframes to tables. You can work with a table independently of a catalog by using one of the factory methods, but it might not appear to provide that much utility over the existing daft.read_ and daft.write_ APIs. You would be correct in assuming that this is what is happening under the hood! The Table interface provides indirection over the table format itself and serves as a single abstraction for reading and writing that our catalogs can work with.
Examples
from daft import Table
from pyiceberg.table import StaticTable
# suppose you have a pyiceberg table
pyiceberg_table = StaticTable("metadata.json")
# we can make it a daft table to use daft's table APIS
table = Table.from_iceberg(pyiceberg_table)
# we can read a dataframe like `daft.read_iceberg(pyiceberg_table)`
df = table.read()
# you can also create temporary tables from dataframes
daft.create_temp_table("my_temp_table", daft.from_pydict({ ... }))
# these will be resolved just like other tables
df = daft.read_table("my_temp_table")!!! note "Note"
Today you can read from `pyiceberg` and `daft.unity` table objects.Reference
!!! note "Note"
For complete documentation, please see the [Catalog & Table API docs](../api/catalogs_tables.md).- [Catalog][daft.catalog.Catalog] - Interface for creating and accessing both tables and namespaces
- [Identifier][daft.catalog.Identifier] - Paths to objects e.g.
catalog.namespace.table - [Table][daft.catalog.Table] - Interface for reading and writing dataframes
Databases
Google Cloud Bigtable
!!! warning "Experimental"
This connector is experimental and the API may change.| Function | Description |
|---|---|
[write_bigtable][daft.dataframe.DataFrame.write_bigtable] |
Write a DataFrame to Google Cloud Bigtable |
See also Bigtable for detailed integration.
ClickHouse
| Function | Description |
|---|---|
[write_clickhouse][daft.dataframe.DataFrame.write_clickhouse] |
Write a DataFrame to ClickHouse |
See also ClickHouse for detailed integration.
PostgreSQL
| Function | Description |
|---|---|
[Catalog.from_postgres][daft.catalog.Catalog.from_postgres] |
Create a catalog from a PostgreSQL database |
See also Postgres for detailed integration.
SQL Databases
| Function | Description |
|---|---|
[read_sql][daft.io.read_sql] |
Read data from a SQL database into a DataFrame |
[write_sql][daft.dataframe.DataFrame.write_sql] |
Write a DataFrame to a SQL database |
See also SQL Databases for detailed integration.
Turbopuffer
| Function | Description |
|---|---|
[write_turbopuffer][daft.dataframe.DataFrame.write_turbopuffer] |
Write a DataFrame to a Turbopuffer namespace |
See also Turbopuffer for detailed usage.
Files
File References
| Function | Description |
|---|---|
[from_files][daft.io.from_files] |
Create a DataFrame of lazy file references from a glob pattern |
[from_glob_path][daft.from_glob_path] |
Create a DataFrame of file paths from a glob pattern |
See also Files for detailed usage.
CSV
| Function | Description |
|---|---|
[read_csv][daft.io.read_csv] |
Read a CSV file or multiple CSV files into a DataFrame |
[write_csv][daft.dataframe.DataFrame.write_csv] |
Write a DataFrame to CSV files |
JSON
| Function | Description |
|---|---|
[read_json][daft.io.read_json] |
Read a JSON file or multiple JSON files into a DataFrame |
[write_json][daft.dataframe.DataFrame.write_json] |
Write a DataFrame to JSON files |
Parquet
| Function | Description |
|---|---|
[read_parquet][daft.io.read_parquet] |
Read a Parquet file or multiple Parquet files into a DataFrame |
[write_parquet][daft.dataframe.DataFrame.write_parquet] |
Write a DataFrame to Parquet files |
Text and Blob
| Function | Description |
|---|---|
[read_text][daft.io.read_text] |
Read text files into a DataFrame as strings, with one row per line |
[read_blob][daft.io.read_blob] |
Read files into a DataFrame as raw bytes, with one row per file |
See also Text Files and Blob Files for detailed usage.
WARC
| Function | Description |
|---|---|
[read_warc][daft.io.read_warc] |
Read a WARC file or multiple WARC files into a DataFrame |
ORC
| Function | Description |
|---|---|
[read_orc][daft.io.read_orc] |
Read raw ORC files into a DataFrame |
See also ORC Files for schema and file-reading behavior.
WebDataset
| Function | Description |
|---|---|
[read_webdataset][daft.io.read_webdataset] |
Read multimodal WebDataset TAR shards into a DataFrame |
See also WebDataset for format details and lazy media reads.
Video
| Function | Description |
|---|---|
[read_video_frames][daft.io.read_video_frames] |
Read video frames into a DataFrame |
Generic File Source Options
Options shared across the file-based readers above (compression, schema hints, and more). See Generic File Source Options for the full list.
Other Sources
Apache Kafka
!!! warning "Experimental"
This connector is experimental. Currently only bounded batch reads are supported — there is no streaming/unbounded mode and no offset commit management.| Function | Description |
|---|---|
[read_kafka][daft.io.read_kafka] |
Read messages from Kafka topic(s) into a DataFrame |
See also Apache Kafka for detailed integration.
MCAP
!!! warning "Experimental"
This connector is experimental. See [MCAP](mcap.md) for details.| Function | Description |
|---|---|
[read_mcap][daft.io.read_mcap] |
Read MCAP files into a DataFrame |
See also MCAP for detailed integration.
Hugging Face Datasets
| Function | Description |
|---|---|
[read_huggingface][daft.read_huggingface] |
Read a Hugging Face dataset into a DataFrame |
See also Hugging Face Datasets for detailed usage.
Custom
Custom Connectors
Implement your own source or sink to read from and write to systems Daft does not support natively:
| Function | Description |
|---|---|
[DataSink][daft.io.sink.DataSink] |
Interface for writing data from DataFrames |
[DataSource][daft.io.source.DataSource] |
Interface for reading data into DataFrames |
[DataSourceTask][daft.io.source.DataSourceTask] |
Represents a partition of data that can be processed independently |
[WriteResult][daft.io.sink.WriteResult] |
Wrapper for intermediate results written by a DataSink |
[write_sink][daft.dataframe.DataFrame.write_sink] |
Write a DataFrame to the given DataSink |
See also Custom Connectors for detailed usage.
Custom Catalogs
Implement your own catalog by extending Daft's Catalog and Table interfaces. See Custom Catalogs for detailed usage.
In-Memory
Create DataFrames directly from in-memory Python objects:
| Function | Description |
|---|---|
[from_arrow][daft.from_arrow] |
Create a DataFrame from PyArrow Tables or RecordBatches |
[from_dask_dataframe][daft.from_dask_dataframe] |
Create a DataFrame from a Dask DataFrame |
[from_pandas][daft.from_pandas] |
Create a DataFrame from a Pandas DataFrame |
[from_pydict][daft.from_pydict] |
Create a DataFrame from a python dictionary |
[from_pylist][daft.from_pylist] |
Create a DataFrame from a python list |
[from_ray_dataset][daft.from_ray_dataset] |
Create a DataFrame from a Ray Dataset |
Custom Code Index (`docs/custom-code/index.md`)
Running Custom Python Code in Daft
When Daft's built-in functions aren't sufficient for your needs, the @daft.func and @daft.cls decorators let you run your own Python code over each row of data. For custom aggregations, @daft.udaf lets you define aggregate functions with a three-stage pipeline. Simply decorate a Python function or class, and it becomes usable in Daft DataFrame operations.
If you are looking to migrate from Legacy UDFs to Daft's next generation UDF API, please see our migration guide.
Quick Example
import daft
@daft.func
def add_and_format(a: int, b: int) -> str:
return f"Sum: {a + b}"
df = daft.from_pydict({"x": [1, 2, 3], "y": [4, 5, 6]})
df = df.select(add_and_format(df["x"], df["y"]))
df.show()╭───────────╮
│ x │
│ --- │
│ Utf8 │
╞═══════════╡
│ Sum: 5 │
├╌╌╌╌╌╌╌╌╌╌╌┤
│ Sum: 7 │
├╌╌╌╌╌╌╌╌╌╌╌┤
│ Sum: 9 │
╰───────────╯Function Variants
Daft supports multiple function variants to optimize for different use cases:
- Row-wise (default): Regular Python functions process one row at a time
- Async row-wise: Async Python functions process rows concurrently
- Generator: Generator functions produce multiple output rows per input row
- Batch: Process entire batches of data with
daft.Seriesfor high performance
Daft automatically detects which variant to use for regular functions based on your function signature. For batch functions, you must use the @daft.func.batch decorator.
End to End Example with Return Type Inference
/* Detailed source-code truncated for AI context efficiency. */Distributed Index (`docs/distributed/index.md`)
Distributed Execution
Daft is designed to scale seamlessly from your laptop to distributed clusters. By default, Daft runs using your local machine's resources with the native runner (codenamed Swordfish), but when you need more compute power, you can easily scale to distributed execution across multiple machines.
There are a few ways to run Daft on multiple machines:
- For the easiest way to get started, check out our guide for running on Kubernetes
- Alternatively, if you have an existing Ray setup, check out our guide for running on Ray
Examples Index (`docs/examples/index.md`)
Examples
-
Read a LeRobot dataset, run hand tracking as a Daft UDF, and score the results against EgoDex ground truth.
Reward Scoring for Physical AI
Read LIBERO episodes with Daft's LeRobot reader and score each one with a reward model - per-frame task progress plus success probability - then filter low-quality episodes with a Daft query.
Motion Trimming for Physical AI
Read DROID episodes with Daft's LeRobot reader, score per-frame motion from the robot's own joint positions - no video decode - and reduce it to one trim window per episode.
Multimodal Structured Outputs: Evaluating Image Understanding
Leverage image ablation to analyze textual bias in image understanding datasets.
Voice AI Analytics with Faster-Whisper and embed_text
Transcribe audio files into segments with timestamps and embed content.
MinHash Deduplication on Common Crawl
Clean web text at scale with MinHash, LSH Banding, and Connected Components.
Getting Started with Common Crawl
Daft provides a simple, performant, and responsible way to access Common Crawl data.
Audio Transcription with Whisper
Effortlessly transcribe audio to text at scale.
Build a 100% GPU Utilization Text Embedding Pipeline featuring spaCy and Turbopuffer
Generate and store millions of text embeddings in vector databases using distributed GPU processing and state-of-the-art models.
Generate Images with Stable Diffusion
Open Source image generation model on your own GPUs using Daft UDFs.
Daft's Four UDF Pattern Tutorial
One notebook, four UDF patterns, one dataset. Row-wise, generator, async, and stateful -- learn when to use each.
Window Functions: The Great Chocolate Race
Explore how window functions can reduce complex joins and groupby's to just a few simple operations.
Running LLMs on the Red Pajamas Dataset
Perform similarity search on Stack Exchange questions using language models and embeddings.
!!! tip "More Examples"
For more examples, check out our new daft-examples repository!
Extensions Overview (`docs/extensions/overview.md`)
Extensions
Daft extensions are reusable libraries that add domain-specific functionality
on top of Daft without requiring every specialized function, datatype,
integration, or workflow to live directly in Daft core.
There are two broad ways to build extensions:
- Python UDF-based extensions: Python packages built on Daft's
custom-code APIs, including@daft.func,@daft.func.batch,@daft.cls, and@daft.method.batch. - Native ABI extensions: shared-library-backed packages that use Daft's
Arrow C Data Interface ABI for lower-level, vectorized native execution.
Both models can expose clean Python APIs that feel like ordinary Daft
expression functions. The implementation details can stay hidden behind
functions, classes, and expressions.
Python UDF-Based Extensions
The fastest way to build a Daft extension is often pure Python. Using@daft.func, @daft.func.batch,@daft.cls, and @daft.method.batch,
contributors can package reusable Python logic that runs inside Daft's
distributed execution engine.
This is ideal for libraries that orchestrate existing Python ecosystems,
external services, ML models, GPUs, data maintenance tasks, or domain-specific
workflows. These extensions do not require a native shared library or the
Arrow C ABI. They are normal Python packages that expose higher-level Daft
APIs.
daft-lance is an example of
this model. It extends Daft with Lance-specific distributed operations such as
file compaction, scalar indexing, column merging, and REST catalog operations.
Internally, it uses Daft's Python UDF and class-UDF APIs to distribute Lance
tasks across a Daft query, while users interact with functions likecompact_files, create_scalar_index, and merge_columns_df.
This pattern also exists inside Daft itself. Thedaft.functions.ai module exposes high-level functions likeprompt,embed_text,embed_image,classify_text, andclassify_image. To users, these look
like normal Daft expression functions. Under the hood, they use Daft's UDF and
class-UDF machinery, including batching, concurrency controls, retries, and
GPU resource hints.
The file APIs follow a similar product pattern. daft.File
values and helpers like file,audio_file,video_file,file_path, andfile_size give users expression-level
building blocks for working with files. Those file objects can then flow into
Python UDFs, model pipelines, and domain-specific libraries without users
needing to think about execution details.
Native ABI Extensions
For contributors who need lower-level, vectorized performance, Daft also
supports native extensions through a C ABI based on the
Arrow C Data Interface.
Native ABI extensions can add high-performance scalar functions, aggregate
functions, Python expression wrappers, and extension-backed datatypes. They
ship as pip-installable Python packages that bundle a native shared library.
Users import the package, load it into a Daft Session,
and call ordinary Python wrappers in their DataFrame expressions.
Rust currently has the most ergonomic SDK throughdaft-ext,
while C++ is demonstrated through the raw ABI. Other systems languages are
possible if they can produce a shared library, export the expected C ABI, and
read/write Arrow C Data Interface arrays.
Daft's own examples include:
examples/hello:
a minimal Rust native extension that registers agreetscalar function.examples/dvector:
a pgvector-style native extension for vector distance functions such asl2_distance,cosine_distance,inner_product, andjaccard_distance.examples/hello_cpp:
a pure C++ native extension using Apache Arrow C++ and the raw Daft C ABI.
What Can Contributors Build?
Contributors can build:
- Pure-Python UDF extension libraries using
@daft.func,@daft.func.batch,@daft.cls, and@daft.method.batch - Distributed task libraries like
daft-lance - GPU-backed model inference extensions
- AI, file, media, and multimodal processing libraries that hide UDFs behind
expression APIs - Native scalar functions
- Native aggregate functions / UDAFs
- Python expression wrappers
- Extension-backed logical datatypes such as
DataType.extension(...)
Where to Start
- Browse installable projects in Community Extensions.
- Learn how to build a native extension in the Authoring Guide.
- Browse projects using Daft in Built on Daft.
- Learn more about Daft's Python UDFs.
Modalities Overview (`docs/modalities/overview.md`)
Modalities Overview
Daft is designed to work with any modality.
Artificial Intelligence now natively understands text, images, audio, video, and documents, but legacy engines were never designed to feed these formats to large models. Daft closes that gap, giving you one distributed engine that processes any modality, respects memory limits, and keeps GPUs fed so you can build the pipeline once and scale it anywhere.
🔠 Text
Normalize, chunk, dedupe, prompt, and embed text data.
🌄 Images
Work with visual data and image processing. Use
daft.ImageFilefor lazy metadata extraction and filtered decoding.🔉 Audio
Read, extract metadata, resample audio files.
🎥 Video
Working with video files and metadata.
-
Extract text and image data from PDF documents.
-
Parse, query, and manipulate semi-structured and hierarchical data.
-
Generate vector representations for RAG and AI search.
-
Take advantage of Daft's built-in URL functions and
daft.Filetypes
**[Custom Modalities](custom.md)**
The most important modality might be one we haven't explored yet. Daft makes it easy to define your own modality with custom connectors to read and write any kind of data, and use user-defined functions to process custom Python code efficiently and reliably at scale.
Sql Index (`docs/sql/index.md`)
Daft SQL
SQL Reference
Welcome to Daft SQL Reference.
Daft's SQL dialect closely follows both DuckDB and PostgreSQL.
Example
Please see Sessions and Catalogs for a detailed look at connecting data sources to Daft SQL.
import daft
from daft import Session
# create a session
sess = Session()
# create temp tables
sess.create_temp_table("T", daft.from_pydict({ "a": [ 0, 1 ] }))
sess.create_temp_table("S", daft.from_pydict({ "b": [ 1, 0 ] }))
# execute sql
sess.sql("SELECT * FROM T, S").show()
"""
╭───────┬───────╮
│ a ┆ b │
│ --- ┆ --- │
│ Int64 ┆ Int64 │
╞═══════╪═══════╡
│ 0 ┆ 1 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 1 ┆ 1 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 0 ┆ 0 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 1 ┆ 0 │
╰───────┴───────╯
"""Usage
SQL with DataFrames
Daft's [daft.sql][daft.sql.sql.sql] function automatically detects any [daft.DataFrame][daft.DataFrame] objects in your current Python environment to let you query them easily by name.
=== "⚙️ SQL"
```python
# Note the variable name my_special_df
my_special_df = daft.from_pydict({"A": [1, 2, 3], "B": [1, 2, 3]})
# Use the SQL table name "my_special_df" to refer to the above DataFrame!
sql_df = daft.sql("SELECT A, B FROM my_special_df")
sql_df.show()
```╭───────┬───────╮
│ A ┆ B │
│ --- ┆ --- │
│ Int64 ┆ Int64 │
╞═══════╪═══════╡
│ 1 ┆ 1 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 2 ┆ 2 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 3 ┆ 3 │
╰───────┴───────╯
(Showing first 3 of 3 rows)In the above example, we query the DataFrame called "my_special_df" by simply referring to it in the SQL command. This produces a new DataFrame sql_df which can natively integrate with the rest of your Daft query. You can also use table functions to query sources directly.
=== "🐍 Python"
python daft.sql("SELECT * FROM read_parquet('s3://...')") daft.sql("SELECT * FROM read_iceberg('s3://.../metadata.json')")
Table Function Options
Table functions take the path as the first positional argument, plus any of the options
below. Named arguments use either => or :=:
=== "🐍 Python"
python daft.sql("SELECT * FROM read_csv('data.csv', has_headers => false)") daft.sql("SELECT * FROM read_csv('data.csv', has_headers := false)")
read_parquet, read_csv, read_json, and read_deltalake also accept the path itself
as a named path argument:
=== "🐍 Python"
python daft.sql("SELECT * FROM read_csv(path => 'data.csv')")
A list of paths is written as a SQL array:
=== "🐍 Python"
python daft.sql("SELECT * FROM read_parquet(['a.parquet', 'b.parquet'])")
| Function | Options |
|---|---|
read_parquet |
infer_schema, schema, coerce_int96_timestamp_unit, chunk_size, multithreaded, io_config, file_path_column, hive_partitioning, ignore_corrupt_files |
read_csv |
infer_schema, schema, has_headers, delimiter, double_quote, quote, escape_char, comment, allow_variable_columns, io_config, file_path_column, hive_partitioning, buffer_size, chunk_size, ignore_corrupt_files |
read_json |
infer_schema, schema, io_config, file_path_column, hive_partitioning, buffer_size, chunk_size, skip_empty_files |
read_iceberg |
snapshot_id, branch, tag, io_config, ignore_corrupt_files |
read_deltalake |
io_config |
These mirror the corresponding Python reader arguments — see
[read_parquet][daft.read_parquet], [read_csv][daft.read_csv],
[read_json][daft.read_json], [read_iceberg][daft.read_iceberg], and
[read_deltalake][daft.read_deltalake] for what each one does, and
Generic File Source Options
for ignore_corrupt_files in depth.
A schema is given as a struct literal mapping column names to type names:
=== "🐍 Python"
python daft.sql(""" SELECT * FROM read_csv('data.csv', schema := {'a': 'int64', 'b': 'string'}) """)
For Iceberg, snapshot_id, branch, and tag select which version to read and are
mutually exclusive:
=== "🐍 Python"
python daft.sql("SELECT * FROM read_iceberg('/warehouse/db/t/metadata/v3.metadata.json', branch => 'audit')")
ignore_corrupt_files skips unreadable files instead of failing the query, anddf.skipped_corrupt_files reports what was skipped once the DataFrame is materialized:
=== "🐍 Python"
python df = daft.sql("SELECT * FROM read_csv('s3://my-bucket/data/**/*.csv', ignore_corrupt_files => true)") df.collect() print(df.skipped_corrupt_files)
SQL Expressions
SQL has the concept of expressions as well. Here is an example of a simple addition expression, adding columns A and B in SQL to produce a new column C.
We also present here the equivalent query for SQL and DataFrame. Notice how similar the concepts are!
=== "⚙️ SQL"
python df = daft.from_pydict({"A": [1, 2, 3], "B": [1, 2, 3]}) df = daft.sql("SELECT A + B as C FROM df") df.show()
=== "🐍 Python"
``` python
expr = (daft.col("A") + daft.col("B")).alias("C")
df = daft.from_pydict({"A": [1, 2, 3], "B": [1, 2, 3]})
df = df.select(expr)
df.show()
```╭───────╮
│ C │
│ --- │
│ Int64 │
╞═══════╡
│ 2 │
├╌╌╌╌╌╌╌┤
│ 4 │
├╌╌╌╌╌╌╌┤
│ 6 │
╰───────╯
(Showing first 3 of 3 rows)In the above query, both the SQL version of the query and the DataFrame version of the query produce the same result.
Under the hood, they run the same Expression col("A") + col("B")!
One really cool trick you can do is to use the [daft.sql_expr][daft.sql.sql.sql_expr] function as a helper to easily create Expressions. The following are equivalent:
=== "⚙️ SQL"
python sql_expr = daft.sql_expr("A + B as C") print("SQL expression:", sql_expr)
=== "🐍 Python"
python py_expr = (daft.col("A") + daft.col("B")).alias("C") print("Python expression:", py_expr)
SQL expression: col(A) + col(B) as C
Python expression: col(A) + col(B) as CThis means that you can pretty much use SQL anywhere you use Python expressions, making Daft extremely versatile at mixing workflows which leverage both SQL and Python.
As an example, consider the filter query below and compare the two equivalent Python and SQL queries:
=== "⚙️ SQL"
```python
df = daft.from_pydict({"A": [1, 2, 3], "B": [1, 2, 3]})
# Daft automatically converts this string using `daft.sql_expr`
df = df.where("A < 2")
df.show()
```=== "🐍 Python"
``` python
df = daft.from_pydict({"A": [1, 2, 3], "B": [1, 2, 3]})
# Using Daft's Python Expression API
df = df.where(df["A"] < 2)
df.show()
```╭───────┬───────╮
│ A ┆ B │
│ --- ┆ --- │
│ Int64 ┆ Int64 │
╞═══════╪═══════╡
│ 1 ┆ 1 │
╰───────┴───────╯
(Showing first 1 of 1 rows)Pretty sweet! Of course, this support for running Expressions on your columns extends well beyond arithmetic as we'll see in the next section on SQL Functions.
SQL Functions
SQL also has access to all of Daft's powerful [daft.Expression][daft.Expression] functionality through SQL functions.
However, unlike the Python Expression API which encourages method-chaining (e.g. col("a").download().decode_image()), in SQL you have to do function nesting instead (e.g. "image_decode(url_download(a))").
Here is an example of an equivalent function call in SQL vs Python:
=== "⚙️ SQL"
python df = daft.from_pydict({"urls": [ "https://user-images.githubusercontent.com/17691182/190476440-28f29e87-8e3b-41c4-9c28-e112e595f558.png", "https://user-images.githubusercontent.com/17691182/190476440-28f29e87-8e3b-41c4-9c28-e112e595f558.png", "https://user-images.githubusercontent.com/17691182/190476440-28f29e87-8e3b-41c4-9c28-e112e595f558.png", ]}) df = daft.sql("SELECT image_decode(url_download(urls)) FROM df") df.show()
=== "🐍 Python"
python df = daft.from_pydict({"urls": [ "https://user-images.githubusercontent.com/17691182/190476440-28f29e87-8e3b-41c4-9c28-e112e595f558.png", "https://user-images.githubusercontent.com/17691182/190476440-28f29e87-8e3b-41c4-9c28-e112e595f558.png", "https://user-images.githubusercontent.com/17691182/190476440-28f29e87-8e3b-41c4-9c28-e112e595f558.png", ]}) df = df.select(daft.col("urls").download().decode_image()) df.show()
╭──────────────╮
│ urls │
│ --- │
│ Image[MIXED] │
╞══════════════╡
│ <Image> │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ <Image> │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ <Image> │
╰──────────────╯
(Showing first 3 of 3 rows)SUMMARY (`docs/SUMMARY.md`)
- Guide
- Overview
- Quickstart
- Installation
- AI Functions
- Modalities
- User Defined Functions
- Common Use Cases
- Datasets
- Data Connectors
- Overview
- Object Storage
- Table Formats
- Catalogs
- Databases
- Files
- Other Sources
- Custom
- Extensions
- Architecture
- Scaling & Performance
- Scaling Out and Deployment
- Optimization
- Checkpointing
- Observability
- Sessions, Catalogs, and Tables
- Resources
- Examples
- Examples
- Hand Tracking for Physical AI
- Reward Scoring for Physical AI
- Motion Trimming for Physical AI
- Multimodal Structured Outputs: Evaluating Image Understanding
- Voice AI Analytics with Faster-Whisper and embed_text
- Web Text Deduplication
- Audio Transcription
- Generate Text Embeddings for Turbopuffer
- Running LLMs on the Red Pajamas Dataset
- Generate Images from Text with Stable Diffusion
- Querying Image Data
- MNIST Digit Classification
- UDF Patterns
- Window Functions
- Working with Common Crawl Data
- Document Processing
- Python API
- SQL Reference
- SQL Reference
- Statements
- Data Types
- Identifiers
- Window Functions
- Contributing
- Daft Skills
Install (`docs/install.md`)
How to Install Daft
This guide helps you install Daft based on your specific needs and environment.
For most users, install Daft with a single command:
pip install -U daftOptional Dependencies
Depending on your use case, you may need to install Daft with additional dependencies.
<h4>Use Cases:</h4>
<label class="checkbox-item">
**Hugging Face** `huggingface`
</label>
<label class="checkbox-item">
**OpenAI** `openai`
</label>
<label class="checkbox-item">
**Transformers** `transformers`
</label>
<label class="checkbox-item">
**Distributed Computing on Ray** `ray`
</label>
<label class="checkbox-item">
**Turbopuffer** `turbopuffer`
</label>
<label class="checkbox-item">
**AWS Glue or AWS S3 Tables** `aws`
</label>
<label class="checkbox-item">
**Apache Iceberg** `iceberg`
</label>
<label class="checkbox-item">
**Delta Lake** `deltalake`
</label>
<label class="checkbox-item">
**Unity Catalog (Databricks)** `unity`
</label>
<label class="checkbox-item">
**LanceDB** `lance`
</label>
<label class="checkbox-item">
**ClickHouse** `clickhouse`
</label>
<label class="checkbox-item">
**Azure Blob Storage** `azure`
</label>
<label class="checkbox-item">
**Google Gemini** `google`
</label>
<label class="checkbox-item">
**Apache Kafka** `kafka`
</label>
<label class="checkbox-item">
**Apache Gravitino** `gravitino`
</label>
<label class="checkbox-item">
**PostgreSQL** `postgres`
</label>
<label class="checkbox-item">
**SQL Databases** `sql`
</label>
<label class="checkbox-item">
**pandas** `pandas`
</label>
<label class="checkbox-item">
**NumPy** `numpy`
</label>
<label class="checkbox-item">
**Audio** `audio`
</label>
<label class="checkbox-item">
**Video** `video`
</label>
<label class="checkbox-item">
**HDF5** `hdf5`
</label>
<label class="checkbox-item">
**HTTP** `http`
</label>
<label class="checkbox-item">
**MCAP** `mcap`
</label>
<h4>Installation Command:</h4>| 1 | code">pip install -U daft |
|---|
You can also install Daft with all extra dependencies:
pip install -U "daft[all]"Nightly Builds
Nightly builds are published daily from the latest main branch. These are useful for testing upcoming features and bug fixes before they are released on PyPI.
pip install daft --pre --extra-index-url https://nightly.daft.ai!!! warning "Stability"
Nightly builds may contain unstable or experimental changes. They are not recommended for production use.
Troubleshooting Legacy CPU Support
If you encounter Illegal instruction errors, your CPU may lack support for advanced instruction sets like AVX. Use the LTS version instead:
pip install -U daft-lts!!! warning "Performance Impact"
The LTS version uses limited CPU instructions and cannot leverage vectorized operations, resulting in slower performance. Only use this if the standard package fails to run.
Roadmap (`docs/roadmap.md`)
Roadmap
Last updated: March 2026
This roadmap outlines the big picture of what the Daft team plans to work on in the coming year, as well as some of the features to expect from these.
Please note that items on this roadmap are subject to change at any time. If there are features you would like to implement, we welcome and encourage open-source contributions. Our team is happy to provide guidance, help scope the work, and review PRs.
Feel free to open an issue or PR on GitHub ↗ or join our Daft Slack Community ↗.
Performance
- Performant large scale shuffles - Support for native shuffling services for data transfer in our distributed engine (Flotilla). This includes a specialized peer-to-peer service based on Arrow Flight RPC. (discussion #6472 ↗)
- Distributed engine optimization - Optimizing our distributed engine (Flotilla) to dynamically schedule and execute tasks to minimize worker setup overhead and contention, add dynamic partitioning.
- Memory management - Tracking and managing memory usage within our local runner to reduce chances of OOMs, inform backpressure and dynamic batching.
- Hash-based operator improvements - Improve partitioning and hash-table building techniques within various hash-based operators, particularly groupby aggregations and hash joins in low-cardinality cases.
Key Features
- Checkpointing - Ability to checkpoint and resume long running workloads from a given checkpoint. (discussion #6446)
- Kubernetes support - Distributed Daft running directly on Kubernetes.
- Multiple distributed backends - Enable Daft to run distributed on multiple backends with a generic interface and continued support of Ray as a backend.
Observability
- Daft Dashboard - Observability dashboard with support for Flotilla, providing cluster, task and partition level observability.
- Improved Debugging - Support for capturing and retrieving debugging information and run history.
- Memory and CPU observability - Ability to capture and retrieve resource utilization metrics.
Extensibility
- Native extensions - Support for Daft native extensions via the Arrow C interfaces; enabling developers to extend Daft in any language which supports C-based interop.
- Data Source refactor - Simplify the source pipeline to make adding native Rust sources straightforward.
- Arrow2 deprecation (discussion #5741)
Future Work
The following features are in consideration, but are not currently on our roadmap. We're sharing these to highlight opportunities for open source contributions, invite discussion around implementation approaches, and provide further visibility. These features have been tagged with help wanted and good first issue on Daft repo ↗.
- Improved Delta Lake support (see roadmap for Delta Lake ↗)
- Support for reading tables with deletion vectors (issue #1954 ↗)
- Support for reading tables with column mappings (issue #1955 ↗)
- VARIANT type - Support for the VARIANT type into Daft's type system which is compatible with the parquet VARIANT.
Result<T>type - Add aResult<T>type (Either) to Daft's type system to support graceful handling of fallible operations.
If you are interested in working on any of these features, feel free to open an issue or start a discussion on GitHub ↗ or join our Daft Slack Community ↗. Our team can provide technical direction and help scope the work appropriately. Thank you in advance 💜
Robots (`docs/robots.txt`)
User-agent: *
Disallow: /en/
Allow: /en/stable
Sitemap: https://docs.daft.ai/en/stable/sitemap.xml
Skills (`docs/skills.md`)
Daft Skills
Daft ships with built-in skills that help AI coding agents work effectively with Daft.
Available skills
[daft-udf-tuning](https://github.com/Eventual-Inc/Daft/blob/main/.claude/skills/daft-udf-tuning/SKILL.md)
Optimize User-Defined Functions for performance.
Triggers: GPU inference setup, slow UDFs, async or batch processing questions.
What it helps with:
- Choosing between
@daft.func(stateless),@daft.cls(stateful), and@daft.func.batch(vectorized) - Async I/O patterns for web APIs
- GPU batch inference with PyTorch/model loading via
@daft.cls(gpus=N) - Tuning
max_concurrency,batch_size,gpus, andinto_batches(N)
[daft-distributed-scaling](https://github.com/Eventual-Inc/Daft/blob/main/.claude/skills/daft-distributed-scaling/SKILL.md)
Scale single-node workflows to distributed execution.
Triggers: Performance optimization, handling large datasets, distributed execution.
What it helps with:
- Choosing between shuffle (
repartition(N)) and streaming (into_batches(N)) strategies - Calculating optimal partition counts for your cluster
- Avoiding OOM errors with heavy data (images, tensors)
- Advanced tuning formulas for repartitioning and streaming
[daft-docs-navigation](https://github.com/Eventual-Inc/Daft/blob/main/.claude/skills/daft-docs-navigation/SKILL.md)
Navigate Daft's documentation for APIs, concepts, and examples.
Triggers: General questions about Daft APIs, concepts, or searching the docs.
What it helps with:
- Navigating key doc locations: API reference, optimization, distributed, UDFs, connectors
- Searching for API usage in the
docs/directory - Browsing the docs structure via
docs/SUMMARY.md
Installation
Already cloned Daft (automatic)
If you're working inside a Daft clone, the skills are already available. No setup needed - Claude Code picks them up from .claude/skills/.
Install with `npx skills add`
To use these Daft skills outside the repo (across many agents including Claude Code, Cursor, Codex, Gemini CLI, Copilot, and more):
# Install all skills
npx skills add Eventual-Inc/Daft
# Install a specific skill
npx skills add Eventual-Inc/Daft --skill daft-udf-tuningFollow the prompts to choose skills, agents, scope (project or global), and whether to symlink or copy.
Claude Code plugin
These Daft skills are also available as a Claude Code plugin.
claude plugin marketplace add Eventual-Inc/Daft
claude plugin install daft@Eventual-IncTelemetry (`docs/telemetry.md`)
Telemetry
To help core developers improve Daft, we collect non-identifiable statistics on Daft usage in order to better understand how Daft is used, common bugs and performance bottlenecks. Data is collected via Scarf and osstelemetry.io.
We take the privacy of our users extremely seriously, and telemetry in Daft is built to be:
Easy to opt-out: To disable telemetry, set the environment variable
DO_NOT_TRACK=trueNon-identifiable: No session IDs or user identifiers are collected
Metadata-only: We do not collect any of our users' proprietary code or data
We do not sell or buy any of the data that is collected in telemetry.
!!! info "Daft telemetry is enabled in versions >= v0.0.21"
What data do we collect?
To audit what data is collected, please see scarf_telemetry.py.
In short, we collect the following:
- On import, we track the version of Daft, OS, Python version, and system architecture.
- On DataFrame executions, we track the same system information as we do on imports, in addition to the runner being used, i.e. native or Ray.
Ai Functions Classify (`docs/ai-functions/classify.md`)
Text and Image Classification with Daft
Daft provides powerful classification functions for both text and images, making it easy to label and categorize your data at scale. Whether you're working with sentiment analysis, content categorization, or visual recognition tasks, Daft's classification functions integrate seamlessly with popular models from Hugging Face and other providers.
!!! tip "Choosing the Right Approach"
- Use classify_text and classify_image for fast, efficient classification with pre-defined labels
- Use prompt for more flexible classification that requires explanations or complex reasoning
- For custom models, Daft supports both Hugging Face Transformers and OpenAI-compatible providers
Text Classification
The classify_text function provides fast, efficient text classification using zero-shot models or fine-tuned classifiers. It's ideal for tasks like sentiment analysis, topic categorization, and intent detection where you have predefined labels.
import daft
from daft.functions import classify_text
df = daft.from_pydict({
"text": [
"One day I will see the world",
"I've always enjoyed preparing dinner for my family",
],
})
df = df.with_column(
"label",
classify_text(
daft.col("text"),
labels=['travel', 'cooking', 'dancing'],
provider="transformers",
model="facebook/bart-large-mnli",
),
)
df.show()╭────────────────────────────────┬─────────╮
│ text ┆ label │
│ --- ┆ --- │
│ String ┆ String │
╞════════════════════════════════╪═════════╡
│ One day I will see the world ┆ travel │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ I've always enjoyed preparing… ┆ cooking │
╰────────────────────────────────┴─────────╯
(Showing first 2 of 2 rows)Image Classification
The classify_image function uses vision models like CLIP for zero-shot image classification. This example shows a complete pipeline including image preprocessing steps:
import daft
from daft.functions import classify_image, decode_image, convert_image, resize
df = (
# Discover a few images from HuggingFace
daft.from_glob_path("hf://datasets/datasets-examples/doc-image-3/images")
# Read the 4 PNG, JPEG, TIFF, WEBP Images
.with_column("image_bytes", daft.col("path").download())
# Decode the image bytes into a daft Image DataType
.with_column("image_type", decode_image(daft.col("image_bytes")))
# Convert Image to RGB and resize the image to 224x224
.with_column(
"image_resized", resize(convert_image(daft.col("image_type"), "RGB"), 224, 224)
)
# Classify the image
.with_column(
"image_label",
classify_image(
daft.col("image_resized"),
provider="transformers",
labels=["bulbasaur", "catapie", "voltorb", "electrode"],
model="openai/clip-vit-base-patch32",
),
)
)
df.show()/* Detailed source-code truncated for AI context efficiency. */!!! note "When to Use Prompts for Classification"
Use classify_image and classify_text when you have a fixed set of labels and need fast, efficient classification. Use prompt when you need:
- Explanations for classification decisions
- Complex reasoning or multi-step logic
- Dynamic or unstructured label sets
- Additional context beyond a simple labelFlexible Classification with Prompts
When you need more flexibility—such as generating explanations, handling complex reasoning, or working with unstructured labels—use the prompt function. This approach trades some speed for significantly more capability.
import os
import daft
from daft.functions import prompt
daft.set_provider(
"openai",
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY")
)
df = daft.from_pydict({
"quote": [
"I am going to be the king of the pirates!",
"I'm going to be the next Hokage!",
],
})
df = (
df
.with_column(
"response",
prompt(
daft.col("quote"),
system_message="Classify the anime from the quote and return the show, character name, and explanation.",
provider="openai",
model="nvidia/nemotron-nano-9b-v2:free"
)
)
)
df.show(format="fancy", max_width=120)╭───────────────────────────────────────────┬──────────────────────────────────────────────────────────────╮
│ quote ┆ response │
╞═══════════════════════════════════════════╪══════════════════════════════════════════════════════════════╡
│ I am going to be the king of the pirates! ┆ **Show:** *One Piece* │
│ ┆ **Character Name:** Monkey D. Luffy │
│ ┆ **Explanation:** │
│ ┆ The quote "I am going to be the king… │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ I'm going to be the next Hokage! ┆ **Show:** *Naruto* │
│ ┆ **Character Name:** Naruto Uzumaki │
│ ┆ **Explanation:** The quote "I'm going to be the next Hokage… │
╰───────────────────────────────────────────┴──────────────────────────────────────────────────────────────╯
(Showing first 2 of 2 rows)Ai Functions Embed (`docs/ai-functions/embed.md`)
Generate Embeddings from Text and Images with Daft
Embeddings are dense vector representations of data that capture semantic meaning. Daft provides fast, scalable embedding generation for both text and images, with support for various providers including OpenAI, Transformers, and local models.
Text Embeddings
The embed_text function generates embeddings from text using various models. You can use AI functions without explicitly configuring a provider—if you have an OPENAI_API_KEY environment variable set, simply specify provider="openai" and it will automatically create a provider in your session.
import daft
from daft.functions import embed_text
df = daft.from_pydict({"text": ["Hello World"]})
df = df.with_column(
"embeddings",
embed_text(
daft.col("text"),
provider="openai",
model="text-embedding-3-small"
)
)
df.show()╭─────────────┬──────────────────────────╮
│ text ┆ embeddings │
│ --- ┆ --- │
│ String ┆ Embedding[Float32; 1536] │
╞═════════════╪══════════════════════════╡
│ Hello World ┆ ▆█▆▆▆▃▆▆▂▄▃▂▃▃▄▁▃▅▂▃▂▂▂▂ │
╰─────────────┴──────────────────────────╯Local Embeddings with LM Studio
For privacy-sensitive workloads or offline environments, you can generate embeddings locally with LM Studio. This lets you run embedding models like Nomic, Qwen, or Mistral on your own machine, taking advantage of hardware accelerators like Apple's Metal Performance Shaders (MPS).
import daft
from daft.functions import embed_text
df = daft.from_pydict({
"text": ["Hello, world!"]
})
df = df.with_column(
"embedding",
embed_text(daft.col("text"), model="text-embedding-nomic-embed-text-v1.5", provider="lm_studio")
)
df.show()╭───────────────┬──────────────────────────╮
│ text ┆ embedding │
│ --- ┆ --- │
│ String ┆ Embedding[Float32; 768] │
╞═══════════════╪══════════════════════════╡
│ Hello, world! ┆ █▄▄▃▃▄▃▄▃▆▂▁▂▅▅▂▂▃▅▄▄▃▂▄ │
╰───────────────┴──────────────────────────╯
(Showing first 1 of 1 rows)Provider-specific request parameters
OpenAI-compatible embedding servers can accept additional JSON request properties through extra_body. For example,
vLLM can truncate long inputs from the right, keeping the first 4,096 tokens:
df = df.with_column(
"embedding",
embed_text(
daft.col("text"),
provider=provider,
model="BAAI/bge-m3",
extra_body={
"truncate_prompt_tokens": 4096,
"truncation_side": "right",
},
),
)Image Embeddings
The embed_image function generates embeddings from images using vision models. Daft natively supports images with the Image DataType, making multimodal preprocessing straightforward with built-in utilities like decode_image, convert_image, and resize.
This example shows a complete pipeline: reading multiple image formats (PNG, JPEG, TIFF, WEBP) from HuggingFace, preprocessing them to the required format, and generating embeddings using Apple's aimv2-large-patch14-224-lit model.
import daft
from daft.functions import embed_image, decode_image, convert_image, resize
df = (
# Discover a few images from HuggingFace
daft.from_glob_path("hf://datasets/datasets-examples/doc-image-3/images")
# Read the 4 PNG, JPEG, TIFF, WEBP Images
.with_column("image_bytes", daft.col("path").download())
# Decode the image bytes into a daft Image DataType
.with_column("image_type", decode_image(daft.col("image_bytes")))
# Convert Image to RGB and resize the image to 288x288
.with_column("image_resized", resize(convert_image(daft.col("image_type"), "RGB"), 288, 288))
# Embed the image
.with_column(
"image_embeddings",
embed_image(
daft.col("image_resized"),
provider="transformers",
model="apple/aimv2-large-patch14-224-lit"
)
)
)
# Show the dataframe
df.show()/* Detailed source-code truncated for AI context efficiency. */!!! tip "Image Preprocessing"
When working with images for embedding, it's important to preprocess them to the right size and format. Be sure to check the model card for specific requirements regarding image format, size, and normalization.Semantic Search with Cosine Distance
Once you have embeddings, you can use cosine distance to find similar items. This example demonstrates a simple RAG (Retrieval-Augmented Generation) pattern where we find the most relevant documents for a query.
import daft
from daft.functions import embed_text, cosine_distance
# Create a knowledge base with documents
documents = daft.from_pydict({
"doc_id": [1, 2, 3, 4],
"text": [
"Python is a high-level programming language",
"Machine learning models require training data",
"Daft is a distributed dataframe library",
"Embeddings capture semantic meaning of text",
]
})
# Embed all documents
documents = documents.with_column(
"embedding",
embed_text(daft.col("text"), provider="openai", model="text-embedding-3-small")
)
# Create a query
query = daft.from_pydict({
"query_text": ["What is Daft?"]
})
# Embed the query
query = query.with_column(
"query_embedding",
embed_text(
daft.col("query_text"),
provider="openai", # Requires OPENAI_API_KEY
model="text-embedding-3-small"
)
)
# Cross join to compare query against all documents
results = query.join(documents, how="cross")
# Calculate cosine distance (lower is more similar)
results = results.with_column(
"distance",
cosine_distance(daft.col("query_embedding"), daft.col("embedding"))
)
# Sort by distance and show top results
results = results.sort("distance").select("query_text", "text", "distance")
results.show()╭───────────────┬────────────────────────────────┬─────────────────────╮
│ query_text ┆ text ┆ distance │
│ --- ┆ --- ┆ --- │
│ String ┆ String ┆ Float64 │
╞═══════════════╪════════════════════════════════╪═════════════════════╡
│ What is Daft? ┆ Daft is a distributed datafra… ┆ 0.36203420173101486 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ What is Daft? ┆ Python is a high-level progra… ┆ 0.91644507004277 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ What is Daft? ┆ Embeddings capture semantic m… ┆ 0.937416795759487 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ What is Daft? ┆ Machine learning models requi… ┆ 0.9697065433855928 │
╰───────────────┴────────────────────────────────┴─────────────────────╯
(Showing first 4 of 4 rows)!!! tip "Scaling Similarity Search"
For large-scale similarity search, consider:
- Pre-computing and storing embeddings to avoid re-generation
- Using approximate nearest neighbor (ANN) algorithms for huge datasets
- Filtering documents before computing similarity to reduce comparisons
- Writing results to a vector database like [Turbopuffer](../connectors/turbopuffer.md) for efficient retrievalAi Functions Jev (`docs/ai-functions/jev.md`)
Filtering, Classifying, and Scoring Rows with Jev
Daft's jev functions let you ask plain-English questions about each row of a DataFrame: does this ticket come from an angry customer, which team should handle it, how luxurious is this product? Questions are answered by TypeSafe's Jev model, and the functions are modelled after DuckDB's jev community extension.
| Function | Answers | Returns |
|---|---|---|
jev |
Does the row satisfy a condition? | Boolean |
jev_prob |
How likely is it that the row satisfies a condition? | Float64 in [0, 1] |
jev_choice |
Which of these options best answers a question about the row? | String |
jev_score |
Where does the row fall on an ordered rubric? | Float64 in [0, levels - 1] |
!!! tip "Choosing the Right Approach"
- Use jev and jev_prob to filter or rank rows by a condition that is easy to state but hard to code
- Use jev_choice and jev_score to label rows without hosting a model; use classify_text to run a classifier locally
- Use prompt when you need free-form text, explanations, or structured outputs
Setup
Install Daft with the jev extra and set your TypeSafe API key:
pip install "daft[jev]"
export TYPESAFE_API_KEY="..."Unlike the other AI functions, the jev functions don't use a provider. Each function takes its settings as keyword arguments, falling back to environment variables:
| Argument | Environment variable | Default |
|---|---|---|
api_key |
TYPESAFE_API_KEY |
None; required |
model |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
base_url |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
timeout |
60 seconds per request |
Settings are read when you build the expression, so workers on a distributed cluster don't need the environment variables.
Filtering with `jev`
The jev function returns True for rows that satisfy a plain-English condition, which makes it a natural fit for where. To judge several columns at once, pack them into a struct with to_struct; the field names are visible to the model.
import daft
from daft.functions import jev, to_struct
df = daft.from_pydict({
"first": ["Hans", "Mei", "Giulia", "Kwame"],
"last": ["Müller", "Chen", "Rossi", "Mensah"],
})
df = df.where(
jev(to_struct(daft.col("first"), daft.col("last")), "the name is European")
)
df.show()A row counts as True when its probability is at least threshold, which defaults to 0.5. Raise it to keep only confident matches:
df.where(jev(daft.col("ticket"), "the customer is angry", threshold=0.9))Probabilities with `jev_prob`
The jev_prob function returns the probability behind jev, so you can rank rows or pick a threshold after looking at the data. jev(row, condition, threshold) is equivalent to jev_prob(row, condition) >= threshold.
import daft
from daft.functions import jev_prob
df = daft.from_pydict({
"ticket": [
"This is the third time you've double charged me. I am furious and cancelling today!",
"Thanks so much, the new dashboard is lovely.",
"I was billed twice for my subscription this month.",
"The app crashes every time I try to log in.",
],
})
df = df.with_column("p_angry", jev_prob(daft.col("ticket"), "the customer is angry"))
df.sort("p_angry", desc=True).show()Classification with `jev_choice`
The jev_choice function picks the most probable of a list of options as the answer to a question about each row. It takes between 1 and 255 options, and duplicate options are dropped.
import daft
from daft.functions import jev_choice
df = daft.from_pydict({
"ticket": [
"I was billed twice for my subscription this month.",
"The app crashes every time I try to log in.",
"Someone logged into my account from another country.",
"Do you offer discounts for teams of 50 or more?",
],
})
df = df.with_column(
"team",
jev_choice(
daft.col("ticket"),
"which team should handle this?",
["billing", "technical", "security", "sales"],
),
)
df.show()Scoring with `jev_score`
The jev_score function places each row on an ordered rubric of 2 to 10 levels, listed lowest first. The result is the index of a level, weighted by the model's probability for each level, so it can fall between levels: with the levels below, a score of 2.5 sits between premium and luxury.
import daft
from daft.functions import jev_score
df = daft.from_pydict({
"product": [
"Plastic garden stool",
"Solid oak dining chair",
"Hand-stitched Italian leather armchair",
],
})
df = df.with_column(
"tier",
jev_score(
daft.col("product"),
"how luxurious is this product?",
["budget", "mid-range", "premium", "luxury"],
),
)
df.sort("tier", desc=True).show()!!! note "Nulls and Batching"
Null rows, including NaN, are never sent to the model and produce a null result. The remaining rows are sent 25 per request with up to 16 requests in flight, and requests that time out, are rate-limited, or fail on the server are retried.
Ai Functions Prompt (`docs/ai-functions/prompt.md`)
Prompting Language Models with Daft
A key strength of Daft is its ability to parallelize data processing on structured data. The prompt function is a powerful tool for use-cases like knowledge extraction, synthetic data generation, and batch tool-calling.
-
Learn how to compose prompt templates using DataFrame syntax, configure models, and pass optional parameters like temperature and max_tokens.
-
Generate structured data from language models using Pydantic models that automatically convert to Daft DataTypes with native struct support.
Multimodal Inputs (Text, Images, Files)
Pass text, images, and files (PDF, Markdown, HTML, CSV) as multimodal inputs to language models with automatic content formatting.
-
Use OpenAI's built-in tools (web search, file search, code interpreter) or define custom functions for agentic workflows at scale.
!!! note
prompt is under active development, so make sure to stay up to date with our latest releases on github or through announcements in our slack community.
Quick Start
The prompt function enables you to call language models on DataFrame columns, making it easy to scale LLM operations across thousands or millions of rows with automatic batching and parallelization.
The default provider for prompt is OpenAI. This means you can use OpenAI's latest models without specifying a provider. Just make sure your OPENAI_API_KEY environment variable is set and an OpenAIProvider will be created and attached to your daft.Session for you.
import daft
from daft.functions import prompt
# read some sample data
df = daft.from_pydict({
"input": [
"What sound does a cat make?",
"What sound does a dog make?",
"What sound does a cow make?",
"What does the fox say?",
]
})
# generate responses using a chat model
df = df.with_column("response", prompt(daft.col("input"), model="gpt-5.4-mini"))
df.show()╭─────────────────────────────┬─────────────────────────────╮
│ input ┆ response │
│ --- ┆ --- │
│ String ┆ String │
╞═════════════════════════════╪═════════════════════════════╡
│ What sound does a cat make? ┆ A cat makes a "meow" sound. │
│ What sound does a dog make? ┆ A dog makes a "woof" sound. │
│ What sound does a cow make? ┆ A cow makes a "moo" sound. │
│ What does the fox say? ┆ The fox says "ring-ding-... │
╰─────────────────────────────┴─────────────────────────────╯Supported Providers for `prompt`
- OpenAI (Default) - Leverage models from OpenAI or any OpenAI-compatible provider.
- Google - Take advantage of Google's latests models like Gemini 3 Pro.
- vLLM Prefix Caching (Beta) - Distributed batch inference on vLLM built by the Daft Cloud team.
- Transformers - Run open-weight instruction-tuned models locally via HuggingFace Transformers.
Prompt Basics
The prompt function supports a wide range of tasks, from simple text generation to complex agentic workflows with tool calling. Daft's native multimodal data handling and scalable execution make it easy to process large datasets efficiently. This section covers core patterns for working with prompts, including templates, parameters, and model configuration.
!!! note
The default endpoint for OpenAI providers is v1/responses. You can pass use_chat_completions=True as an option to use the Chat Completions API instead of the new Responses API.
Working with OpenAI-compatible providers
This example shows how to use the prompt function with an OpenAI-compatible provider like OpenRouter to classify anime quotes.
import os
import daft
from daft.functions import prompt
daft.set_provider(
"openai",
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY")
)
df = daft.from_pydict({
"quote": [
"I am going to be the king of the pirates!",
"I'm going to be the next Hokage!",
],
})
df = (
df
.with_column(
"response",
prompt(
daft.col("quote"),
system_message="Classify the anime from the quote and return the show, character name, and explanation.",
provider="openai",
model="nvidia/nemotron-nano-9b-v2:free"
)
)
)
df.show(format="fancy", max_width=120)╭───────────────────────────────────────────┬──────────────────────────────────────────────────────────────╮
│ quote ┆ response │
╞═══════════════════════════════════════════╪══════════════════════════════════════════════════════════════╡
│ I am going to be the king of the pirates! ┆ **Show:** *One Piece* │
│ ┆ **Character Name:** Monkey D. Luffy │
│ ┆ **Explanation:** │
│ ┆ The quote "I am going to be the king… │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ I'm going to be the next Hokage! ┆ **Show:** *Naruto* │
│ ┆ **Character Name:** Naruto Uzumaki │
│ ┆ **Explanation:** The quote "I'm going to be the next Hokage… │
╰───────────────────────────────────────────┴──────────────────────────────────────────────────────────────╯
(Showing first 2 of 2 rows)For more details on how to use OpenAI-compatible providers with the prompt function like OpenRouter, Hugging Face Inference Providers, Databricks, and more, check out the Prompting OpenAI-Compatible Providers section. Additionally, check out the Prompting with vLLM Online Serving section for more details on how to use vLLM Online Serving.
Running models locally with the Transformers provider
The transformers provider runs open-weight instruction-tuned models locally via HuggingFace pipeline("text-generation"). Useful for offline batch inference without standing up a separate inference service.
pip install 'daft[transformers]'import daft
from daft.functions import prompt
df = daft.from_pydict({"quote": ["I am going to be the king of the pirates!"]})
df = df.with_column(
"response",
prompt(
daft.col("quote"),
provider="transformers",
model="HuggingFaceTB/SmolLM2-360M-Instruct",
system_message="Classify the anime from the quote in one short sentence.",
max_new_tokens=100,
),
)
df.show()Generation kwargs (max_new_tokens, temperature, top_p, ...) are forwarded to the pipeline call. Constructor-time options (dtype, device_map, trust_remote_code, device, ...) go through pipeline_kwargs. The provider auto-selects cuda > mps > cpu.
You can also pass text-file columns (daft.File or raw bytes with a text/* MIME type) directly. The file contents are read and inlined into the prompt wrapped in a <file_*>...</file_*> tag derived from the MIME type, so the model can distinguish file content from your instructions:
df = daft.from_files("s3://my-bucket/docs/*.md")
df = df.with_column(
"summary",
prompt(
[daft.col("file"), "Summarize this document in 3 bullets."],
provider="transformers",
model="HuggingFaceTB/SmolLM2-360M-Instruct",
),
)Building Prompt Templates with the `format` function
In addition to passing plain strings, you can build dynamic prompt templates using Daft's format function. This works similarly to Python's format, but works with Daft expressions. You can also define prompt templates with user-defined functions. With @daft.func you can pass in expressions or plain python strings for more flexible composition.
import daft
from daft.functions import prompt, format
# Define a prompt template with "format"
def answer_in_another_language(language: str, column_name: str) -> daft.Expression:
return format(
"Answer the following text in {}: {}",
daft.lit(language),
daft.col(column_name),
)
# Alternatively define a row-wise UDF
@daft.func
def answer_in_another_language_func(language: str, question: str) -> str:
return f"Answer the following text in {language}: {question}"
# Define some toy data
df = daft.from_pydict({
"text": [
"In what state(s) does water reach 101 degrees celsius?",
"What are the total number of planets in our solar system?",
"Who was the first person to discover radioactivity?",
]
})
# Lazily plan to create two new columns
df = (
df
.with_column(
"answer1",
prompt(
answer_in_another_language("Chinese", "text"),
model="google/gemma-3-4b",
provider="lm_studio" # Make sure you have google/gemma-3-4b loaded & running in lm_studio (local)
)
)
.with_column(
"answer2",
prompt(
answer_in_another_language_func(daft.lit("Spanish"), daft.col("text")),
model="google/gemma-3-4b",
provider="lm_studio"
)
)
)
df.show(format="fancy", max_width=80)Optional parameters
Passing additional arguments like temperature or max_tokens can simply be added to any prompt call as a keyword argument. All **kwargs are routed to the inference request unless documented otherwise.
Instructions for the model can also be added via the system_message argument which automatically prepends user content messages:
{"role": "system", "content": "You are an expert Chinese translation interpreter"}For instance, if we wanted to call OpenAI via the Chat Completions endpoint, specifying temperature and max_tokens we would define a script like:
import daft
from daft.functions import prompt
df = daft.from_pydict({
"prompt": [
"What is the triple point of water?",
]
})
# Prompt Usage for GPT-4.1 Chat Completions
df = df.with_column(
"result",
prompt(
messages=daft.col("prompt"),
system_message="You are a helpful assistant.",
model="gpt-4.1-2025-04-14",
provider="openai",
temperature=0.717, # Specify temperature
max_tokens=200, # Specify max_tokens
use_chat_completions=True,
)
)However, the Responses API uses different parameter names. For GPT-5 models, use max_output_tokens instead of max_tokens, and note that temperature is not supported:
import daft
from daft.functions import prompt
df = daft.from_pydict({
"prompt": [
"What is the triple point of water?",
]
})
# Prompt Usage for GPT-5 Responses
df = df.with_column(
"result",
prompt(
messages=daft.col("prompt"),
system_message="You are a helpful assistant.",
model="gpt-5",
provider="openai",
max_output_tokens=200, # Specify max_output_tokens
)
)Reasoning Models
GPT-5 and other reasoning-capable models support the reasoning parameter to control compute allocation. Pass it as you would in a client.responses.create() request:
import daft
from daft.functions import prompt
df = daft.from_pydict({
"prompt": [
"Find me buy-one-get-one-free burrito deals in SF right now.",
]
})
# Prompt Usage for GPT-5 Responses
df = df.with_column(
"result",
prompt(
messages=daft.col("prompt"),
system_message="You are a helpful assistant.",
model="gpt-5-2025-08-07",
provider="openai",
reasoning={"effort": "high"}, # Adjust reasoning level
)
)See also: Providers Overview.
Tools
The easiest way to get started with tool calling is to use OpenAI's built-in tools like web-search.
import daft
from daft.functions import prompt
from datetime import datetime
df = daft.from_pydict({
"query": [
"Buy one get one free burritos in SF.",
]
})
df = df.with_column(
"search_results",
prompt(
daft.col("query"),
model="gpt-5",
tools=[{"type": "web_search"}],
)
)
df.write_csv(f"../.data/prompt/oai_web_search_{str(datetime.now())}")Multimodal Inputs
Daft is purpose-built for multimodal AI workloads. The prompt function accepts text, images, and files as a list of expressions in the messages parameter, making it easy to work with mixed content types.
import daft
from daft.functions import prompt
df = daft.from_pydict({
"prompt": ["What's in this image and file?"],
"my_image": ["/Users/MyName/Downloads/SigmundTheCat.png"],
"my_file": ["/Users/MyName/Downloads/report.pdf"],
})
# Decode the image and file paths
df = df.with_column(
"my_image",
daft.functions.decode_image(daft.col("my_image").download())
)
df = df.with_column(
"my_file",
daft.functions.file(daft.col("my_file"))
)
# Prompt Usage for GPT-5 Responses
df = df.with_column(
"result",
prompt(
messages=[daft.col("prompt"), daft.col("my_image"), daft.col("my_file")],
system_message="You are a helpful assistant.",
model="gpt-5-2025-08-07",
provider="openai",
reasoning={"effort": "high"}, # Adjust reasoning level
tools=[{"type": "web_search"}], # Leverage internal OpenAI Tools
)
)Working with Files
The prompt function supports multiple file input methods depending on the provider and file type:
- PDF files: Passed directly as file inputs (native OpenAI support)
- Text files (Markdown, HTML, CSV, etc.): Content is automatically extracted and injected into prompts
- Images: Supported via
daft.DataType.Image()or file paths
PDF Files
To include PDF files, create a daft.File column and pass it in your messages list:
import daft
from daft import lit, col
from daft.functions import prompt, file, format
from dotenv import load_dotenv
load_dotenv()
# Discover PDF files from HuggingFace
df = daft.from_glob_path("hf://datasets/Eventual-Inc/sample-files/papers/*.pdf")
df = (
df
# Create a daft.File column from the path
.with_column("file", file(col("path")))
# Prompt GPT-5-nano with PDF files as context
.with_column(
"response",
prompt(
[lit("What are in the contents of this file?\n"), col("file")],
model="gpt-5-nano",
provider="openai",
)
)
)
df.show(format="fancy", max_width=80)/* Detailed source-code truncated for AI context efficiency. */Text Files (Markdown, HTML, CSV)
While OpenAI's native API only supports PDFs directly, attempting to pass other file types like Markdown, CSV, or HTML will throw errors like:
Invalid input: Expected file type to be a supported format: .pdf but got .jsonl”To work around this, Daft automatically extracts content from any text/* MIME type file and injects it into the prompt with XML-wrapped tags. For example, to use Markdown files as context:
import daft
from daft import lit, col
from daft.functions import prompt, file, format
# Discover Markdown Files in your Documents Folder
df = daft.from_glob_path("/Users/MyName/Documents/*.md")
df = (
df
# Create a daft.File column from the path
.with_column("file", file(col("path")))
# Prompt GPT-5-nano with markdown files as context
.with_column(
"response",
prompt(
messages=[lit("What are in the contents of this file?\n"), col("file")],
model="gpt-5-nano",
provider="openai",
)
)
)
df.show(format="fancy", max_width=80)When you pass [lit("What are in the contents of this file?\n"), col("file")], Daft automatically formats the content. The resulting prompt will look like:
What are in the contents of this file?
<file_text_markdown>
## Some Title
Example Markdown content with a [link](https://somelink.com).
</file_text_markdown>Structured Outputs
Daft natively supports converting Pydantic Models to Daft Datatypes. This simplifies structured generation in a vectorized context where nested fields materialize as structs.
import os
from dotenv import load_dotenv
import daft
from daft.functions import prompt, unnest
from pydantic import BaseModel, Field
load_dotenv()
class Anime(BaseModel):
show: str = Field(description="The name of the anime show")
character: str = Field(description="The name of the character who says the quote")
explanation: str = Field(description="Why the character says the quote")
daft.set_provider(
"openai",
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY"),
)
# Create a dataframe with the quotes
df = daft.from_pydict(
{
"quote": [
"I am going to be the king of the pirates!",
"I'm going to be the next Hokage!",
],
}
)
# Use the prompt function to classify the quotes
df = df.with_column(
"nemotron-response",
prompt(
daft.col("quote"),
system_message="You are an anime expert. Classify the anime based on the text and return the name, character, and quote.",
return_format=Anime,
provider="openai",
model="nvidia/nemotron-nano-9b-v2:free",
),
).select("quote", unnest(daft.col("nemotron-response")))
df.show(format="fancy", max_width=120)Structured Outputs with vLLM OpenAI Online Server
Sometimes Pydantic structured outputs is overkill. vLLM's structured outputs syntax uses the extra_body param which we can use thanks to how Daft wires kwargs to the model request. Keep in mind the normal Pydantic structured outputs via return_format also works with vLLM online serving.
One of the fastest ways to get started with online serving with vLLM is with Google Colab. To launch an OpenAI compatible server on vLLM run the following command in your terminal on an L4 GPU instance or larger:
python -m vllm.entrypoints.openai.api_server \
--model google/gemma-3-4b-it \
--guided-decoding-backend guidance \
--dtype bfloat16 \
--gpu-memory-utilization 0.85 \
--host 0.0.0.0 --port 8000If you are in Google Colab, you can open a terminal by clicking the terminal icon in the bottom left of the ui. It usually takes at least 7.5 minutes before the vLLM server is ready.
Setting up vLLM under the OpenAI provider
import daft
from daft.functions import prompt, format
daft.set_provider(
"openai",
api_key="none",
base_url="http://localhost:8000/v1",
)Guided Choice (Classification)
df = daft.from_pydict({
"statement": [
"vLLM is wonderful",
"Daft is wicked fast",
"Slow inference sucks",
]
})
df = df.with_column(
"sentiment",
prompt(
messages=format("Classify this sentiment: {}", daft.col("statement")),
model="google/gemma-3-4b-it",
extra_body={"structured_outputs": {"choice": ["positive", "negative"]}},
)
)Regex
df = daft.from_pydict({
"name": [
"Alan Turing",
"Steve Jobs",
"Sammy Sidhu",
"Jay Chia",
],
"company": [
"enigma.uk",
"apple.com",
"daft.ai",
"daft.ai",
]
})
df = df.with_column(
"email",
prompt(
messages=format(
"Generate an example email address for {}, who works at {}. Example result: [email protected]\nMake sure to finish with a new line symbol\n",
daft.col("name"),
daft.col("company")
),
model="google/gemma-3-4b-it",
extra_body={"structured_outputs": {"regex": r"\w+@\w+\.com\n"}, "stop": ["\n"]},
)
)Ai Functions Providers (`docs/ai-functions/providers.md`)
AI Providers Overview
Providers in Daft give you flexible control over where and how your AI inference runs. Whether you're using commercial APIs like OpenAI, running local models with LM Studio, or leveraging open-source models from Hugging Face, providers offer a unified interface for configuration.
This guide covers:
- Quick provider setup
- Setting named providers within sessions
- Working with multiple providers
- Prompting with OpenAI-Compatible Providers
- Prompting with vLLM Online Serving
Quick provider setup
The simplest way to use a provider is with daft.set_provider(), which configures a provider globally for your Daft session:
import os
import daft
# Set OpenAI Provider for Global Daft Session
daft.set_provider(
"openai",
api_key=os.environ.get("OPENAI_API_KEY"),
timeout=30.0,
max_retries=3
)
# Retrieve Provider for an AI Function
provider = daft.get_provider("openai")Supported Providers
Daft supports the following AI Providers:
- OpenAI - API for GPT models and other AI capabilities
- Transformers - Hugging Face models for local inference
- Google - Google's Gemini API
- LM Studio - Run local models privately on your computer
Setting a named OpenAI Provider within a Session
For more control, you can create named providers within a session. This is useful when working with OpenAI-compatible APIs like OpenRouter, or when you need to switch between different configurations.
You can pass a Provider instance directly to set_provider(), which will automatically attach it to the session:
import os
import daft
from daft.ai.openai.provider import OpenAIProvider
sess = daft.Session()
openrouter_provider = OpenAIProvider(
name="OpenRouter",
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY")
)
sess.set_provider(openrouter_provider) # Auto-attaches and sets as current
# Retrieve Provider for an AI Function
provider = sess.get_provider("OpenRouter")This is equivalent to the two-step approach of calling attach_provider() followed by set_provider():
# The two-step approach (still supported):
sess.attach_provider(openrouter_provider)
sess.set_provider("OpenRouter")Using the Google Provider
The Google provider enables you to use Google's Gemini models for text generation, multimodal processing, and structured outputs.
import daft
import os
# Set up the Google provider with your API key
with daft.session() as session:
session.set_provider("google", api_key=os.environ["GOOGLE_API_KEY"])
# Create a DataFrame with questions
df = daft.from_pydict({
"question": [
"What is the capital of France?",
"Explain quantum computing in simple terms.",
"What are the benefits of fusion energy?"
]
})
# Use the prompt function with Google's Gemini model
df = df.with_column(
"answer",
daft.functions.prompt(
daft.col("question"),
provider="google",
model="gemini-2.5-flash" # or "gemini-3-pro-preview"
)
)
df.show()The Google provider supports all the same features as other providers:
- Structured outputs with Pydantic models
- Multimodal inputs (text, images, PDFs, documents)
- System messages for custom instructions
- Custom generation config (temperature, max_output_tokens, etc.)
End-to-End Usage with Multiple Providers
For complex workflows, you might need to use different providers for different tasks—for example, using GPT-5 for validation while using a cheaper model for initial classification. Daft makes it easy to manage multiple providers in a single session.
/* Detailed source-code truncated for AI context efficiency. */This example demonstrates:
- Multiple API keys: Different providers for development and production environments
- Cost optimization: Use cheaper models for initial processing, premium models for validation
- Provider routing: Explicitly specify which provider to use for each operation
!!! tip "When to Use Multiple Providers"
Multiple providers are useful when you need to:
- **Balance cost and quality**: Use cheaper models for bulk processing, premium models for critical tasks
- **Ensure redundancy**: Fall back to alternative providers if one fails
- **Compare model outputs**: Run the same prompt through different models for quality assessment
- **Manage rate limits**: Distribute load across multiple API keys or providersPrompting with OpenAI-Compatible Providers
You can use OpenAI-compatible providers with the prompt function like OpenRouter, HuggingFace Inference Providers, Databricks, and more.
import os
import daft
# For OpenRouter
# See: https://openrouter.ai/docs/quickstart#using-the-openai-sdk
daft.set_provider(
"openai",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENROUTER_API_KEY")
)
# For Hugging Face Inference Providers
# See: https://huggingface.co/inference/get-started
daft.set_provider(
"openai",
base_url="https://router.huggingface.co/v1",
api_key=os.getenv("HF_TOKEN")
)
# For Databricks
# How to get your Databricks token: https://docs.databricks.com/en/dev-tools/auth/pat.html
# More info on model serving: https://docs.databricks.com/aws/en/machine-learning/model-serving/score-foundation-models
daft.set_provider(
"openai",
base_url="https://<workspace-id>.cloud.databricks.com/serving-endpoints",
api_key=os.getenv("DATABRICKS_TOKEN"),
)Prompting with vLLM Online Serving
For vLLM Online Serving, you can set the provider as demonstrated in the example below.
Note: vLLM's OpenAI-compatible endpoint expects requests in the Chat Completions API format. Daft's prompt() function defaults to using the newer Responses API format, which is not supported by vLLM. To ensure compatibility, set use_chat_completions=True in prompt() so that your requests use the Chat Completions API format required by vLLM.
import os
import daft
# For vLLM Online Serving
daft.set_provider(
"openai",
api_key="none",
base_url="http://localhost:8000/v1",
) # Make sure to set use_chat_completions=True in prompt()
df = df.with_column(
"response",
prompt(
daft.col("input"),
use_chat_completions=True,
model="google/gemma-3-4b-it",
)
)
df.show()The following sample cli launch command is optimized for Google Colab's A100 High-RAM instance and Google's gemma-3-4b-it model.
python -m vllm.entrypoints.openai.api_server \
--model google/gemma-3-4b-it \
--enable-chunked-prefill \
--guided-decoding-backend guidance \
--dtype bfloat16 \
--gpu-memory-utilization 0.85 \
--host 0.0.0.0 --port 8000- Server readiness may take ~7–8 minutes
- For vLLM online serving, set
api_key = "none"andbase_url = "http://0.0.0.0:8000/v1" guided-decoding-backend guidanceis required for structured outputs.
More Resources
Api Aggregations (`docs/api/aggregations.md`)
Aggregations
When performing aggregations such as sum, mean and count, Daft enables you to group data by certain keys and aggregate within those keys.
Calling [df.groupby()][daft.DataFrame.groupby] returns a GroupedDataFrame object which is a view of the original DataFrame but with additional context on which keys to group on. You can then call various aggregation methods to run the aggregation within each group, returning a new DataFrame.
::: daft.dataframe.dataframe.GroupedDataFrame
options:
filters: ["!^_"]
Api Ai (`docs/api/ai.md`)
AI
Feature coverage table:
| Feature | OpenAI | vLLM Prefix Caching | LM Studio | Transformers | |
|---|---|---|---|---|---|
| Prompt | ✅ | ✅ | ✅ | ✅ | x |
| Embedding | ✅ | x | x | ✅ | ✅ |
| Classification | x | x | x | x | ✅ |
| Image Classification | x | x | x | x | ✅ |
!!! note
Open an issue on our GitHub if you would like to see support for a feature.
Functions
::: daft.functions.ai.prompt
options:
heading_level: 3
::: daft.functions.ai.embed_text
options:
heading_level: 3
::: daft.functions.ai.embed_image
options:
heading_level: 3
::: daft.functions.ai.classify_text
options:
heading_level: 3
::: daft.functions.ai.classify_image
options:
heading_level: 3
Model Protocols
::: daft.ai.protocols.Prompter
options:
heading_level: 3
::: daft.ai.protocols.TextEmbedder
options:
heading_level: 3
::: daft.ai.protocols.TextEmbedderDescriptor
options:
heading_level: 3
::: daft.ai.protocols.ImageEmbedder
options:
heading_level: 3
::: daft.ai.protocols.ImageEmbedderDescriptor
options:
heading_level: 3
::: daft.ai.protocols.TextClassifier
options:
heading_level: 3
::: daft.ai.protocols.TextClassifierDescriptor
options:
heading_level: 3
Providers
::: daft.ai.provider.Provider
options:
filters: ["!^_"]
heading_level: 3
::: daft.ai.provider.load_provider
options:
heading_level: 3
::: daft.ai.provider.load_google
options:
heading_level: 3
::: daft.ai.provider.load_lm_studio
options:
heading_level: 3
::: daft.ai.provider.load_openai
options:
heading_level: 3
::: daft.ai.provider.load_transformers
options:
heading_level: 3
::: daft.ai.provider.load_vllm_prefix_caching
options:
heading_level: 3
Api Catalogs Tables (`docs/api/catalogs_tables.md`)
Catalogs and Tables
Daft integrates with various catalog implementations using its Catalog and Table interfaces. These are high-level APIs to manage catalog objects (tables and namespaces), while also making it easy to leverage Daft's existing daft.read_ and df.write_ APIs for open table formats like Iceberg and Delta Lake.
::: daft.catalog.Catalog
options:
filters: ["!^_"]
::: daft.catalog.Identifier
options:
filters: ["!^_"]
::: daft.catalog.Table
options:
filters: ["!^_"]
Api Config (`docs/api/config.md`)
Configuration
Configure the execution backend, Daft in various ways during execution, and how Daft interacts with storage.
Setting the Runner
Control the execution backend that Daft will run on by calling these functions once at the start of your application.
::: daft.set_runner_native
options:
heading_level: 3
::: daft.set_runner_ray
options:
heading_level: 3
::: daft.get_or_create_runner
options:
heading_level: 3
Checking the Runner
Check the execution backend that Daft is currently using.
::: daft.get_or_infer_runner_type
options:
heading_level: 3
Setting Configurations
Configure Daft in various ways during execution.
::: daft.context.set_planning_config
options:
heading_level: 3
::: daft.context.planning_config_ctx
options:
heading_level: 3
::: daft.context.set_execution_config
options:
heading_level: 3
::: daft.context.execution_config_ctx
options:
heading_level: 3
I/O Configurations
Configure behavior when Daft interacts with storage (e.g. credentials, retry policies and various other knobs to control performance/resource usage)
These configurations are most often used as inputs to Daft when reading I/O functions such as in I/O.
::: daft.daft.IOConfig
options:
filters: ["!^_"]
::: daft.io.S3Config
options:
filters: ["!^_"]
::: daft.io.S3Credentials
options:
filters: ["!^_"]
::: daft.io.GCSConfig
options:
filters: ["!^_"]
::: daft.io.AzureConfig
options:
filters: ["!^_"]
::: daft.io.HTTPConfig
options:
filters: ["!^_"]
::: daft.io.UnityConfig
options:
filters: ["!^_"]
::: daft.io.HuggingFaceConfig
options:
filters: ["!^_"]
::: daft.io.TosConfig
options:
filters: ["!^_"]
::: daft.io.CosConfig
options:
filters: ["!^_"]
Api Dataframe (`docs/api/dataframe.md`)
DataFrame
Most DataFrame methods are lazy, meaning that they do not execute computation immediately when invoked. Instead, these operations are enqueued in the DataFrame's internal query plan, and are only executed when Execution DataFrame methods are called.
::: daft.DataFrame
options:
filters: ["!^[^]", "!repr", "!__builder"]
Api Datasets (`docs/api/datasets.md`)
Datasets
Daft provides simple, performant, and responsible ways to access useful datasets like Common Crawl and DROID.
Common Crawl
Check out our Common Crawl dataset guide for more examples!
::: daft.datasets.common_crawl.common_crawl
options:
filters: ["!^_"]
heading_level: 3
LeRobot
See the LeRobot dataset guide for v2.1 / v3 layouts, episode vs frame workflows, and Hub/local paths.
::: daft.datasets.lerobot.read
options:
filters: ["!^_"]
heading_level: 3
::: daft.datasets.lerobot.read_episodes
options:
filters: ["!^_"]
heading_level: 3
::: daft.datasets.lerobot.load_episode_frames
options:
filters: ["!^_"]
heading_level: 3
::: daft.datasets.lerobot.read_tasks
DROID
Check out our DROID dataset guide for more examples!
::: daft.datasets.droid.raw
options:
filters: ["!^_"]
heading_level: 3
::: daft.datasets.droid.scenes
options:
filters: ["!^_"]
heading_level: 3
::: daft.datasets.droid.trajectory
options:
filters: ["!^_"]
heading_level: 3
::: daft.datasets.droid.camera_frames
options:
filters: ["!^_"]
heading_level: 3
Api Expressions (`docs/api/expressions.md`)
Expressions
Daft Expressions allow you to express some computation that needs to happen in a DataFrame. This page provides an overview of all the functionality that is provided by Daft Expressions.
Constructors
::: daft.expressions.col
options:
heading_level: 3
::: daft.expressions.lit
options:
heading_level: 3
::: daft.sql.sql.sql_expr
options:
heading_level: 3
::: daft.expressions.Expression
options:
filters: ["!^[^]", "!over", "!lag", "!lead"]
::: daft.expressions.WhenExpr
options:
filters: ["!^_"]
::: daft.expressions.visitor.ExpressionVisitor
options:
filters: ["!^_"]
Api Io (`docs/api/io.md`)
I/O
Daft offers a variety of approaches to creating a DataFrame from reading various data sources (in-memory data, files, data catalogs, and integrations) and writing to various data sources. See more about other Connectors in Daft User Guide.
Input
::: daft.from_arrow
options:
heading_level: 3
::: daft.from_dask_dataframe
options:
heading_level: 3
::: daft.from_glob_path
options:
heading_level: 3
::: daft.from_pandas
options:
heading_level: 3
::: daft.from_pydict
options:
heading_level: 3
::: daft.from_pylist
options:
heading_level: 3
::: daft.from_ray_dataset
options:
heading_level: 3
::: daft.read_blob
options:
heading_level: 3
::: daft.read_csv
options:
heading_level: 3
::: daft.read_deltalake
options:
heading_level: 3
::: daft.read_hudi
options:
heading_level: 3
::: daft.read_iceberg
options:
heading_level: 3
::: daft.read_json
options:
heading_level: 3
::: daft.read_kafka
options:
heading_level: 3
::: daft.read_lance
options:
heading_level: 3
::: daft.read_parquet
options:
heading_level: 3
::: daft.read_orc
options:
heading_level: 3
::: daft.read_sql
options:
heading_level: 3
::: daft.read_video_frames
options:
heading_level: 3
::: daft.read_warc
options:
heading_level: 3
::: daft.read_webdataset
options:
heading_level: 3
::: daft.read_huggingface
options:
heading_level: 3
::: daft.sql.sql.sql
options:
heading_level: 3
Output
::: daft.dataframe.DataFrame.write_csv
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_json
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_parquet
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_deltalake
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_iceberg
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_lance
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_sql
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_bigtable
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_clickhouse
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_huggingface
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_turbopuffer
options:
heading_level: 3
::: daft.dataframe.DataFrame.write_sink
options:
heading_level: 3
User-Defined
Daft supports diverse input sources and output sinks, this section covers lower-level APIs which we are evolving for more advanced usage.
!!! warning "Warning"
These APIs are considered experimental.::: daft.io.source.DataSource
options:
filters: ["!^_"]
heading_level: 3
::: daft.io.source.DataSourceTask
options:
filters: ["!^_"]
heading_level: 3
::: daft.io.sink.DataSink
options:
filters: ["!^_"]
heading_level: 3
::: daft.io.sink.WriteResult
options:
filters: ["!^_"]
heading_level: 3
Pushdowns
Daft supports predicate, projection, and limit pushdowns.
::: daft.io.pushdowns.Pushdowns
options:
filters: ["!^_"]
heading_level: 3
::: daft.io.scan.ScanOperator
options:
filters: ["!^_"]
Api Misc (`docs/api/misc.md`)
Miscellaneous
Image Types
::: daft.daft.ImageMode
options:
filters: ["!^_"]
::: daft.daft.ImageFormat
options:
filters: ["!^_"]
Api Schema (`docs/api/schema.md`)
Schema
Daft can display your DataFrame's schema without materializing it. Under the hood, it performs intelligent sampling of your data to determine the appropriate schema, and if you make any modifications to your DataFrame it can infer the resulting types based on the operation.
::: daft.schema.Schema
options:
filters: ["!^_"]
!!! warning ""
Schema has been moved to `daft.schema` but is still accessible at `daft.logical.schema`.::: daft.logical.schema.Schema
options:
filters: ["!^_"]
Api Series (`docs/api/series.md`)
Series
Each column in a Table is a Series. Series expose methods which invoke high-performance kernels for manipulation of a column of data.
::: daft.series.Series
options:
filters: ["^to", "^from"]
Api Sessions (`docs/api/sessions.md`)
Sessions
Sessions enable you to attach objects such as catalogs, providers, models, functions, and tables which are reference-able in DataFrame operations. The session also enables creating temporary objects which are accessible through both the Python and SQL APIs. Sessions hold configuration state such as current_catalog and current_namespace which are used in name resolution and can simplify your workflows. Learn more about Sessions in the Daft User Guide.
::: daft.session.session
options:
heading_level: 3
::: daft.session.current_session
options:
heading_level: 3
::: daft.session.Session
options:
filters: ["!^_"]
Api Udf (`docs/api/udf.md`)
User-Defined Functions
User-Defined Functions (UDFs) are a mechanism to run Python code on the data that lives in a DataFrame. A UDF can be used just like Expressions, allowing users to express computation that should be executed by Daft lazily.
To write a UDF, use the @daft.func or @daft.cls decorators to turn Python functions into Daft operations that work seamlessly with DataFrame expressions. Daft also supports custom aggregation functions, or UDAFs with @daft.udaf with a three-stage pipeline (aggregate, combine, finalize) that plugs into Daft's distributed aggregation engine.
Learn more about user-defined functions in the user guide: daft.func, daft.cls, daft.udaf.
Stateless Function UDFs
::: daft.func
::: daft.udf._FuncDecorator
Stateful Class UDFs
::: daft.cls
::: daft.method
::: daft.udf._MethodDecorator
::: daft.udf.udf_v2.Func
Aggregate UDFs
::: daft.udaf
Api Window (`docs/api/window.md`)
Window Functions
Window functions allow you to perform calculations across a set of rows that are related to the current row. They operate on a group of rows (called a window frame) and return a result for each row based on the values in its window frame, without collapsing the result into a single row like aggregate functions do. See Window Functions Tutorial for a step-by-step tutorial.
::: daft.window.Window
Applying Window Functions
::: daft.expressions.Expression.over
options:
heading_level: 3
Aggregate Functions
Standard aggregate functions (e.g., [sum][daft.expressions.Expression.sum], [mean][daft.expressions.Expression.mean], [count][daft.expressions.Expression.count], [min][daft.expressions.Expression.min], [max][daft.expressions.Expression.max], etc) can be used as window functions by applying them with [.over][daft.expressions.Expression.over]. They work with all valid window specifications (partition by only, partition + order by, partition + order by + frame). Refer to the Expressions API for a full list of aggregate functions.
!!! note "Note"
When using aggregate functions with both partition by and order by, the default window frame includes all rows from the start of the partition up to the current row — equivalent to rows between unbounded preceding and current row.
Ranking Functions
These functions compute ranks within a window partition. They require an [order_by][daft.window.Window.order_by] clause without a [rows_between][daft.window.Window.rows_between] or [range_between][daft.window.Window.range_between] clause in the window specification.
Lead/Lag Functions
These functions access data from preceding or succeeding rows within a window partition. They require an [order_by][daft.window.Window.order_by] clause without a [rows_between][daft.window.Window.rows_between] or [range_between][daft.window.Window.range_between] clause in the window specification.
::: daft.expressions.Expression.lag
options:
heading_level: 3
::: daft.expressions.Expression.lead
options:
heading_level: 3
Configuration Sessions Usage (`docs/configuration/sessions-usage.md`)
Daft Sessions, Catalogs, and Tables
Sessions enable you to attach catalogs, tables, and create temporary objects which are accessible through both the Python and SQL APIs. Sessions hold configuration state such as current_catalog and current_namespace which are used in name resolution and can simplify your workflows.
Example
import daft
# `import daft` defines an implicit session `daft.current_session()`
from daft.session import Session
# create a new session
sess = Session()
# create a temp table from a DataFrame
sess.create_temp_table("T", daft.from_pydict({ "x": [1,2,3] }))
# read table as dataframe from the session
_ = sess.read_table("T")
# get the table instance
t = sess.get_table("T")
# read table instance as a datadrame
_ = t.read()
# execute sql against the session
sess.sql("SELECT * FROM T").show()
╭───────╮
│ x │
│ --- │
│ Int64 │
╞═══════╡
│ 1 │
├╌╌╌╌╌╌╌┤
│ 2 │
├╌╌╌╌╌╌╌┤
│ 3 │
╰───────╯Usage
This section covers detailed usage of the current APIs with some code snippets.
Setup
!!! note "Note"
For these examples, we are using sqlite Iceberg which requires `pyiceberg[sql-sqlite]`.from daft import Catalog
from pyiceberg.catalog.sql import SqlCatalog
# don't forget to `mkdir -p /tmp/daft/example`
tmpdir = "/tmp/daft/example"
# create a pyiceberg catalog backed by sqlite
iceberg_catalog = SqlCatalog(
"default",
**{
"uri": f"sqlite:///{tmpdir}/catalog.db",
"warehouse": f"file://{tmpdir}",
},
)
# creating a daft catalog from the pyiceberg catalog implementation
catalog = Catalog.from_iceberg(iceberg_catalog)
# check
catalog.name
"""
'default'
"""Session State
Let's get started by creating an empty session and checking the state.
import daft
from daft.session import Session
# create a new empty session
sess = Session()
# check the current catalog (None)
sess.current_catalog()
# get the current namespace (None)
sess.current_namespace()Attach & Detach
The attach and detach methods make it easy to use catalogs and tables in a session. This example shows how we can attach our newly created catalog. When you attach a catalog to an empty session, it automatically becomes the current active catalog.
# attach makes it possible to use existing catalogs in the session
sess.attach(catalog)
# check the current catalog was set automatically
sess.current_catalog()
"""
Catalog('default')
"""
# detach would remove the catalog
# sess.detach_catalog("default")Create & Drop
We can create tables and namespaces directly through a catalog or via our session.
# create a namespace 'example'
sess.create_namespace("example")
# verify it was created
sess.list_namespaces()
"""
[Identifier('example')]
"""
# you can create an empty table with a schema
# sess.create_table("example.tbl", schema)
# but suppose we have some data..
df = daft.from_pydict({
"x": [ True, True, False ],
"y": [ 1, 2, 3 ],
"z": [ "abc", "def", "ghi" ],
})
# create a table from the dataframe, which will create + append
sess.create_table("example.tbl", df)
# you can also create temporary tables from dataframes
# > echo "x,y,z\nFalse,4,jkl" > /tmp/daft/row.csv
sess.create_temp_table("temp", daft.read_csv("/tmp/daft/row.csv"))
# you can drop too
# sess.drop_table("example.tbl")
# sess.drop_namespace("example")Read & Write
Using sessions abstracts away underlying catalog and table implementations so you can easily read and write DataFrames.
/* Detailed source-code truncated for AI context efficiency. */Using SQL
The session enables executing Daft SQL against your catalogs.
# use the given catalog and namespace (like the use/set_ methods)
sess.sql("USE default.example")
# we support both qualified and unqualified names by leveraging the session state
sess.sql("SELECT * FROM tbl LIMIT 1").show()
╭─────────┬───────┬──────╮
│ x ┆ y ┆ z │
│ --- ┆ --- ┆ --- │
│ Boolean ┆ Int64 ┆ String │
╞═════════╪═══════╪══════╡
│ true ┆ 4 ┆ jkl │
╰─────────┴───────┴──────╯
# we can even combine our queries with the temp table from earlier
sess.sql("SELECT * FROM example.tbl, temp LIMIT 1").show()
╭─────────┬───────┬──────┬────────────┬────────┬────────╮
│ x ┆ y ┆ z ┆ … ┆ temp.y ┆ temp.z │
│ --- ┆ --- ┆ --- ┆ ┆ --- ┆ --- │
│ Boolean ┆ Int64 ┆ String ┆ (1 hidden) ┆ Int64 ┆ String │
╞═════════╪═══════╪══════╪════════════╪════════╪════════╡
│ true ┆ 4 ┆ jkl ┆ … ┆ 4 ┆ jkl │
╰─────────┴───────┴──────┴────────────┴────────┴────────╯!!! note "Note"
We aim to support SQL DDL in future releases!Reference
For complete documentation, please see the Session API docs.
| Method | Description |
|---|---|
[attach][daft.Session.attach] |
Attaches a catalog, table, or function to this session. |
[attach_catalog][daft.Session.attach_catalog] |
Attaches an exiting catalog to this session |
[attach_table][daft.Session.attach_table] |
Attaches an existing table to this session |
[attach_function][daft.Session.attach_function] |
Attaches a user-defined function to this session |
[create_namespace][daft.Session.create_namespace] |
Creates a new namespace |
[create_namespace_if_not_exists][daft.Session.create_namespace_if_not_exists] |
Creates a new namespace if it doesn't already exist |
[create_table][daft.Session.create_table] |
Creates a new table from the source |
[create_table_if_not_exists][daft.Session.create_table_if_not_exists] |
Creates a new table from the source if it doesn't already exist |
[create_temp_table][daft.Session.create_temp_table] |
Creates a temp table scoped to this session from an existing view. |
[current_catalog][daft.Session.current_catalog] |
Returns the session's current catalog. |
[current_namespace][daft.Session.current_namespace] |
Returns the session's current namespace. |
[detach_catalog][daft.Session.detach_catalog] |
Detaches the catalog from this session |
[detach_table][daft.Session.detach_table] |
Detaches the table from this session |
[drop_namespace][daft.Session.drop_namespace] |
Drop the namespace in the session's current catalog |
[drop_table][daft.Session.drop_table] |
Drop the table in the session's current catalog |
[get_catalog][daft.Session.get_catalog] |
Returns the catalog or an object not found error. |
[get_table][daft.Session.get_table] |
Returns the table or an object not found error. |
[has_catalog][daft.Session.has_catalog] |
Returns true iff the session has access to a matching catalog. |
[has_namespace][daft.Session.has_namespace] |
Returns true iff the session has access to a matching namespace. |
[has_table][daft.Session.has_table] |
Returns true iff the session has access to a matching table. |
[list_catalogs][daft.Session.list_catalogs] |
Lists all catalogs matching the pattern. |
[list_namespaces][daft.Session.list_namespaces] |
Lists all namespaces matching the pattern. |
[list_tables][daft.Session.list_tables] |
Lists all tables matching the pattern. Pattern syntax is catalog-dependent: native/memory and Postgres catalogs use SQL LIKE syntax (%, _, \); other catalogs may use different pattern matching. |
[read_table][daft.Session.read_table] |
Reads a table from the session. |
[write_table][daft.Session.write_table] |
Writes a dataframe to the table. |
[set_catalog][daft.Session.set_catalog] |
Sets the current catalog. |
[set_namespace][daft.Session.set_namespace] |
Sets the current namespace. |
[sql][daft.Session.sql] |
Executes SQL against the session. |
[use][daft.Session.use] |
Sets the current catalog and namespace. |
Connectors Aws (`docs/connectors/aws.md`)
Reading from and Writing to AWS S3
Daft has record-setting performance when reading from and writing to AWS S3, and understands natively the URL protocol s3:// as referring to data that resides in S3.
Authorization/Authentication
In AWS S3, data is stored under the hierarchy of:
- Bucket: The container for data storage, which is the top-level namespace for data storage in S3.
- Object Key: The unique identifier for a piece of data within a bucket.
URLs to data in S3 come in the form: s3://{BUCKET}/{OBJECT_KEY}.
Rely on Environment
You can configure the AWS CLI to have Daft automatically discover credentials. Alternatively, you may specify your credentials in environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN.
Please be aware that when doing so in a distributed environment such as Ray, Daft will pick these credentials up from worker machines and thus each worker machine needs to be appropriately provisioned.
If instead you wish to have Daft use credentials from the "driver", you may wish to manually specify your credentials.
Manually specify credentials
You may also choose to pass these values into your Daft I/O function calls using an [daft.io.S3Config][daft.io.S3Config] config object.
[daft.set_planning_config][daft.context.set_planning_config] is a convenient way to set your [daft.io.IOConfig][daft.io.IOConfig] as the default config to use on any subsequent Daft method calls.
=== "🐍 Python"
```python
from daft.io import IOConfig, S3Config
# Supply actual values: key_id is the AWS Access Key ID, access_key is the AWS Secret Access Key
io_config = IOConfig(s3=S3Config(key_id="key_id", session_token="session_token", access_key="access_key"))
# Globally set the default IOConfig for any subsequent I/O calls
daft.set_planning_config(default_io_config=io_config)
# Perform some I/O operation
df = daft.read_parquet("s3://my_bucket/my_path/**/*")
```Alternatively, Daft supports overriding the default IOConfig per-operation by passing it into the io_config= keyword argument. This is extremely flexible as you can pass a different [daft.io.S3Config][daft.io.S3Config] per function call if you wish!
=== "🐍 Python"
```python
# Perform some I/O operation but override the IOConfig
df2 = daft.read_csv("s3://my_bucket/my_other_path/**/*", io_config=io_config)
```Connectors Azure (`docs/connectors/azure.md`)
Reading from and Writing to Azure Blob Store
Daft is able to read/write data to/from Azure Blob Store, and understands natively the URL protocols az:// and abfs:// as referring to data that resides in Azure Blob Store.
!!! warning "Warning"
Daft currently only supports globbing and listing files in storage accounts with [hierarchical namespaces](https://learn.microsoft.com/en-us/azure/storage/blobs/data-lake-storage-namespace) enabled. Hierarchical namespaces enable Daft to use its embarrassingly parallel globbing algorithm to improve performance of listing large nested directories of data. Please file an [issue](https://github.com/Eventual-Inc/Daft/issues) if you need support for non-hierarchical namespace buckets! We'd love to support your use-case.Authorization/Authentication
In Azure Blob Service, data is stored under the hierarchy of:
- Storage Account
- Container (sometimes referred to as "bucket" in S3-based services)
- Object Key
URLs to data in Azure Blob Store come in the form: az://{CONTAINER_NAME}/{OBJECT_KEY}.
Given that the Storage Account is not a part of the URL, you must provide this separately.
Rely on Environment
You can rely on Azure's environment variables to have Daft automatically discover credentials.
Please be aware that when doing so in a distributed environment such as Ray, Daft will pick these credentials up from worker machines and thus each worker machine needs to be appropriately provisioned.
If instead you wish to have Daft use credentials from the "driver", you may wish to manually specify your credentials.
Manually specify credentials
You may also choose to pass these values into your Daft I/O function calls using an [daft.io.AzureConfig][daft.io.AzureConfig] config object.
[daft.set_planning_config][daft.context.set_planning_config] is a convenient way to set your [daft.io.IOConfig][daft.io.IOConfig] as the default config to use on any subsequent Daft method calls.
=== "🐍 Python"
```python
from daft.io import IOConfig, AzureConfig
# Supply actual values for the storage_account and access key here
io_config = IOConfig(azure=AzureConfig(storage_account="***", access_key="***"))
# Globally set the default IOConfig for any subsequent I/O calls
daft.set_planning_config(default_io_config=io_config)
# Perform some I/O operation
df = daft.read_parquet("az://my_container/my_path/**/*")
```Alternatively, Daft supports overriding the default IOConfig per-operation by passing it into the io_config= keyword argument. This is extremely flexible as you can pass a different [daft.io.AzureConfig][daft.io.AzureConfig] per function call if you wish!
=== "🐍 Python"
```python
# Perform some I/O operation but override the IOConfig
df2 = daft.read_csv("az://my_container/my_other_path/**/*", io_config=io_config)
```Connect to Microsoft Fabric/OneLake
If you are connecting to storage in OneLake or another Microsoft Fabric service, set the use_fabric_endpoint parameter to True in the [daft.io.AzureConfig][daft.io.AzureConfig] object.
=== "🐍 Python"
```python
from daft.io import IOConfig, AzureConfig
io_config = IOConfig(
azure=AzureConfig(
storage_account="onelake",
use_fabric_endpoint=True,
# Set credentials as needed
)
)
df = daft.read_deltalake('abfss://[WORKSPACE]@onelake.dfs.fabric.microsoft.com/[LAKEHOUSE].Lakehouse/Tables/[TABLE]', io_config=io_config)
```Connectors Bigtable (`docs/connectors/bigtable.md`)
Writing to Google Cloud Bigtable
!!! warning "Experimental"
This connector is experimental and the API may change in future releases.Google Cloud Bigtable is a fully managed, scalable NoSQL database service. Daft can write DataFrames to Bigtable tables using [df.write_bigtable()][daft.dataframe.DataFrame.write_bigtable].
Installing Dependencies
Bigtable support requires the google-cloud-bigtable package:
pip install google-cloud-bigtableBasic Usage
import daft
# Create a DataFrame
df = daft.from_pydict({
"user_id": ["user_001", "user_002", "user_003"],
"name": ["Alice", "Bob", "Charlie"],
"age": [30, 25, 35],
"email": ["[email protected]", "[email protected]", "[email protected]"],
})
# Write to Bigtable
result = df.write_bigtable(
project_id="my-gcp-project",
instance_id="my-bigtable-instance",
table_id="users",
row_key_column="user_id",
column_family_mappings={
"name": "profile",
"age": "profile",
"email": "contact",
},
)Key Concepts
Row Keys
Every Bigtable row requires a unique row key. Use row_key_column to specify which DataFrame column should be used as the row key:
df.write_bigtable(
...,
row_key_column="user_id", # This column becomes the row key
)Column Families
Bigtable organizes columns into column families. Use column_family_mappings to specify which family each column belongs to:
df.write_bigtable(
...,
column_family_mappings={
"name": "user_data", # 'name' column goes to 'user_data' family
"age": "user_data", # 'age' column goes to 'user_data' family
"email": "contact_info", # 'email' column goes to 'contact_info' family
},
)The column families must already exist in the Bigtable table.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
project_id |
str |
Yes | Google Cloud project ID |
instance_id |
str |
Yes | Bigtable instance ID |
table_id |
str |
Yes | Bigtable table ID |
row_key_column |
str |
Yes | Column name to use as the row key |
column_family_mappings |
dict[str, str] |
Yes | Mapping of column names to column families |
client_kwargs |
dict |
No | Additional arguments for the Bigtable Client |
write_kwargs |
dict |
No | Additional arguments for MutationsBatcher |
serialize_incompatible_types |
bool |
No | Auto-convert incompatible types to JSON (default: True) |
Data Type Handling
Bigtable cells only accept data that can be converted to bytes. By default, Daft automatically serializes incompatible types to JSON:
df = daft.from_pydict({
"id": ["row1"],
"data": [{"nested": "object"}], # Complex type
})
# Complex types are automatically serialized to JSON
df.write_bigtable(
...,
serialize_incompatible_types=True, # Default behavior
)To disable automatic serialization (will raise an error for incompatible types):
df.write_bigtable(
...,
serialize_incompatible_types=False,
)Advanced Configuration
Client Options
Pass additional options to the Bigtable client:
result = df.write_bigtable(
...,
client_kwargs={
"admin": True,
"channel": custom_channel,
},
)Write Options
Configure the MutationsBatcher for write operations:
result = df.write_bigtable(
...,
write_kwargs={
"flush_count": 1000,
"max_row_bytes": 5 * 1024 * 1024, # 5MB
},
)Use Cases
IoT Data Storage
import daft
from daft import col
# Read sensor data
df = daft.read_parquet("s3://bucket/sensors/*.parquet")
# Prepare for Bigtable (create composite row key)
df = df.with_column(
"row_key",
col("device_id").concat("#").concat(col("timestamp").cast(str)),
)
# Write to Bigtable
df.write_bigtable(
project_id="iot-project",
instance_id="sensor-data",
table_id="readings",
row_key_column="row_key",
column_family_mappings={
"temperature": "metrics",
"humidity": "metrics",
"device_id": "metadata",
"timestamp": "metadata",
},
)User Profile Storage
import daft
df = daft.from_pydict({
"user_id": ["u001", "u002"],
"preferences": [{"theme": "dark"}, {"theme": "light"}],
"last_login": ["2024-01-15", "2024-01-16"],
})
df.write_bigtable(
project_id="my-project",
instance_id="user-store",
table_id="profiles",
row_key_column="user_id",
column_family_mappings={
"preferences": "settings",
"last_login": "activity",
},
)Notes
- The Bigtable table and column families must exist before writing
- Row keys should be designed carefully for efficient access patterns
- Consider Bigtable's row key design best practices for optimal performance
Connectors Blob (`docs/connectors/blob.md`)
Reading Files as Blobs
Daft can read files as raw bytes into DataFrames using [daft.read_blob()][daft.io.read_blob], similar to DuckDB's read_blob function. This is useful for loading non-tabular files such as images, audio, PDFs, or any other binary data into a DataFrame, with one row per file.
Basic Usage
Read files matching a path or glob pattern, where each file becomes a row:
=== "Local Files"
```python
import daft
df = daft.read_blob("/path/to/files/*.jpeg")
df.show()
```=== "Remote Files (S3)"
```python
import daft
from daft.io import S3Config, IOConfig
io_config = IOConfig(s3=S3Config(region_name="us-west-2", anonymous=True))
df = daft.read_blob("s3://my-bucket/images/*.jpeg", io_config=io_config)
df.show()
```=== "Remote Files (GCS)"
```python
import daft
from daft.io import GCSConfig, IOConfig
io_config = IOConfig(gcs=GCSConfig(anonymous=True))
df = daft.read_blob("gs://my-bucket/images/*.jpeg", io_config=io_config)
df.show()
```Output Schema
The read_blob function returns a DataFrame with the following columns:
| Column | Type | Description |
|---|---|---|
path |
string |
Path to the file |
size |
int64 |
Size of the file in bytes |
content |
binary |
Raw bytes contents of the file |
Glob Patterns
read_blob supports the same wildcards as other Daft readers:
# All .png files in a directory
df = daft.read_blob("/data/*.png")
# Recursive search
df = daft.read_blob("/data/**/*.png")
# Multiple paths
df = daft.read_blob(["/data/a.bin", "/data/b.bin"])Error Handling
By default, a download error for any file raises immediately. Set on_error="null" to log the error and fall back to a null content value instead:
df = daft.read_blob("/data/*.bin", on_error="null")Common Use Cases
Decoding Images
Read image files and decode them for downstream processing:
import daft
from daft.functions import decode_image
df = daft.read_blob("s3://my-bucket/images/*.jpeg", io_config=io_config)
df = df.with_column("image", decode_image(df["content"]))Detecting File Types
Inspect magic bytes to determine the MIME type of each file:
import daft
from daft.functions import guess_mime_type
df = daft.read_blob("/data/**/*")
df = df.with_column("mime", guess_mime_type(df["content"]))Relationship to Other APIs
read_blob is a convenience API composed of [daft.from_glob_path()][daft.from_glob_path] (which lists files with their path and size) and the [download][daft.functions.download] expression (which fetches file contents as bytes). Use those building blocks directly if you need more control, such as downloading only a filtered subset of files.
Connectors Clickhouse (`docs/connectors/clickhouse.md`)
Writing to ClickHouse
ClickHouse is a fast, open-source columnar database for real-time analytics. Daft can write DataFrames to ClickHouse tables using [df.write_clickhouse()][daft.dataframe.DataFrame.write_clickhouse].
Installing Dependencies
ClickHouse support requires the clickhouse-connect package:
pip install clickhouse-connectBasic Usage
import daft
# Create a DataFrame
df = daft.from_pydict({
"id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Charlie", "Diana"],
"value": [100.5, 200.3, 150.7, 300.2],
})
# Write to ClickHouse
result = df.write_clickhouse(
table="my_table",
host="localhost",
port=8123,
user="default",
password="",
)
result.show()Output Schema
The write operation returns a DataFrame with write statistics:
| Column | Type | Description |
|---|---|---|
total_written_rows |
int64 |
Total number of rows written |
total_written_bytes |
int64 |
Total number of bytes written |
Connection Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
table |
str |
Yes | Name of the ClickHouse table to write to |
host |
str |
Yes | ClickHouse server hostname |
port |
int |
No | ClickHouse HTTP port (default: 8123) |
user |
str |
No | ClickHouse username |
password |
str |
No | ClickHouse password |
database |
str |
No | ClickHouse database name |
Advanced Configuration
Client Options
Pass additional options to the ClickHouse client:
result = df.write_clickhouse(
table="my_table",
host="localhost",
client_kwargs={
"secure": True,
"verify": True,
"connect_timeout": 30,
},
)Write Options
Pass additional options to the write operation:
result = df.write_clickhouse(
table="my_table",
host="localhost",
write_kwargs={
"settings": {
"async_insert": 1,
"wait_for_async_insert": 1,
},
},
)Use Cases
Analytics Pipeline
import daft
from daft import col
# Process data
df = daft.read_parquet("s3://bucket/events/*.parquet")
aggregated = df.groupby("event_type").agg(
col("value").sum().alias("total_value"),
col("user_id").count().alias("event_count"),
)
# Write aggregated results to ClickHouse for dashboards
aggregated.write_clickhouse(
table="event_aggregates",
host="clickhouse.example.com",
database="analytics",
user="writer",
password="secret",
)Real-Time Data Ingestion
import daft
# Read streaming batch
df = daft.read_json("/data/batch/*.json")
# Transform and load to ClickHouse
df.write_clickhouse(
table="events",
host="localhost",
write_kwargs={
"settings": {"async_insert": 1},
},
)Notes
- The target table must exist in ClickHouse before writing
- Column names and types in the DataFrame should match the table schema
- ClickHouse will perform type coercion where possible
Connectors Cos (`docs/connectors/cos.md`)
Reading from and Writing to Tencent Cloud COS
Daft supports reading and writing data to Tencent Cloud COS (Cloud Object Storage), and understands natively the URL protocols cos:// and cosn:// (Hadoop CosN compatible) as referring to data that resides in COS.
Authorization/Authentication
In Tencent Cloud COS, data is stored under the hierarchy of:
- Bucket: The container for data storage, which is the top-level namespace for data storage in COS.
- Object Key: The unique identifier for a piece of data within a bucket.
URLs to data in COS come in the form: cos://{BUCKET}/{OBJECT_KEY}.
!!! note "Hadoop CosN Compatibility"
Daft also supports the `cosn://` URL scheme for compatibility with Hadoop CosN. Both `cos://` and `cosn://` are treated identically.Rely on Environment
You can configure Daft to automatically discover credentials from environment variables. Daft supports the following environment variable prefixes:
| Environment Variable | Description |
|---|---|
COS_ENDPOINT |
Endpoint of the COS service |
COS_REGION or TENCENTCLOUD_REGION |
Region of the COS service |
COS_SECRET_ID or TENCENTCLOUD_SECRET_ID |
SecretId for COS authentication |
COS_SECRET_KEY or TENCENTCLOUD_SECRET_KEY |
SecretKey for COS authentication |
COS_SECURITY_TOKEN or TENCENTCLOUD_SECURITY_TOKEN |
Security token for temporary credentials (STS) |
Please be aware that when doing so in a distributed environment such as Ray, Daft will pick these credentials up from worker machines and thus each worker machine needs to be appropriately provisioned.
If instead you wish to have Daft use credentials from the "driver", you may wish to manually specify your credentials.
Manually specify credentials
You may also choose to pass these values into your Daft I/O function calls using a [daft.io.CosConfig][daft.io.CosConfig] config object.
[daft.set_planning_config][daft.context.set_planning_config] is a convenient way to set your [daft.io.IOConfig][daft.io.IOConfig] as the default config to use on any subsequent Daft method calls.
=== "Using Permanent Keys"
```python
from daft.io import IOConfig, CosConfig
io_config = IOConfig(
cos=CosConfig(
region="ap-guangzhou",
secret_id="your-secret-id",
secret_key="your-secret-key",
)
)
# Globally set the default IOConfig for any subsequent I/O calls
daft.set_planning_config(default_io_config=io_config)
# Perform some I/O operation
df = daft.read_parquet("cos://my-bucket/my_path/**/*")
```=== "Using STS Temporary Credentials"
```python
from daft.io import IOConfig, CosConfig
io_config = IOConfig(
cos=CosConfig(
region="ap-guangzhou",
secret_id="your-tmp-secret-id",
secret_key="your-tmp-secret-key",
security_token="your-security-token",
)
)
# Perform some I/O operation
df = daft.read_parquet("cos://my-bucket/my_path/**/*", io_config=io_config)
```=== "Using Custom Endpoint"
```python
from daft.io import IOConfig, CosConfig
# Use a custom endpoint (region will be auto-derived from the endpoint)
io_config = IOConfig(
cos=CosConfig(
endpoint="https://cos.ap-beijing.myqcloud.com",
secret_id="your-secret-id",
secret_key="your-secret-key",
)
)
df = daft.read_parquet("cos://my-bucket/data/**/*", io_config=io_config)
```=== "Anonymous Access"
```python
from daft.io import IOConfig, CosConfig
# Access public buckets without credentials
io_config = IOConfig(cos=CosConfig(anonymous=True))
# Read from a public bucket
df = daft.read_parquet("cos://public-bucket/data/**/*", io_config=io_config)
```=== "Using CosN URL Scheme"
```python
from daft.io import IOConfig, CosConfig
io_config = IOConfig(
cos=CosConfig(
region="ap-guangzhou",
secret_id="your-secret-id",
secret_key="your-secret-key",
)
)
# Use cosn:// scheme (Hadoop CosN compatible)
df = daft.read_parquet("cosn://my-bucket/my_path/**/*", io_config=io_config)
```Alternatively, Daft supports overriding the default IOConfig per-operation by passing it into the io_config= keyword argument. This is extremely flexible as you can pass a different [daft.io.CosConfig][daft.io.CosConfig] per function call if you wish!
=== "Per-Operation Config"
```python
from daft.io import IOConfig, CosConfig
io_config = IOConfig(
cos=CosConfig(
region="ap-guangzhou",
secret_id="your-secret-id",
secret_key="your-secret-key",
)
)
# Perform some I/O operation but override the IOConfig
df2 = daft.read_csv("cos://my-bucket/my_other_path/**/*", io_config=io_config)
```Writing Data
Daft supports writing data to COS using the same CosConfig:
import daft
from daft.io import IOConfig, CosConfig
io_config = IOConfig(
cos=CosConfig(
region="ap-guangzhou",
secret_id="your-secret-id",
secret_key="your-secret-key",
)
)
df = daft.from_pydict({"a": [1, 2, 3], "b": ["x", "y", "z"]})
# Write to COS as Parquet
df.write_parquet("cos://my-bucket/output/", io_config=io_config)Configuration Options
The [daft.io.CosConfig][daft.io.CosConfig] object supports the following options:
| Parameter | Type | Default | Description |
|---|---|---|---|
region |
str |
None |
Region name, e.g. "ap-guangzhou", "ap-beijing", "ap-shanghai" |
endpoint |
str |
None |
Custom endpoint URL, e.g. "https://cos.ap-guangzhou.myqcloud.com". If not provided, it will be derived from the region. |
secret_id |
str |
None |
Tencent Cloud SecretId |
secret_key |
str |
None |
Tencent Cloud SecretKey |
security_token |
str |
None |
Security token for temporary credentials (STS) |
anonymous |
bool |
False |
Whether to use anonymous access (for public buckets) |
max_retries |
int |
3 |
Maximum number of retries for failed requests |
retry_timeout_ms |
int |
30000 |
Timeout duration for retry attempts in milliseconds |
connect_timeout_ms |
int |
10000 |
Connection timeout in milliseconds |
read_timeout_ms |
int |
30000 |
Read timeout in milliseconds |
max_concurrent_requests |
int |
50 |
Maximum number of concurrent requests |
max_connections |
int |
50 |
Maximum number of connections per IO thread |
!!! tip "Region and Endpoint Auto-Derivation"
You only need to specify either `region` or `endpoint` — Daft will automatically derive the other:
- If only `region` is provided, the endpoint is derived as `https://cos.{region}.myqcloud.com`
- If only `endpoint` is provided, the region is extracted from the endpoint URL
- If both are provided, both values are used as-isSupported Operations
Daft supports the following operations with COS:
- Read:
read_parquet,read_csv,read_json, and other file readers - Write:
write_parquet,write_csv,write_json(including multipart uploads) - List: Listing objects with glob pattern matching
- Delete: Deleting objects
Connectors Custom Catalogs (`docs/connectors/custom-catalogs.md`)
Custom Catalogs
Daft ships with built-in catalog integrations for Iceberg, Unity, AWS Glue, and others. But if your tables live behind a proprietary metadata service, an internal API, or a convention like "every subdirectory in S3 is a Delta table," you can implement your own catalog and plug it into Daft. This also applies if you use a public catalog system that Daft doesn't have a built-in integration for yet - you can bridge the gap yourself rather than waiting for native support!
The Catalog and Table abstract base classes in daft.catalog are public interfaces designed for this. Subclass them to teach Daft how to discover and read your tables. Once registered, your catalog works everywhere - the Python API, SQL queries, and Sessions all resolve tables through it. Tables are resolved lazily, so you can expose thousands of tables without paying the cost of eagerly constructing DataFrames for all of them upfront.
Overview
Daft's catalog system has two core interfaces:
Catalog- discovers and manages tables and namespacesTable- reads and writes data for a single table
Both are abstract base classes. You implement the private _-prefixed abstract methods, and the base classes provide the public API (argument normalization, if_not_exists variants, etc.) on top.
Once implemented, custom catalogs work with Sessions and SQL just like the built-in ones:
import daft
from daft.session import Session
sess = Session()
sess.attach(my_catalog)
sess.sql("SELECT * FROM my_namespace.my_table").show()Catalog Interface
A Catalog subclass implements these abstract methods:
| Method | Description |
|---|---|
name (property) |
Returns the catalog's name |
_get_table(ident) |
Look up a table by identifier |
_has_table(ident) |
Check if a table exists |
_list_tables(pattern) |
List tables, optionally filtered by pattern |
_has_namespace(ident) |
Check if a namespace exists |
_list_namespaces(pattern) |
List namespaces, optionally filtered by pattern |
_create_namespace(ident) |
Create a namespace |
_create_table(ident, schema, ...) |
Create a table |
_drop_namespace(ident) |
Drop a namespace |
_drop_table(ident) |
Drop a table |
The base class provides the public methods (get_table, list_tables, create_table_if_not_exists, read_table, write_table, etc.) on top of these.
Table Interface
A Table subclass implements these abstract methods:
| Method | Description |
|---|---|
name (property) |
Returns the table's name |
schema() |
Returns the table's Schema |
read(**options) |
Returns a DataFrame for this table |
append(df, **options) |
Appends a DataFrame to this table |
overwrite(df, **options) |
Overwrites this table with a DataFrame |
The base class provides write(df, mode), select(*columns), and show(n) on top of these.
Walkthrough: Iceberg Catalog Implementation
Daft's Iceberg Catalog integration is a complete built-in implementation that covers every abstract method. The snippets below are trimmed to highlight the pattern - see the full source for details.
IcebergCatalog
The catalog wraps a PyIceberg Catalog client. Each abstract method converts between Daft's Identifier type and PyIceberg's tuple-based identifiers, then delegates to the inner client:
/* Detailed source-code truncated for AI context efficiency. */The key pattern: convert Identifier to your backend's native format (here, a tuple via tuple(identifier)), call the backend, and translate exceptions to NotFoundError.
IcebergTable
The table wraps a PyIceberg Table object. read() delegates to daft.read_iceberg and write methods delegate to DataFrame.write_iceberg:
from daft.catalog import Table
from daft.dataframe import DataFrame
from daft.logical.schema import Schema
class IcebergTable(Table):
_inner: InnerTable # pyiceberg.table.Table
@property
def name(self) -> str:
return self._inner.name()[-1]
def schema(self) -> Schema:
# Iceberg derives schema from the lazy read plan here, but if your
# backend exposes schema metadata directly, prefer that instead.
return self.read().schema()
def read(self, **options) -> DataFrame:
return daft.read_iceberg(self._inner, **options)
def append(self, df: DataFrame, **options) -> None:
df.write_iceberg(self._inner, mode="append")
def overwrite(self, df: DataFrame, **options) -> None:
df.write_iceberg(self._inner, mode="overwrite")Using a Custom Catalog
Custom catalogs integrate with the full Daft API - Sessions, SQL, and the Python API:
import daft
from daft.session import Session
# Create your catalog
catalog = MyCatalog(...)
# Use it directly
catalog.list_tables()
catalog.read_table("my_namespace.my_table")
# Or attach it to a session for SQL support
sess = Session()
sess.attach(catalog)
sess.set_namespace("my_namespace")
# SQL queries resolve tables through your catalog
sess.sql("SELECT * FROM my_table").show()Reference
For complete API documentation, see the Catalogs & Tables API reference.
Other built-in implementations for reference:
- Unity Catalog - read-only, simplest implementation
- AWS Glue Catalog - AWS Glue metadata service
- PostgreSQL Catalog - full read/write with PostgreSQL
Connectors Custom (`docs/connectors/custom.md`)
Reading from and Writing to Custom Connectors
This guide shows you how to create custom connectors to read from or write to data sources that aren't currently implemented in Daft, which includes proprietary data sources that can't be implemented in open source.
Overview
Daft provides two main interfaces for custom connectors:
You can also take a look at actual code references on how we implemented:
Reading from a Custom Data Source
Step 1: Implement the `DataSource` and `DataSourceTask` Interfaces
Create a class that inherits from DataSource, a class that inherits from DataSourceTask, and implement the required methods. Here's a simple example doing this with a custom local file reader that reads each line from a file as a single String row.
=== "🐍 Python"
/* Detailed source-code truncated for AI context efficiency. */Step 2: Use Your Custom Data Source to Create a Daft DataFrame
=== "🐍 Python"
import daft
# Create a sample text file and read from it.
sample_file = "sample_text.txt"
with open(sample_file, "w") as f:
f.write("Alice was beginning to get very tired of sitting by her sister on the bank.\n")
f.write("So she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid),\n")
f.write("when suddenly a White Rabbit with pink eyes ran close by her.\n")
f.write("There was nothing so very remarkable in that;\n")
f.write("nor did Alice think it so very much out of the way to hear the Rabbit say to itself, 'Oh dear! Oh dear! I shall be late!'\n")
data_source = TextFileDataSource([sample_file])
(
data_source
.read()
.show()
)Output:
╭────────────────────────────────╮
│ line │
│ --- │
│ Utf8 │
╞════════════════════════════════╡
│ Alice was beginning to get ve… │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ So she was considering in her… │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ when suddenly a White Rabbit … │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ There was nothing so very rem… │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ nor did Alice think it so ver… │
╰────────────────────────────────╯
(Showing first 5 of 5 rows)Optional: Declaring Clustering to Skip Shuffles
Clustering is an execution-time property: it describes how rows are distributed across the
in-memory partitions a query runs over. It is distinct from storage partitioning (the on-disk
layout declared via get_partition_fields(), e.g. Hive/Iceberg directories) — a source can be laid
out one way on disk yet emit partitions clustered another way.
Two clustering hints are available:
Hash clustering
If your source already emits data that is hash-partitioned by some keys — for example, eachDataSourceTask corresponds to exactly one(producer, hour) group — you can tell Daft by overriding get_clustering_keys(). Daft then
skips the shuffle it would otherwise insert before a downstream groupby, Window.partition_by,
or distinct whose keys are covered by (equal to, or a superset of) the declared clustering.
from daft import col
from daft.io.clustering import ClusteringKeys
from daft.io.source import DataSource
class ClusteredSource(DataSource):
# ... name / schema / get_tasks as above ...
def get_clustering_keys(self) -> ClusteringKeys | None:
# Each task emits exactly one (a, b) group, so the output is hash-partitioned by (a, b).
return ClusteringKeys.hash("a", "b")Keys may be column names or arbitrary Expressions. An expression-valued
key follows a projection that materializes it as a derived column, so the clustering is preserved
even after a with_column:
def hour_bucket(ts: "daft.Expression") -> "daft.Expression":
# bucket a unix-epoch timestamp (in seconds) into hourly buckets
return ts // 3600
class EventSource(DataSource):
def get_clustering_keys(self) -> ClusteringKeys | None:
return ClusteringKeys.hash(col("producer"), hour_bucket(col("ts")))
df = (
EventSource().read()
.with_column("hour", hour_bucket(col("ts"))) # materialize the expression key as a column
.groupby("producer", "hour") # covered by the declared clustering => no shuffle
.sum("value")
)Range clustering
If your source already emits data where each task covers a non-overlapping range of values for
those columns, you can tell Daft by returning ClusteringKeys.range(). Pass descending=True
if partitions are ordered from highest to lowest values. If any key values are null, declare
where those rows live with nulls_first (default: nulls in the last partition when ascending,
the first when descending, matching sort semantics).
class TimeSeriesSource(DataSource):
# ... name / schema / get_tasks as above ...
def get_clustering_keys(self) -> ClusteringKeys | None:
# Each task holds a non-overlapping ts range (ascending partition order).
return ClusteringKeys.range("ts")
# Or, if partitions are ordered high-to-low:
# return ClusteringKeys.range("ts", descending=True)!!! note "Shuffle elision applies to the distributed runner"
Daft only inserts these shuffles when running distributed (e.g. on Ray); the single-node
runner already executes the operators locally. The declaration is therefore a no-op for local
execution and only changes plans for distributed runs.!!! warning "Clustering must hold for every task"
Daft trusts the declaration. For hash clustering, every row with the same hash of the declared
keys must be produced within a single task. For range clustering, each task must cover a
genuinely non-overlapping range of values for the declared columns. Incorrectly overriding
`get_clustering_keys()` will likely lead to incorrect results.Writing to a Custom Data Sink
Step 1: Implement the `DataSink` Interface
Create a class that inherits from DataSink and implements the required methods. Here's a simple example doing this with a custom local file writer.
=== "🐍 Python"
/* Detailed source-code truncated for AI context efficiency. */Step 2: Use Your Custom Data Sink
=== "🐍 Python"
import daft
from custom_datasink_example import LocalFileDataSink
data = {
"id": list(range(1, 1001)),
"name": [f"User_{i}" for i in range(1, 1001)],
}
local_file_data_sink = LocalFileDataSink(
output_dir="./output_folder",
filename_prefix="users",
max_rows_per_file=10
)
(
daft.from_pydict(data)
.write_sink(local_file_data_sink)
.show()
)Sample File Output:
{'id': 111, 'name': 'User_111'}
{'id': 112, 'name': 'User_112'}
{'id': 113, 'name': 'User_113'}
{'id': 114, 'name': 'User_114'}
{'id': 115, 'name': 'User_115'}
{'id': 116, 'name': 'User_116'}
{'id': 117, 'name': 'User_117'}
{'id': 118, 'name': 'User_118'}
{'id': 119, 'name': 'User_119'}
{'id': 120, 'name': 'User_120'}More Examples
For further reference, feel free to check out some of our notable data connectors that are implemented using the DataSource and DataSink interfaces:
- Reads from Video Frames
- Reads from LanceDB
- Writes to Hugging Face 🤗
- Writes to ClickHouse
- Writes to Turbopuffer
- Writes to LanceDB
Connectors Delta Lake (`docs/connectors/delta_lake.md`)
Reading from and Writing to Delta Lake
Delta Lake is an open-source storage framework for data analytics on data lakes. It provides ACID transactions, scalable metadata handling, and a unification of streaming and batch data processing, all on top of Parquet files in cloud storage.
Daft currently supports:
Parallel + Distributed Reads: Daft parallelizes Delta Lake table reads over all cores of your machine, if using the default multithreading runner, or all cores + machines of your Ray cluster, if using the distributed Ray runner.
Skipping Filtered Data: Daft ensures that only data that matches your [
df.where()][daft.DataFrame.where] filter will be read, often skipping entire files/partitions.Multi-cloud Support: Daft supports reading Delta Lake tables from AWS S3, Azure Blob Store, and GCS, as well as local files.
A detailed Delta Lake roadmap for Daft can be found on our GitHub issues. For the overall Daft development plan, see Daft Roadmap.
Installing Daft with Delta Lake Support
Daft internally uses the deltalake Python package to fetch metadata about the Delta Lake table, such as paths to the underlying Parquet files and table statistics. The deltalake package therefore must be installed to read Delta Lake tables with Daft, either manually or with the below daft[deltalake] extras install of Daft.
pip install -U "daft[deltalake]"Reading a Table
A Delta Lake table can be read by providing [daft.read_deltalake][daft.read_deltalake] with the URI for your table.
The below example uses the deltalake Python package to create a local Delta Lake table for Daft to read, but Daft can also read Delta Lake tables from all of the major cloud stores.
=== "🐍 Python"
```python
# Create a local Delta Lake table.
from deltalake import write_deltalake
import pandas as pd
df = pd.DataFrame({
"group": [1, 1, 2, 2, 3, 3, 4, 4],
"num": list(range(8)),
"letter": ["a", "b", "c", "d", "e", "f", "g", "h"],
})
# This will write out separate partitions for group=1, group=2, group=3, group=4.
write_deltalake("some-table", df, partition_by="group")
```After writing this local example table, we can easily read it into Daft.
=== "🐍 Python"
```python
# Read a Delta Lake table
import daft
df = daft.read_deltalake("some-table")
```Data Skipping Optimizations
Subsequent filters on the partition column group will efficiently skip data that doesn't match the predicate. In the below example, the group != 2 partitions (files) will be pruned, i.e. they will never be read into memory.
=== "🐍 Python"
```python
# Filter on partition columns will result in efficient partition pruning; non-matching partitions will be skipped.
df2 = df.where(df["group"] == 2)
df2.show()
```Filters on non-partition columns will still benefit from automatic file pruning via file-level statistics. In the below example, the group=2 partition (file) will have 2 <= df["num"] <= 3 lower/upper bounds for the num column, and since the filter predicate is df["num"] < 2, Daft will prune the file from the read. Similar is true for group=3 and group=4 partitions, with none of the data from those files being read into memory.
=== "🐍 Python"
```python
# Filter on non-partition column, relying on file-level column stats to efficiently prune unnecessary file reads.
df3 = df.where(df["num"] < 2)
df3.show()
```Write to Delta Lake
You can use [df.write_deltalake()][daft.DataFrame.write_deltalake] to write a DataFrame to a Delta table:
=== "🐍 Python"
```python
df.write_deltalake("tmp/daft-recordbatch", mode="overwrite")
```Daft supports multiple write modes. See the API docs for [df.write_deltalake()][daft.DataFrame.write_deltalake] for more details.
When writing Delta Lake tables to S3, Daft relies on the native conditional write support in deltalake versions >=0.23.0. DynamoDB locking is not required by default for AWS S3 writes. To explicitly use a DynamoDB locking provider, pass dynamo_table_name="..." to [df.write_deltalake()][daft.DataFrame.write_deltalake]. For deltalake versions less than 0.23.0, if dynamo_table_name is not provided and allow_unsafe_rename is False, ValueError exception will be raised.
Checkpointing
Daft supports idempotent writes to Delta Lake via the checkpoint= parameter on [df.write_deltalake()][daft.DataFrame.write_deltalake]. Retries of the same logical commit — after a crash, a transient catalog error, or a deliberate re-invocation — produce the same Delta state without duplicate commits. See the Checkpointing user guide for concepts; the sections below cover Delta-specific behavior.
Example
The pattern: one CheckpointStore paired into both the source (via CheckpointConfig) and the sink (via IdempotentCommit). The source records which inputs were processed; the sink stamps the resulting Delta commit with the idempotence key.
=== "🐍 Python"
```python
import daft
# One store, paired into both the source and the sink.
store = daft.CheckpointStore("s3://my-bucket/ckpt/")
df = daft.read_parquet(
"s3://input/",
checkpoint=daft.CheckpointConfig(store, on="file_id"),
)
# Any map-only operations work here: filter, project, UDF, explode, ...
df = df.where(df["status"] == "active")
written = df.write_deltalake(
"s3://my-bucket/my-table/",
checkpoint=daft.IdempotentCommit(store, idempotence_key="job-2026-05-21-001"),
)
```A fresh run produces one new Delta commit tagged with daft.idempotence-key=job-2026-05-21-001. Retries with the same idempotence_key recognize the prior commit and exit cleanly — no duplicate commit, no reprocessing of inputs already handled.
Inspecting the Marker
Every idempotent commit tags its Delta commit info with daft.idempotence-key. To verify which logical commit produced the current state of a table:
=== "🐍 Python"
```python
# Inspect via the deltalake library.
from deltalake import DeltaTable
table = DeltaTable("s3://my-bucket/my-table/")
print(table.history()[0].get("daft.idempotence-key"))
# → "job-2026-05-21-001"
```For older commits, walk table.history() and check each entry.
Constraints
These are constraints specific to the Delta Lake connector. Daft-wide constraints (Ray runner, map-only pipelines, single-writer concurrency, etc.) are in the user guide's Limitations section.
mode='append'only. Other modes raiseNotImplementedErrorwhencheckpoint=is set.- Reserved
daft.idempotence-*property prefix. Keys incustom_metadatathat start with this prefix raiseValueError— Daft uses this namespace internally.
Idempotence-Key Contract
The user picks the idempotence_key. Daft uses it as the marker that identifies a logical commit. The key must be stable across retries and unique across distinct logical commits — both halves matter:
- Same key, different inputs → silent no-op (data loss). Daft sees the existing marker and skips the commit. The new data is dropped without an error.
- Different key on a retry of the same logical commit → duplicate commit. Daft doesn't recognize the prior attempt and lands a second commit.
Recovery
The user guide's Recovery section walks through the full chronology. The Delta-specific steps:
Commit-from-store. When a run crashes after the pipeline finishes but before the Delta commit lands, Daft reads the staged file references from the store on rerun and commits them as a single new Delta transaction with
daft.idempotence-keyincustom_metadata.Marker recognition. When a run crashes after the Delta commit lands but before the store is marked done — or when the user deliberately re-runs with the same key — Daft walks
table.history(), finds the marker, marks the store, and exits. No second commit. Returned DataFrame is empty.
Orphan files on crash. A worker that crashed mid-task may leave parquet files unreferenced by any Delta commit. Daft doesn't auto-clean these — use the deltalake vacuum operation.
Delta-Specific Notes
Parallel history walk.
table.history()is already concurrent under the hood (deltalake-rs buffers up tonum_cpus * 4log reads), so marker recognition is fast even on tables with long commit histories.Fresh-table case handled. If the target table doesn't exist yet at the configured URI, the first idempotent write creates it.
Transient retry on
CommitFailedError. Daft retries up to twice on this exception (concurrent-writer conflicts, lock contention). Other exceptions — schema mismatches, metadata-construction errors, network errors — propagate immediately. Wrap the call in your own retry policy if you need broader coverage.
Type System
Daft and Delta Lake have compatible type systems. Here are how types are converted across the two systems.
When reading from a Delta Lake table into Daft:
| Delta Lake | Daft |
|---|---|
BOOLEAN |
[daft.DataType.bool()][daft.datatype.DataType.bool] |
BYTE |
[daft.DataType.int8()][daft.datatype.DataType.int8] |
SHORT |
[daft.DataType.int16()][daft.datatype.DataType.int16] |
INT |
[daft.DataType.int32()][daft.datatype.DataType.int32] |
LONG |
[daft.DataType.int64()][daft.datatype.DataType.int64] |
FLOAT |
[daft.DataType.float32()][daft.datatype.DataType.float32] |
DOUBLE |
[daft.DataType.float64()][daft.datatype.DataType.float64] |
DECIMAL(precision, scale) |
[daft.DataType.decimal128(precision, scale)][daft.datatype.DataType.decimal128] |
DATE |
[daft.DataType.date()][daft.datatype.DataType.date] |
TIMESTAMP_NTZ |
[daft.DataType.timestamp(timeunit="us", timezone=None)][daft.datatype.DataType.timestamp] |
TIMESTAMP |
[daft.DataType.timestamp(timeunit="us", timezone="UTC")][daft.datatype.DataType.timestamp] |
STRING |
[daft.DataType.string()][daft.datatype.DataType.string] |
BINARY |
[daft.DataType.binary()][daft.datatype.DataType.binary] |
MAP<key_type, value_type> |
[daft.DataType.map(key_type, value_type)][daft.datatype.DataType.map] |
STRUCT<[field_name: field_type,]> |
[daft.DataType.struct(fields)][daft.datatype.DataType.struct] |
ARRAY<element_type> |
[daft.DataType.list(element_type)][daft.datatype.DataType.list] |
References:
- Python
unitycatalogtype name code reference - Spark types documentation
- Databricks types documentation
Connectors Files (`docs/connectors/files.md`)
Creating File References with from_files
Daft provides [daft.from_files()][daft.io.from_files] to create a DataFrame of lazy file references from glob patterns. Unlike other read functions that immediately load file contents, from_files creates File objects that can be read on demand.
Basic Usage
=== "Local Files"
```python
import daft
df = daft.from_files("/path/to/files/*.jpeg")
df.show()
```=== "Remote Files (S3)"
```python
import daft
df = daft.from_files("s3://my-bucket/images/*.png")
df.show()
```=== "Remote Files (GCS)"
```python
import daft
df = daft.from_files("gs://my-bucket/images/*.png")
df.show()
```Output Schema
The from_files function returns a DataFrame with a single column:
| Column | Type | Description |
|---|---|---|
file |
File |
Lazy file references that can be read on demand |
Wildcard Patterns
from_files supports standard glob patterns:
| Pattern | Description |
|---|---|
* |
Matches any number of characters |
? |
Matches any single character |
[...] |
Matches any single character in the brackets |
** |
Recursively matches directories |
=== "Examples"
```python
# All JPEG files in a directory
df = daft.from_files("/images/*.jpeg")
# Recursive search
df = daft.from_files("/images/**/*.png")
# Multiple patterns
df = daft.from_files(["/images/*.jpeg", "/photos/*.jpeg"])
```Working with File Objects
The File type is a lazy reference that provides access to file metadata and content:
import daft
from daft import col
df = daft.from_files("/path/to/files/*")
# Access file properties
df = df.select(
col("file").file_path().alias("path"),
col("file").file_size().alias("size_bytes"),
)
df.show()Use Cases
Image Processing Pipeline
For image processing, use [daft.from_glob_path()][daft.from_glob_path] with .download() and decode_image():
import daft
from daft import col
from daft.functions import decode_image
df = (
daft.from_glob_path("s3://bucket/images/**/*.jpg")
.with_column("image_bytes", col("path").download())
.with_column("image", decode_image(col("image_bytes")))
)
df.show()Batch File Operations
import daft
from daft import col
# Get file references with metadata
df = daft.from_files("/data/**/*")
# Filter by file properties before reading content
large_files = df.where(col("file").file_size() > 1_000_000)
large_files.show()Comparison with from_glob_path
from_files is similar to [daft.from_glob_path()][daft.from_glob_path] but returns File objects instead of path strings:
| Function | Returns | Use Case |
|---|---|---|
from_glob_path |
path column (string) |
When you need file paths only |
from_files |
file column (File) |
When you need to read file content or access file properties |
# from_glob_path returns paths
paths_df = daft.from_glob_path("/images/*.jpg") # Column: path (string)
# from_files returns File objects
files_df = daft.from_files("/images/*.jpg") # Column: file (File)Empty Results
If no files match the glob pattern(s), an empty DataFrame is returned instead of raising an error:
df = daft.from_files("/nonexistent/*.txt")
df.show() # Empty DataFrame with "file" columnConnectors Gcs (`docs/connectors/gcs.md`)
Reading from and Writing to Google Cloud Storage
Daft is able to read/write data to/from Google Cloud Storage (GCS), and understands natively the URL protocols gs:// and gcs:// as referring to data that resides in GCS.
Authorization/Authentication
In GCS, data is stored under the hierarchy of:
- Project: The Google Cloud project that owns the storage resources.
- Bucket: The container for data storage.
- Object Key: The unique identifier for a piece of data within a bucket.
URLs to data in GCS come in the form: gs://{BUCKET}/{OBJECT_KEY}.
Rely on Environment
You can configure Application Default Credentials (ADC) to have Daft automatically discover credentials. Common methods include:
- Setting the
GOOGLE_APPLICATION_CREDENTIALSenvironment variable to point to a service account key file - Running
gcloud auth application-default loginfor local development - Using the default service account when running on Google Cloud (GCE, GKE, Cloud Run, etc.)
Please be aware that when doing so in a distributed environment such as Ray, Daft will pick these credentials up from worker machines and thus each worker machine needs to be appropriately provisioned.
If instead you wish to have Daft use credentials from the "driver", you may wish to manually specify your credentials.
Manually specify credentials
You may also choose to pass these values into your Daft I/O function calls using a [daft.io.GCSConfig][daft.io.GCSConfig] config object.
[daft.set_planning_config][daft.context.set_planning_config] is a convenient way to set your [daft.io.IOConfig][daft.io.IOConfig] as the default config to use on any subsequent Daft method calls.
=== "Using Service Account Credentials"
```python
from daft.io import IOConfig, GCSConfig
# Path to your service account JSON key file
io_config = IOConfig(gcs=GCSConfig(credentials="/path/to/service-account-key.json"))
# Globally set the default IOConfig for any subsequent I/O calls
daft.set_planning_config(default_io_config=io_config)
# Perform some I/O operation
df = daft.read_parquet("gs://my_bucket/my_path/**/*")
```=== "Using OAuth2 Token"
```python
from daft.io import IOConfig, GCSConfig
# Use an OAuth2 access token directly
io_config = IOConfig(gcs=GCSConfig(token="your-oauth2-access-token"))
# Perform some I/O operation
df = daft.read_parquet("gs://my_bucket/my_path/**/*", io_config=io_config)
```=== "Anonymous Access"
```python
from daft.io import IOConfig, GCSConfig
# Access public buckets without credentials
io_config = IOConfig(gcs=GCSConfig(anonymous=True))
# Read from a public bucket
df = daft.read_parquet("gs://public_bucket/data/**/*", io_config=io_config)
```Alternatively, Daft supports overriding the default IOConfig per-operation by passing it into the io_config= keyword argument. This is extremely flexible as you can pass a different [daft.io.GCSConfig][daft.io.GCSConfig] per function call if you wish!
=== "Per-Operation Config"
```python
from daft.io import IOConfig, GCSConfig
io_config = IOConfig(gcs=GCSConfig(credentials="/path/to/service-account-key.json"))
# Perform some I/O operation but override the IOConfig
df2 = daft.read_csv("gs://my_bucket/my_other_path/**/*", io_config=io_config)
```Configuration Options
The [daft.io.GCSConfig][daft.io.GCSConfig] object supports the following options:
| Parameter | Type | Description |
|---|---|---|
project_id |
str |
Google Cloud project ID |
credentials |
str |
Path to service account JSON key file |
token |
str |
OAuth2 access token |
anonymous |
bool |
Whether to use anonymous access (for public buckets) |
max_connections |
int |
Maximum number of concurrent connections |
retry_initial_backoff_ms |
int |
Initial backoff time in milliseconds for retries |
connect_timeout_ms |
int |
Connection timeout in milliseconds |
read_timeout_ms |
int |
Read timeout in milliseconds |
num_tries |
int |
Number of retry attempts |
Connectors Generic File Source Options (`docs/connectors/generic-file-source-options.md`)
Generic File Source Options
These options apply to read_parquet, read_csv, and read_iceberg. They are not tied to any single connector or format. Other readers (read_json, read_warc, read_text) do not support these options.
Ignoring Corrupt Files
When reading large collections of files, some files may be unreadable — corrupt, truncated, or deleted between the time Daft lists them and the time it reads them. By default, Daft raises an error and halts the query. The ignore_corrupt_files option changes that behavior: qualifying files are silently skipped and the query continues with the remaining data.
Enabling `ignore_corrupt_files`
Pass ignore_corrupt_files=True to any of the supported reader functions:
import daft
# Parquet / CSV (glob-based)
df = daft.read_parquet("s3://my-bucket/data/**/*.parquet", ignore_corrupt_files=True)
df = daft.read_csv("s3://my-bucket/data/**/*.csv", ignore_corrupt_files=True)
# Iceberg
import pyiceberg
table = pyiceberg.table.StaticTable.from_metadata("s3://bucket/iceberg/metadata.json")
df = daft.read_iceberg(table, ignore_corrupt_files=True)
df.collect()What counts as "corrupt"
Daft skips a file when it encounters a problem that is specific to the file itself and cannot be resolved by retrying:
| Category | Examples |
|---|---|
| Invalid format | Bad Parquet magic bytes, truncated footer, mismatched row/column counts |
| Corrupt data | Unreadable row group, invalid CSV encoding, wrong field count in a row |
| Missing file | File deleted between listing and reading (e.g. concurrent compaction or partition overwrite) |
Daft does not skip files for transient infrastructure problems, because those can and should be retried:
| Category | Examples |
|---|---|
| Network errors | Connection reset, read timeout, throttled I/O |
| Permission errors | Access denied, insufficient credentials |
This distinction matters. Silently retrying a permission error would mask a misconfiguration that needs human attention.
Observability: knowing what was skipped
ignore_corrupt_files is designed around the principle that errors should be visible, not hidden. Daft provides two complementary observability mechanisms.
Python warning logs
Daft emits a WARNING-level log message for every skipped file, including the file path and the reason:
WARNING daft.io - Skipping corrupt Parquet file s3://my-bucket/data/bad.parquet: ...
WARNING daft.io - Skipping corrupt CSV chunk in s3://my-bucket/data/partial.csv: ...You can see these with standard Python logging:
import logging
logging.basicConfig(level=logging.WARNING)`df.skipped_corrupt_files` — programmatic access
After materializing the dataframe with .collect(), the skipped_corrupt_files property returns the list of skipped (path, reason) pairs as structured data, so your pipeline code can act on them:
df = daft.read_parquet("s3://my-bucket/data/**/*.parquet", ignore_corrupt_files=True)
df.collect()
skipped = df.skipped_corrupt_files # list[tuple[str, str, bool]]
for path, reason, partial in skipped:
tag = " (partial)" if partial else ""
print(f"Skipped{tag}: {path}\n Reason: {reason}")Each entry is a (path, reason, partial) tuple. When partial is True, some batches from the file were already emitted before the corruption was detected — the file was not fully skipped. This can happen when corruption appears in a later row group.
skipped_corrupt_files is available after calling .collect() on the dataframe. Other execution methods such as .count_rows() do not populate this property, because they operate on an internal dataframe rather than materializing the original one.
Handling skipped files in production
Because skipped_corrupt_files is plain Python data, you can plug it directly into your existing alerting or data-quality workflows:
import daft
df = daft.read_parquet("s3://my-bucket/nightly/**/*.parquet", ignore_corrupt_files=True)
df.write_parquet("s3://my-bucket/processed/")
skipped = df.skipped_corrupt_files
if skipped:
# Option 1: send an alert
send_alert(f"{len(skipped)} file(s) skipped during nightly run", details=skipped)
# Option 2: push to a dead-letter queue for later reprocessing
for path, reason, partial in skipped:
dead_letter_queue.put({"path": path, "reason": reason, "partial": partial, "run": TODAY})This pattern — errors visible, impact contained, tooling to fix — lets automated batch jobs complete reliably while still surfacing problems for human review.
!!! warning "Do not use ignore_corrupt_files as a catch-all"
This option is designed for files that are genuinely unreadable. It should not be used to suppress transient I/O errors (network issues, throttling) — Daft already retries those automatically. If you find yourself needing ignore_corrupt_files for a large fraction of your files, investigate the root cause rather than silencing the errors.
Supported formats
| Format | File-level skip | Within-file error skip |
|---|---|---|
Parquet (read_parquet) |
Yes (bad footer, wrong magic bytes, file too small) | Yes (corrupt row group data) |
CSV (read_csv) |
Yes (unreadable file, truncated) | Yes (bad encoding, wrong field count in chunk) |
Iceberg (read_iceberg) |
Yes (data files go through the Rust Parquet reader) | Yes |
All three are supported from both the Python API and the SQL table functions. See
Table Function Options for the full list of
named arguments each SQL reader accepts.
!!! note "Iceberg delete files"
Corruption in Iceberg delete files is not covered. If a delete file is unreadable, Daft will raise an error regardless of ignore_corrupt_files. Delete files are small metadata structures and corruption there generally indicates a more serious catalog inconsistency.
!!! note "Count pushdown"
When ignore_corrupt_files is enabled for Parquet, count pushdown is disabled. This means df.count() will read all row-group data instead of using the metadata-only optimization, which may be slower on large datasets.
Connectors Glue (`docs/connectors/glue.md`)
Reading from and Writing to AWS Glue
!!! warning "Warning"
These APIs are early in their development. Please feel free to [open feature requests and file issues](https://github.com/Eventual-Inc/Daft/issues/new/choose). We'd love hear want you would like, thank you! 🤘Daft integrates with AWS Glue using the Daft Catalog interface. Daft provides builtin support for a handful of tables (details below), but you may provide your own GlueTable implementations and register them to your GlueCatalog instance. Note that in Daft, the AWS Glue service-level Data Catalog maps to a Daft Catalog and a Glue Database is a Namespace — this can be confusing and is important to remember.
| Daft | Glue |
|---|---|
| Catalog | Data Catalog |
| Namespace | Database |
| Table | Table |
Example
=== "🐍 Python"
```python
from daft.catalog.__glue import load_glue
# Load a glue catalog instance
catalog = load_glue(
name="my_glue_catalog",
region_name="us-west-2"
)
# Load a glue table
tbl = catalog.get_table("my_namespace.my_table")
# Read the table as a DataFrame
df = tbl.read()
df.show()
```Support
Glue supports many different table classifications along with various table formats like Iceberg, Delta Lake, and Hudi.
Formats
Daft has preliminary read support for the CSV and Parquet formats, but does not yet support reading with Hive-style partitioning. Daft does support
reading and writing both Iceberg and Delta Lake. We do not currently support creating Glue tables.
| Table Format | Support | AWS Documentation |
|---|---|---|
| CSV | read | Documentation |
| Parquet | read | Documentation |
| Iceberg | read, write | Documentation |
| Delta Lake | read, write | Documentation |
Type System
The Glue Catalog Type System is based on the Apache Hive Type System; however, the exact types are not validated and are dependent upon the underlying table format.
The Data Catalog does not validate types written to type fields. When AWS Glue components read and write to the Data Catalog, they will be compatible with each other. AWS Glue components also aim to preserve a high degree of compatibility with the Hive types. However, AWS Glue components do not guarantee compatibility with all Hive types.
| Table Format | Type System |
|---|---|
| CSV | Glue CSV Type Reference |
| Parquet | Arrow Type System Reference |
| Iceberg | Iceberg Type System Reference |
| Delta Lake | Delta Lake Type System Reference |
Custom Table Implementation
!!! warning "Warning"
This is not considered a stable API, it is just a patch technique!To implement a custom table format, you must implement the GlueTable abstract class and register to your GlueCatalog instance. To register,
you must append your custom table's class to the catalog's _table_impls field.
The Daft GlueCatalog._table_impls field holds a list of GlueTable implementation classes. When we resolve a table, we call Glue's GetTable API and call from_table_info with the Glue Table object. It is expected that each GlueTable implementation will throw a ValueError if the table metadata does not match.
from typing import Any, Literal
from daft.catalog.__glue import GlueCatalog, GlueTable, load_glue
class GlueTestTable(GlueTable):
"""GlueTestTable shows how we register custom table implementations."""
@classmethod
def from_table_info(cls, catalog: GlueCatalog, table: dict[str, Any]) -> GlueTable:
if bool(table["Parameters"].get("pytest")):
return cls(catalog, table)
raise ValueError("Expected Parameter pytest='True'")
def read(self, **options) -> DataFrame:
raise NotImplementedError
def write(self, df: DataFrame, mode: Literal['append'] | Literal['overwrite'] = "append", **options) -> None:
raise NotImplementedError
gc = load_glue("my_glue_catalog", region_name="us-west-2")
gc._table_impls.append(GlueTestTable) # Register custom table implementationConnectors Goosefs (`docs/connectors/goosefs.md`)
Reading from and Writing to GooseFS
Daft supports reading and writing data to GooseFS — a distributed caching file system — and understands natively the URL protocol goosefs:// as referring to data that resides in a GooseFS cluster.
Under the hood, Daft speaks GooseFS's native gRPC protocol via OpenDAL's services-goosefs backend. This means that no HDFS/JVM gateway is required — Daft talks to GooseFS masters and workers directly.
URL Format
URLs to data in GooseFS come in the form:
goosefs://{MASTER_HOST}:{MASTER_PORT}/{PATH}MASTER_HOST:MASTER_PORTidentifies a GooseFS master. If no explicitmaster_addris provided in the config, Daft will use this URL authority as the master address.PATHis the absolute path to the file or directory inside the GooseFS namespace.
!!! note "High Availability (HA) masters"
For a GooseFS deployment with multiple masters, encode all masters as a comma-separated list in the [`GooseFSConfig`][daft.io.GooseFSConfig] `master_addr` field, e.g. `"10.0.0.1:9200,10.0.0.2:9200,10.0.0.3:9200"`. When `master_addr` is set, the URL authority is ignored — you can then use a symbolic host in the URL, for example `goosefs://ha/path/to/data`.Authorization/Authentication
GooseFS supports two authentication modes: simple (username-based, the default) and nosasl (no authentication). Anonymous access forces nosasl.
Rely on Environment
You can configure Daft to automatically discover GooseFS settings from environment variables. Daft supports the following variables when [GooseFSConfig.from_env()][daft.io.GooseFSConfig] is used:
| Environment Variable | Description |
|---|---|
GOOSEFS_MASTER_ADDR |
Master address(es) for the GooseFS cluster (single host:port, or comma-separated list for HA) |
GOOSEFS_AUTH_USERNAME |
Authentication username (falls back to USER / USERNAME when unset) |
GOOSEFS_AUTH_PASSWORD |
Authentication password |
GOOSEFS_AUTH_TYPE |
Authentication type — "simple" or "nosasl" |
GOOSEFS_WRITE_TYPE |
Default write type for new files ("must_cache", "cache_through", "through", "async_through") |
GOOSEFS_ROOT |
Root path of the backend |
Please be aware that when doing so in a distributed environment such as Ray, Daft will pick these values up from worker machines and thus each worker machine needs to be appropriately provisioned.
If instead you wish to have Daft use configuration from the "driver", you may wish to manually specify your credentials.
Manually specify credentials
You can pass a [GooseFSConfig][daft.io.GooseFSConfig] into your Daft I/O function calls using a [daft.io.IOConfig][daft.io.IOConfig] config object.
[daft.set_planning_config][daft.context.set_planning_config] is a convenient way to set your [daft.io.IOConfig][daft.io.IOConfig] as the default config to use on any subsequent Daft method calls.
=== "Using Simple Authentication"
```python
import daft
from daft.io import IOConfig, GooseFSConfig
io_config = IOConfig(
goosefs=GooseFSConfig(
master_addr="10.0.0.1:9200",
auth_type="simple",
auth_username="alice",
)
)
# Globally set the default IOConfig for any subsequent I/O calls
daft.set_planning_config(default_io_config=io_config)
# Perform some I/O operation
df = daft.read_parquet("goosefs://10.0.0.1:9200/my_path/**/*")
```=== "Using HA Masters"
```python
import daft
from daft.io import IOConfig, GooseFSConfig
io_config = IOConfig(
goosefs=GooseFSConfig(
# Comma-separated HA master list; the URL authority is ignored.
master_addr="10.0.0.1:9200,10.0.0.2:9200,10.0.0.3:9200",
auth_type="simple",
auth_username="alice",
)
)
df = daft.read_parquet("goosefs://ha/my_path/**/*", io_config=io_config)
```=== "Anonymous Access"
```python
import daft
from daft.io import IOConfig, GooseFSConfig
# Anonymous mode forces `nosasl` and skips credential forwarding.
io_config = IOConfig(goosefs=GooseFSConfig(anonymous=True))
df = daft.read_parquet(
"goosefs://10.0.0.1:9200/public/**/*",
io_config=io_config,
)
```=== "Custom Root and Write Type"
```python
import daft
from daft.io import IOConfig, GooseFSConfig
io_config = IOConfig(
goosefs=GooseFSConfig(
master_addr="10.0.0.1:9200",
root="/warehouse",
# One of: must_cache, cache_through, through, async_through
write_type="cache_through",
)
)
df = daft.read_parquet("goosefs://10.0.0.1:9200/table/**/*", io_config=io_config)
```Alternatively, Daft supports overriding the default IOConfig per-operation by passing it into the io_config= keyword argument. This is extremely flexible as you can pass a different [GooseFSConfig][daft.io.GooseFSConfig] per function call if you wish!
=== "Per-Operation Config"
```python
import daft
from daft.io import IOConfig, GooseFSConfig
io_config = IOConfig(
goosefs=GooseFSConfig(
master_addr="10.0.0.1:9200",
auth_username="alice",
)
)
# Perform some I/O operation but override the IOConfig
df2 = daft.read_csv(
"goosefs://10.0.0.1:9200/my_other_path/**/*",
io_config=io_config,
)
```Writing Data
Daft supports writing data to GooseFS using the same GooseFSConfig:
import daft
from daft.io import IOConfig, GooseFSConfig
io_config = IOConfig(
goosefs=GooseFSConfig(
master_addr="10.0.0.1:9200",
auth_username="alice",
# Persist writes through to the underlying UFS.
write_type="cache_through",
)
)
df = daft.from_pydict({"a": [1, 2, 3], "b": ["x", "y", "z"]})
# Write to GooseFS as Parquet
df.write_parquet("goosefs://10.0.0.1:9200/output/", io_config=io_config)Configuration Options
The [GooseFSConfig][daft.io.GooseFSConfig] object supports the following options:
| Parameter | Type | Default | Description |
|---|---|---|---|
root |
str |
None ("/") |
Root path of the backend. All operations happen under this root. |
master_addr |
str |
None |
Master address(es) in host:port format. Comma-separated for HA, e.g. "10.0.0.1:9200,10.0.0.2:9200". If not set, the URL authority is used. |
block_size |
int |
None (64 MiB) |
Block size in bytes for new files. |
chunk_size |
int |
None (1 MiB) |
Chunk size in bytes for streaming RPCs. |
write_type |
str |
None ("cache_through") |
Default write type for new files. One of "must_cache", "cache_through", "through", "async_through". |
auth_type |
str |
None ("simple") |
Authentication type. One of "nosasl", "simple". |
auth_username |
str |
None (current OS user) |
Authentication username used in SIMPLE mode. |
auth_password |
str |
None |
Optional authentication password. |
anonymous |
bool |
False |
Whether to use anonymous access. Forces auth_type="nosasl" and skips credential forwarding. |
max_retries |
int |
3 |
Maximum number of retries for failed requests. |
retry_timeout_ms |
int |
30000 |
Timeout duration for retry attempts in milliseconds. |
connect_timeout_ms |
int |
10000 |
Connection timeout in milliseconds. |
read_timeout_ms |
int |
30000 |
Read timeout in milliseconds. |
max_concurrent_requests |
int |
50 |
Maximum number of concurrent requests. |
max_connections |
int |
50 |
Maximum number of connections per IO thread. |
!!! tip "Master address resolution"
You do not have to duplicate the master address in both the URL and the config:
- If only the URL authority (`goosefs://host:port/...`) is provided, it is used as `master_addr`.
- If `master_addr` is set explicitly, it takes precedence over the URL authority (this is required for HA setups).
- If neither is provided, the operation will fail because there is no master to talk to.!!! tip "Choosing a write_type"
- `must_cache` — write only to workers' cache; not persisted to UFS.
- `cache_through` — write to workers' cache and synchronously to UFS. This is the safest default for durability.
- `through` — bypass workers' cache and write directly to UFS.
- `async_through` — write to workers' cache and asynchronously to UFS.Supported Operations
Daft supports the following operations with GooseFS:
- Read:
read_parquet,read_csv,read_json, and other file readers - Write:
write_parquet,write_csv,write_json - List: Listing objects with glob pattern matching
- Delete: Deleting objects
Connectors Gravitino (`docs/connectors/gravitino.md`)
Reading from Apache Gravitino
Apache Gravitino is an open-source data catalog that provides unified metadata management for various data sources and storage systems. Users of Gravitino can work with data assets such as tables (Iceberg, Hive, etc.) and filesets (storing raw files, on s3, gcs, azure blob, etc).
To use Daft with Gravitino, you will need to install Daft with the gravitino option specified like so:
pip install daft[gravitino]!!! warning "Warning"
These APIs are in beta and may be subject to change as the Gravitino connector continues to be developed.Features
- Catalog Navigation: List catalogs, schemas, and tables
- Multi-Format Tables: Read Iceberg and Hive/Parquet tables via
catalog.get_table("...").read() - Table Management: Load existing tables or create new external tables
- Fileset Support: Access Gravitino filesets for file storage
- GVFS Protocol: Read and write files using
gvfs://URLs for seamless fileset access - Authentication: Supports simple and OAuth2 authentication methods
- Daft Catalog Integration: Integration with Daft's catalog system via
Catalog.from_gravitino()
Connecting to Gravitino
=== "🐍 Python"
```python
import daft
from daft.catalog import Catalog
catalog = Catalog.from_gravitino(
endpoint="http://localhost:8090",
metalake_name="my_metalake",
username="admin",
)
# List all available tables
tables = catalog.list_tables("my_catalog.my_schema")
# Read a table (format detected automatically)
df = catalog.get_table("my_catalog.my_schema.my_table").read()
df.show()
```Configuration
Authentication
Catalog.from_gravitino supports two authentication methods:
- Simple Authentication: Uses username/password or just username
- OAuth2: Uses bearer token authentication
=== "🐍 Python"
```python
from daft.catalog import Catalog
# Simple auth with username only
catalog = Catalog.from_gravitino(
endpoint="http://localhost:8090",
metalake_name="my_metalake",
auth_type="simple",
username="admin",
)
# OAuth2 auth
catalog = Catalog.from_gravitino(
endpoint="http://localhost:8090",
metalake_name="my_metalake",
auth_type="oauth2",
token="my-bearer-token",
)
```Storage Credentials
Gravitino manages storage credentials through table and fileset properties. The client automatically extracts and configures:
- S3: Access key, secret key, and session token
GVFS Protocol Support
Daft supports reading and writing files directly from Gravitino filesets using the gvfs:// protocol. This provides a unified interface for accessing files stored in various cloud storage systems through Gravitino's metadata management.
GVFS URL Format
GVFS URLs follow this format:
gvfs://fileset/<catalog>/<schema>/<fileset>/<path>Where:
<catalog>- Name of the Gravitino catalog<schema>- Name of the schema within the catalog<fileset>- Name of the fileset- `` - Optional path to specific files within the fileset
Reading Files with GVFS
=== "🐍 Python"
```python
import daft
from daft.io import IOConfig, GravitinoConfig
# Build an IOConfig for GVFS fileset access
io_config = IOConfig(
gravitino=GravitinoConfig(
endpoint="http://localhost:8090",
metalake_name="my_metalake",
username="admin",
)
)
# Read parquet files from a fileset
df = daft.read_parquet(
"gvfs://fileset/my_catalog/my_schema/my_fileset/**/*.parquet",
io_config=io_config
)
# Read specific file
df = daft.read_parquet(
"gvfs://fileset/my_catalog/my_schema/my_fileset/data.parquet",
io_config=io_config
)
# Read CSV files
df = daft.read_csv(
"gvfs://fileset/my_catalog/my_schema/my_fileset/*.csv",
io_config=io_config
)
# Use glob patterns for file discovery
files_df = daft.from_glob_path(
"gvfs://fileset/my_catalog/my_schema/my_fileset/**/*.json",
io_config=io_config
)
```Writing Files with GVFS
=== "🐍 Python"
```python
import daft
from daft.io import IOConfig, GravitinoConfig
io_config = IOConfig(
gravitino=GravitinoConfig(
endpoint="http://localhost:8090",
metalake_name="my_metalake",
username="admin",
)
)
# Create sample data
df = daft.from_pydict({
"id": [1, 2, 3],
"name": ["Alice", "Bob", "Charlie"],
"age": [25, 30, 35]
})
# Write parquet files to a fileset
df.write_parquet(
"gvfs://fileset/my_catalog/my_schema/my_fileset/output.parquet",
io_config=io_config
)
# Write CSV files
df.write_csv(
"gvfs://fileset/my_catalog/my_schema/my_fileset/output.csv",
io_config=io_config
)
# Write JSON files
df.write_json(
"gvfs://fileset/my_catalog/my_schema/my_fileset/output.json",
io_config=io_config
)
```GVFS Benefits
- Unified Access: Use the same URL format for reading and writing
- Storage Abstraction: Access files without knowing underlying storage details (S3, GCS, etc.)
- Metadata Integration: Leverage Gravitino's catalog metadata for data discovery
- Credential Management: Gravitino handles storage credentials automatically
- Multi-format Support: Works with Parquet, CSV, JSON, and other file formats
API Reference
Catalog.from_gravitino(...)
Creates a Daft Catalog from a Gravitino metalake.
=== "🐍 Python"
```python
from daft.catalog import Catalog
catalog = Catalog.from_gravitino(
endpoint="http://localhost:8090",
metalake_name="my_metalake",
username="admin",
)
catalog.list_tables("my_catalog.my_schema")
```Table.from_gravitino(table)
Creates a Daft Table from a GravitinoTable.
=== "🐍 Python"
```python
from daft.catalog import Catalog, Table
catalog = Catalog.from_gravitino(
endpoint="http://localhost:8090",
metalake_name="my_metalake",
username="admin",
)
table = catalog.get_table("my_catalog.my_schema.my_table")
df = table.read()
```Requirements
- Apache Gravitino server (0.9.0+)
- Python requests library
- Appropriate cloud storage credentials configured in Gravitino
Compatibility
This integration supports both legacy and current Gravitino API formats:
- Legacy format (pre-1.0): Storage location in
properties.location - Current format (1.0+): Multiple storage locations in
storageLocationswith configurable default
The client automatically detects and handles both formats for seamless compatibility.
Limitations
- Credential vending is not yet implemented
- This version directly calls Gravitino RESTful API, not using Gravitino Python client
- GVFS write support currently works with S3-backed filesets (other storage backends coming soon)
- Some advanced Gravitino features may not be exposed through this client
Roadmap
- Support for read/write Iceberg tables from Gravitino ✓
- Support for Hive/Parquet tables ✓
- Support for additional table formats (Hudi)
- Support for more storages (gcs, azure adls, oss, etc)
- Support for credential vending
Please open issues on the Daft repository or Gravitino repository if you have any use-cases that Daft Gravitino connector does not currently cover!
Connectors Hudi (`docs/connectors/hudi.md`)
Reading from Apache Hudi
Apache Hudi is an open-sourced transactional data lake platform that brings database and data warehouse capabilities to data lakes. Hudi supports transactions, efficient upserts/deletes, advanced indexes, streaming ingestion services, data clustering/compaction optimizations, and concurrency all while keeping your data in open source file formats.
Daft currently supports:
Parallel + Distributed Reads: Daft parallelizes Hudi table reads over all cores of your machine, if using the default multithreading runner, or all cores + machines of your Ray cluster, if using the distributed Ray runner.
Skipping Filtered Data: Daft ensures that only data that matches your [
df.where()][daft.DataFrame.where] filter will be read, often skipping entire files/partitions.Multi-cloud Support: Daft supports reading Hudi tables from AWS S3, Azure Blob Store, and GCS, as well as local files.
A detailed Apache Hudi roadmap for Daft can be found on our GitHub Issues page. For the overall Daft development plan, see Daft Roadmap.
Reading a Table
To read from an Apache Hudi table, use the [daft.read_hudi()][daft.read_hudi] function. The following is an example snippet of loading an example table into Daft:
=== "🐍 Python"
```python
# Read Apache Hudi table
import daft
df = daft.read_hudi("some-table-uri")
df = df.where(df["foo"] > 5)
df.show()
```Currently there are limitations of reading Hudi tables:
- Only support snapshot read of Copy-on-Write tables
- Only support reading table version 5 & 6 (tables created using release 0.12.x - 0.15.x)
- Table must not have
hoodie.datasource.write.drop.partition.columns=true
Type System
Daft and Hudi have compatible type systems. Here are how types are converted across the two systems.
When reading from a Hudi table into Daft:
| Apachi Hudi | Daft |
|---|---|
| Primitive Types | |
boolean |
[daft.DataType.bool()][daft.datatype.DataType.bool] |
byte |
[daft.DataType.int8()][daft.datatype.DataType.int8] |
short |
[daft.DataType.int16()][daft.datatype.DataType.int16] |
int |
[daft.DataType.int32()][daft.datatype.DataType.int32] |
long |
[daft.DataType.int64()][daft.datatype.DataType.int64] |
float |
[daft.DataType.float32()][daft.datatype.DataType.float32] |
double |
[daft.DataType.float64()][daft.datatype.DataType.float64] |
decimal(precision, scale) |
[daft.DataType.decimal128(precision, scale)][daft.datatype.DataType.decimal128] |
date |
[daft.DataType.date()][daft.datatype.DataType.date] |
timestamp |
[daft.DataType.timestamp(timeunit="us", timezone=None)][daft.datatype.DataType.timestamp] |
timestampz |
[daft.DataType.timestamp(timeunit="us", timezone="UTC")][daft.datatype.DataType.timestamp] |
string |
[daft.DataType.string()][daft.datatype.DataType.string] |
binary |
[daft.DataType.binary()][daft.datatype.DataType.binary] |
| Nested Types | |
struct(fields) |
[daft.DataType.struct(fields)][daft.datatype.DataType.struct] |
list(child_type) |
[daft.DataType.list(child_type)][daft.datatype.DataType.list] |
map(K, V) |
[daft.DataType.struct({"key": K, "value": V})][daft.datatype.DataType.struct] |
Connectors Huggingface (`docs/connectors/huggingface.md`)
Hugging Face Datasets
Daft has native support for reading from and writing to Hugging Face datasets.
To install all dependencies required for Daft's Hugging Face integrations, use the huggingface feature:
pip install 'daft[huggingface]'Reading From a Dataset
Daft is able to read datasets directly from Hugging Face using the [daft.read_huggingface()][daft.read_huggingface] function or via the hf://datasets/ protocol.
Reading an Entire Dataset
Using [daft.read_huggingface()][daft.read_huggingface], you can easily read a Hugging Face dataset.
=== "🐍 Python"
```python
import daft
df = daft.read_huggingface("username/dataset_name")
```This will read the entire dataset into a DataFrame.
For repositories stored as uncompressed
WebDataset TAR shards,
select the WebDataset reader explicitly:
=== "🐍 Python"
```python
import daft
df = daft.read_huggingface(
"laion/conceptual-captions-12m-webdataset",
format="webdataset",
)
```Media members remain lazy, range-backed file references, so selecting metadata
columns does not download image, audio, or video payloads.
!!! warning "Warning"
`daft.read_huggingface()` is currently limited to WebDataset repositories
or datasets that Hugging Face
[automatically convert](https://huggingface.co/docs/dataset-viewer/en/parquet)
to Parquet. When `format=None`, Daft currently uses the Parquet reader.
For other datasets, you will need to manually specify the path or glob pattern to the files you want to read, similar to how you would read from a local file system. See the next section for an example.Reading Specific Files
Not only can you read entire datasets, but you can also read individual files from a dataset. Using a read function that takes in a path (such as [daft.read_parquet()][daft.read_parquet], [daft.read_csv()][daft.read_csv], or [daft.read_json()][daft.read_json]), specify a Hugging Face dataset path via the hf://datasets/ prefix:
=== "🐍 Python"
```python
import daft
# read a specific Parquet file
df = daft.read_parquet("hf://datasets/username/dataset_name/file_name.parquet")
# or a csv file
df = daft.read_csv("hf://datasets/username/dataset_name/file_name.csv")
# or a set of Parquet files using a glob pattern
df = daft.read_parquet("hf://datasets/username/dataset_name/**/*.parquet")
```Writing to a Dataset
Daft is able to write Parquet files to Hugging Face datasets using [DataFrame.write_huggingface][daft.DataFrame.write_huggingface]. Daft supports Content-Defined Chunking and Xet for faster, deduplicated writes.
Basic usage:
=== "🐍 Python"
```python
import daft
df: daft.DataFrame = ...
df.write_huggingface("username/dataset_name")
```See the [DataFrame.write_huggingface][daft.DataFrame.write_huggingface] API page for more info.
Configuring Writes
[DataFrame.write_huggingface][daft.DataFrame.write_huggingface] accepts an [IOConfig][daft.io.IOConfig] which can be used to configure the write behavior. Here's an example of how to use it:
=== "🐍 Python"
```python
import daft
from daft.io import IOConfig, HuggingFaceConfig
df: daft.DataFrame = ...
io_config = IOConfig(hf=HuggingFaceConfig(
use_content_defined_chunking=...,
row_group_size=...,
target_filesize=...,
max_operations_per_commit=...,
))
df.write_huggingface("username/dataset_name", io_config=io_config)
```See the [HuggingFaceConfig][daft.io.HuggingFaceConfig] API page for more information about each argument.
Xet-Accelerated Reads
When [HuggingFaceConfig.use_xet][daft.io.HuggingFaceConfig] is enabled (the default), Daft probes whether a file is stored on Hugging Face's Xet backend and downloads via the Xet protocol when possible. Non-Xet files (small git blobs, etc.) continue to use the standard HTTP resolve path.
To disable Xet reads and always use HTTP:
=== "🐍 Python"
```python
from daft.io import IOConfig, HuggingFaceConfig
io_config = IOConfig(hf=HuggingFaceConfig(use_xet=False))
df = daft.read_parquet("hf://datasets/username/dataset_name", io_config=io_config)
```You can also set the HF_HUB_DISABLE_XET=1 environment variable to force HTTP-only reads.
Authentication
The token parameter in [HuggingFaceConfig][daft.io.HuggingFaceConfig] can be used to specify a Hugging Face access token for requests that require authentication (e.g. reading private datasets or writing to a dataset).
Example of reading a dataset with a specified token:
=== "🐍 Python"
```python
from daft.io import IOConfig, HuggingFaceConfig
io_config = IOConfig(hf=HuggingFaceConfig(token="your_token"))
df = daft.read_parquet("hf://datasets/username/dataset_name", io_config=io_config)
```Connectors Iceberg (`docs/connectors/iceberg.md`)
Reading from and Writing to Apache Iceberg
Apache Iceberg is an open-source table format originally developed at Netflix for large-scale analytical tables and datasets. It provides a way to manage and organize data files (like Parquet and ORC) as tables, offering benefits like ACID transactions, time travel, and schema evolution.
Daft currently natively supports:
- Distributed Reads: Daft will fully distribute the I/O of reads over your compute resources (whether Ray or on local multithreading)
- Skipping Filtered Data: Daft uses [
df.where()][daft.DataFrame.where] filter calls to only read data that matches your predicates - All Catalogs From PyIceberg: Daft is natively integrated with PyIceberg, and supports all the catalogs that PyIceberg does
A detailed Iceberg roadmap for Daft can be found on our GitHub issues. For the overall Daft development plan, see Daft Roadmap.
Tutorial
Reading a Table
To read from the Apache Iceberg table format, use the [daft.read_iceberg][daft.read_iceberg] function.
We integrate closely with PyIceberg (the official Python implementation for Apache Iceberg) and allow the reading of DataFrames easily from PyIceberg's Table objects. The following is an example snippet of loading an example table, but for more information please consult the PyIceberg Table loading documentation.
=== "🐍 Python"
```python
# Access a PyIceberg table as per normal
from pyiceberg.catalog import load_catalog
catalog = load_catalog("my_iceberg_catalog")
table = catalog.load_table("my_namespace.my_table")
```After a table is loaded as the table object, reading it into a DataFrame is extremely easy.
=== "🐍 Python"
```python
# Create a DataFrame
import daft
df = daft.read_iceberg(table)
```Daft can also read a named Iceberg branch or tag. Branch, tag, and snapshot ID
reads are mutually exclusive.
=== "🐍 Python"
```python
# Create branch/tag references with PyIceberg
table.refresh()
snapshot = table.current_snapshot()
with table.manage_snapshots() as snapshot_manager:
snapshot_manager.create_branch(snapshot.snapshot_id, "audit")
snapshot_manager.create_tag(snapshot.snapshot_id, "v1")
# Read the branch or tag with Daft
branch_df = daft.read_iceberg(table, branch="audit")
tag_df = daft.read_iceberg(table, tag="v1")
```Any subsequent filter operations on the Daft df DataFrame object will be correctly optimized to take advantage of Iceberg features such as hidden partitioning and file-level statistics for efficient reads.
=== "🐍 Python"
```python
# Filter which takes advantage of partition pruning capabilities of Iceberg
df = df.where(df["partition_key"] < 1000)
df.show()
```Writing to a Table
To write to an Apache Iceberg table, use the [df.write_iceberg()][daft.DataFrame.write_iceberg] method.
The following is an example of appending data to an Iceberg table:
=== "🐍 Python"
```python
written_df = df.write_iceberg(table, mode="append")
written_df.show()
```This call will then return a DataFrame containing the operations that were performed on the Iceberg table, like so:
╭───────────┬───────┬───────────┬────────────────────────────────╮
│ operation ┆ rows ┆ file_size ┆ file_name │
│ --- ┆ --- ┆ --- ┆ --- │
│ Utf8 ┆ Int64 ┆ Int64 ┆ Utf8 │
╞═══════════╪═══════╪═══════════╪════════════════════════════════╡
│ ADD ┆ 5 ┆ 707 ┆ 2f1a2bb1-3e64-49da-accd-1074e… │
╰───────────┴───────┴───────────┴────────────────────────────────╯Overwriting a Table
mode="overwrite" replaces the entire table: the existing rows are deleted, then the
DataFrame's rows are appended.
=== "🐍 Python"
```python
written_df = df.write_iceberg(table, mode="overwrite")
```Overwriting a Partition
To replace only part of a table, known as a static partition overwrite, pass anoverwrite_filter along with mode="overwrite". Only the rows matching the predicate are
deleted, and the delete and the append land in a single Iceberg transaction.
=== "🐍 Python"
```python
# Replace only the `dt = '2024-01-01'` partition and leave the others in place.
written_df = df.write_iceberg(table, mode="overwrite", overwrite_filter="dt = '2024-01-01'")
```The filter accepts an Iceberg predicate string or a Daft expression, so these are
equivalent:
=== "🐍 Python"
```python
from daft import col
df.write_iceberg(table, mode="overwrite", overwrite_filter="dt = '2024-01-01'")
df.write_iceberg(table, mode="overwrite", overwrite_filter=col("dt") == "2024-01-01")
```Daft checks that the rows being written fall inside the filter, so an overwrite cannot
leave behind rows its delete did not cover:
ValueError: Cannot write rows that do not match overwrite_filter EqualTo(term=Reference(name='dt'), literal=literal('2024-01-01')): .../dt=2024-01-02/....parquet holds rows outside the filter, or its statistics are not sufficient to prove otherwise. Filter the DataFrame down to the rows the filter covers, or pass validate_overwrite_filter=False to write anyway.The check reads the written files' partition values and column statistics rather than the
rows themselves. Statistics only ever widen, since truncated bounds cover more than the real
range, so the check may refuse a write it cannot prove but never admits a row the filter does
not cover. It runs before anything commits, so a rejected write leaves the table unchanged.
Two cases are not provable: an equality predicate over a non-partition column whose values
exceed the bounds truncation length (16 bytes by default), and a predicate over a column whose
table properties disable statistics. Partition values are stored whole and a range predicate
only needs the bounds to sit inside it, so neither of those is affected. Passvalidate_overwrite_filter=False for the cases that cannot be proven.
To drop a partition without writing anything, pass an empty DataFrame with the filter:
=== "🐍 Python"
```python
empty = df.limit(0)
empty.write_iceberg(table, mode="overwrite", overwrite_filter="dt = '2024-01-01'")
```!!! warning "Warning"
A filter aligned with partition boundaries is a metadata-only delete. A finer predicate
makes PyIceberg fall back to copy-on-write and rewrite the partially matched data files.The same option is available through the Catalog API:
=== "🐍 Python"
```python
catalog.write_table("default.my_table", df, mode="overwrite", overwrite_filter="dt = '2024-01-01'")
```Checkpointing
Daft supports idempotent writes to Iceberg via the checkpoint= parameter on [df.write_iceberg()][daft.DataFrame.write_iceberg]. Retries of the same logical commit — after a crash, a transient catalog error, or a deliberate re-invocation — produce the same Iceberg state without duplicate snapshots. See the Checkpointing user guide for concepts; the sections below cover Iceberg-specific behavior.
Example
The pattern: one CheckpointStore paired into both the source (via CheckpointConfig) and the sink (via IdempotentCommit). The source records which inputs were processed; the sink stamps the resulting Iceberg snapshot with the idempotence key.
=== "🐍 Python"
```python
import daft
# One store, paired into both the source and the sink.
store = daft.CheckpointStore("s3://my-bucket/ckpt/")
df = daft.read_parquet(
"s3://input/",
checkpoint=daft.CheckpointConfig(store, on="file_id"),
)
# Any map-only operations work here: filter, project, UDF, explode, ...
df = df.where(df["status"] == "active")
written = df.write_iceberg(
table,
checkpoint=daft.IdempotentCommit(store, idempotence_key="job-2026-05-21-001"),
)
```A fresh run produces one new Iceberg snapshot tagged with daft.idempotence-key=job-2026-05-21-001. Retries with the same idempotence_key recognize the prior commit and exit cleanly — no duplicate snapshot, no reprocessing of inputs already handled.
Inspecting the Marker
Every idempotent commit tags its Iceberg snapshot summary with daft.idempotence-key. To verify which logical commit produced the current state of a table:
=== "🐍 Python"
```python
# Inspect via pyiceberg's Table API — `table` is the same pyiceberg.table.Table
# loaded in the "Reading a Table" section above.
summary = table.current_snapshot().summary
print(summary.get("daft.idempotence-key"))
# → "job-2026-05-21-001"
```For older commits, walk table.metadata.snapshots (order isn't guaranteed — sort by timestamp_ms if you need chronological order) and check each snapshot's summary.
Constraints
These are constraints specific to the Iceberg connector. Daft-wide constraints (Ray runner, map-only pipelines, single-writer concurrency, etc.) are in the user guide's Limitations section.
mode='append'only.mode='overwrite'withcheckpoint=raisesNotImplementedError.- Reserved
daft.idempotence-*property prefix. Keys insnapshot_propertiesthat start with this prefix raiseValueError— Daft uses this namespace internally.
Idempotence-Key Contract
The user picks the idempotence_key. Daft uses it as the marker that identifies a logical commit. The key must be stable across retries and unique across distinct logical commits — both halves matter:
- Same key, different inputs → silent no-op (data loss). Daft sees the existing marker and skips the commit. The new data is dropped without an error.
- Different key on a retry of the same logical commit → duplicate snapshot. Daft doesn't recognize the prior attempt and lands a second snapshot.
Recovery
The user guide's Recovery section walks through the full chronology. The Iceberg-specific steps:
Commit-from-store. When a run crashes after the pipeline finishes but before the snapshot commits, Daft reads the staged file references from the store on rerun and commits them as a single new Iceberg snapshot tagged with
daft.idempotence-key.Marker recognition. When a run crashes after the snapshot commits but before the store is marked done — or when the user deliberately re-runs with the same key — Daft walks
table.metadata.snapshots, finds the marker, marks the store, and exits. No second snapshot. Returned DataFrame is empty.
Orphan files on crash. A worker that crashed mid-task may leave parquet files unreferenced by any snapshot. Daft doesn't auto-clean these — use your Iceberg engine's orphan-file cleanup procedures.
Iceberg-Specific Notes
In-memory history walk. Marker recognition scans
table.metadata.snapshotsdirectly — fast even on tables with many snapshots, since the metadata is already in memory after refresh.commit.manifest-merge.enabledis honored. If the table is configured for manifest merging, Daft usesmerge_append; otherwisefast_append. Same behavior as a non-checkpoint write.Partitioned tables include a
partitioningstruct column in the result. Whethercheckpoint=is set or not — callers don't see schema drift when they toggle the flag.Transient retry on
CommitFailedException. Daft retries up to twice on this exception (concurrent-writer conflicts, lock contention). Other exceptions — REST 5xx, network errors, auth — propagate immediately. Wrap the call in your own retry policy if you need broader coverage.
Type System
| Iceberg | Daft |
|---|---|
BOOLEAN |
[daft.DataType.bool()][daft.datatype.DataType.bool] |
INT |
[daft.DataType.int32()][daft.datatype.DataType.int32] |
LONG |
[daft.DataType.int64()][daft.datatype.DataType.int64] |
FLOAT |
[daft.DataType.float32()][daft.datatype.DataType.float32] |
DOUBLE |
[daft.DataType.float64()][daft.datatype.DataType.float64] |
DECIMAL(precision, scale) |
[daft.DataType.decimal128(precision, scale)][daft.datatype.DataType.decimal128] |
DATE |
[daft.DataType.date()][daft.datatype.DataType.date] |
TIME |
[daft.DataType.int64()][daft.datatype.DataType.int64] |
TIMESTAMP |
[daft.DataType.timestamp(timeunit="us", timezone=None)][daft.datatype.DataType.timestamp] |
TIMESTAMPZ |
[daft.DataType.timestamp(timeunit="us", timezone="UTC")][daft.datatype.DataType.timestamp] |
STRING |
[daft.DataType.string()][daft.datatype.DataType.string] |
UUID |
[daft.DataType.binary()][daft.datatype.DataType.binary] |
FIXED(size) |
[daft.DataType.fixed_size_binary(size)][daft.datatype.DataType.fixed_size_binary] |
BINARY |
[daft.DataType.binary()][daft.datatype.DataType.binary] |
STRUCT<[field_name: field_type,]> |
[daft.DataType.struct(fields)][daft.datatype.DataType.struct] |
LIST<element_type> |
[daft.DataType.list(element_type)][daft.datatype.DataType.list] |
MAP<key_type, value_type> |
[daft.DataType.map(key_type, value_type)][daft.datatype.DataType.map] |
See also Iceberg Schemas and Data Types.
Reference
Daft has high-level Session and Catalog APIs
to read and write Iceberg tables; however it is the [daft.read_iceberg][daft.read_iceberg] and
[df.write_iceberg][daft.DataFrame.write_iceberg] API which is ultimately the entry-point to Iceberg reads and
writes respectively. This section gives a short reference on those APIs and how
they relate to both DataFrames and Iceberg.
Daft's DataFrames are an abstraction over relational algebra operators like
filter, project, and join. DataFrames start with a data source and are built
upwards, via composition with additional operators, to form a tree with sources
as the leaves. We typically call these leaves tables or sources and their
algebraic operator is called a scan.
[`read_iceberg`][daft.read_iceberg]
Daft's [daft.read_iceberg][daft.read_iceberg] method creates a DataFrame from the given PyIceberg
table. It produces rows by traversing the table's metadata tree to locate all
the data files for the given snapshot which is handled by ourIcebergDataSource.
Daft's IcebergDataSource initializes itself by fetching the latest schema,
or the schema of the given snapshot, along with setting up the partition key
metadata. The data source's primary method, get_tasks, accepts pushdowns
(projections, predicates, partition filters) and yields an iterator of
tasks. Each task holds a data file, optional delete files,
and the associated pushdowns. Finally, we read each data file's parquet to
produce a stream of record batches which later operators consume and transform.
[`write_iceberg`][daft.DataFrame.write_iceberg]
Daft's [write_iceberg][daft.DataFrame.write_iceberg] method writes the DataFrame's contents to the given PyIceberg table.
It works by creating a special sink operator which consumes all inputs and writes
data files to the table's location.
Daft's sink operator will apply the Iceberg partition transform and distribute
records to an appropriate data file writer. Each writer is responsible for
actually writing the parquet to storage and keeping track of metadata like total
bytes written. Once the sink has exhausted its input, it will close all open
writers.
Finally, we update the Iceberg table's metadata to include these new data files,
and use a transaction to update the latest metadata pointer.
Iceberg Architecture
!!! note "Note"
Please see the
[Iceberg Table Specification](https://iceberg.apache.org/spec/) for full
details.Iceberg tables are a tree where metadata files are the inner nodes, and data
files are the leaves. The data files are typically parquet, but are not
necessarily so. To write data, the new data files are inserted into the tree
by creating the necessary inner nodes (metadata files) and re-rooting the
parent. To read data, we choose a root (table version) and use the metadata
files to collect all relevant data files (their children).
The data layer is composed of data files and delete files; where as the
metadata layer is composed of manifest files, manifest lists, and metadata
files.
Manifest Files
Manifest files (avro) keep track of data files, delete files, and statistics.
These lists track the leaves of the iceberg tree. While all manifest files use
the same schema, a manifest file contains either exclusively data files or
exclusively delete files.
Manifest List
Manifests lists (snapshots) contain all manifest file locations along with their
partitions, and partition columns upper and lower bounds. Each entry in the
manifest list has the form,
| Field | Description |
|---|---|
manifest_path |
location |
manifest_length |
file length in bytes |
content |
flag where 0=data and 1=deletes |
partitions |
array of field summaries e.g. nullable, upper, lower |
These are not all of the fields, but gives us an idea of what a manifest
list looks like.
Metadata Files
Metadata files store the table schema, partition information, and a list of all
snapshots including which one is the current. Each time an Iceberg table is
modified, a new metadata file is created; this is what is meant earlier by
"re-rooting" the tree. The catalog is responsible for atomically updating the
current metadata file.
Puffin Files
Puffin files store arbitrary metadata as blobs along with the necessary metadata
to use these blobs.
Table Writes
Iceberg can efficiently insert (append) and update rows. To insert data, the new
data files (parquet) are written to object storage along with a new manifest
file. Then a new manifest list, along with all existing manifest files, is added
to the new metadata file. The catalog then marks this new metadata file as the
current one.
An upsert / merge into query is a bit more complicated because its action is
conditional. If a record already exists, then it is updated. Otherwise, a new
record is inserted. This write operation is accomplished by reading all matched
records into memory, then updating each match by either copying with updates
applied (COW) or deleting (MOR) then including the updated in the insert. All
records which were not matched (did not exist) are inserted like a normal
insert.
Table Reads
Iceberg reads begin by fetching the latest metadata file to then locate the
"current snapshot id". The current snapshot is a manifest list which has the
relevant manifest files which ultimately gives us a list of all data files
pertaining to the query. For each metadata layer, we can leverage the statistics
to prune both manifest files and data files.
Other
COW vs. MOR
When data is modified or deleted, we can either rewrite the relevant portion
(copy-on-write) or save the modifications for later reconciliation
(merge-on-read). Copy-on-write optimizes for read performance; however,
modifications are more expensive. Merge-on-read optimizes for write performance;
however, reads are more expensive.
Delete File
Delete files are used for the merge-on-read strategy in which tables updates are
written to a "delete file" which is applied or "merged" with the data files
while reading. A delete file can contain either positional or equality
deletes. Positional deletes denote which rows (filepath+row) have been deleted,
whereas equality deletes have an equality condition i.e. WHERE x = 100 to
filter rows.
Partitioning
When a table is partitioned on some field, Iceberg will write separate data files
for each record, grouped by the partitioned field's value. That is, each data file
will contain records for a single partition. This enables efficient scanning of
a partition because all other data files can be ignored. You may partition by a
column's value (identity) or use a partition transform to derive a partition value.
[Apache Iceberg Partition Transforms](https://iceberg.apache.org/spec/#partition-transforms)
| Transform | Description |
|---|---|
identity |
Column value unmodified. |
bucket(n) |
Hash of value, mod n. |
truncate(w) |
Truncated value, width w. |
year |
Timestamp year value. |
month |
Timestamp month value. |
day |
Timestamp day value. |
hour |
Timestamp hour value. |
FAQs
How does Daft read Iceberg tables?
Daft reads Iceberg tables by reading a snapshot's data files into its arrow-based record batches. For more detail, please see the
read_icebergreference.How does Daft write Iceberg tables?
Daft writes Iceberg tables by writing the new, possibly partitioned, data files to storage then atomically committing an updated Iceberg metadata file. For more detail, please see
write_iceberg.How do Daft's data types compare to Iceberg's data types?
The type systems are quite similar because they are both based around Apache Arrow's types. Please see our comprehensive type comparison table.
Does Daft support Iceberg REST catalog implementations?
Yes! Daft uses PyIceberg to interface with Iceberg catalogs which has extensive Iceberg REST catalog support.
How does Daft handle positional deletes vs equality deletes?
Daft currently only supports positional deletes, but V2 equality deletes are on the roadmap.
Can Daft leverage Iceberg's metadata for predicate pushdown?
Yes, uses min/max statistics from manifest files for partition pruning.
Does Daft support time travel queries?
Daft supports reading by snapshot id, and snapshot slices are on the roadmap.
Which complex data types does Daft support in Iceberg tables?
Daft supports arrays, structs, and maps.
How does Daft handle schema evolution?
Daft currently does not expose any data definition operators beyond [create_table][daft.session.Session.create_table].
Does Daft support reading table metadata like snapshot information?
Daft does not have native APIs for this, and we recommend using PyIceberg for reading metadata.
How does Daft handle Iceberg's hidden partitioning?
Transparently leverages partition information without user specification.
Can Daft read and write partition transforms like truncate, bucket, or hour?
Daft can read and write all Iceberg partition transform types: identity, bucket, date transforms (year/month/day), and truncate.
How does Daft optimize queries against partitioned data?
Daft will shuffle partitioned data when possible and based upon your execution environment.
Which writes operations does Daft support?
Daft supports append, full overwrite, and static partition overwrite (
overwrite_filter); it does not support upserts like copy-on-write updates.
Connectors Kafka (`docs/connectors/kafka.md`)
Reading from Kafka
!!! warning "Experimental"
This connector is experimental and the API may change in future releases. Current limitations:
- **Bounded batch reads only** — no streaming or unbounded mode
- **No offset commit management** — consumer group offsets are not committed
- **No write support** — Daft cannot produce messages to KafkaApache Kafka is a distributed event streaming platform. Daft can read bounded ranges of messages from Kafka topics into DataFrames using [daft.read_kafka()][daft.io.read_kafka].
Daft currently supports:
- Bounded batch reads: Read a fixed range of messages defined by offsets, timestamps, or earliest/latest markers
- Multi-topic reads: Read from one or more topics in a single call
- Partition filtering: Read from specific partitions within a topic
- Flexible bounds: Specify start/end using
"earliest"/"latest", timestamps (datetime, ISO-8601, or epoch ms), or explicit per-partition offset maps - Limit pushdown: Use
.limit(N)to stop reading early once enough rows are collected
Installing Daft with Kafka Support
Daft integrates with Kafka through the confluent-kafka package:
pip install -U "daft[kafka]"Or install the dependency manually:
pip install confluent-kafkaOutput Schema
Every read_kafka call returns a DataFrame with the following fixed schema:
| Column | Type | Description |
|---|---|---|
topic |
string |
The topic the message was read from |
partition |
int32 |
The partition ID within the topic |
offset |
int64 |
The offset of the message within the partition |
timestamp_ms |
int64 |
Message timestamp in milliseconds (null if unavailable) |
key |
binary |
The message key as raw bytes (null if not present) |
value |
binary |
The message value as raw bytes |
Reading Messages
Basic Usage
Read all messages from a topic (earliest to latest):
=== "🐍 Python"
```python
import daft
df = daft.read_kafka(bootstrap_servers="localhost:9092", topics="my-topic")
df.show()
```Timestamp Bounds
Read messages within a time range:
=== "🐍 Python"
```python
import daft
from datetime import datetime, timezone
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 2, tzinfo=timezone.utc)
df = daft.read_kafka(
bootstrap_servers="localhost:9092",
topics="my-topic",
start=start,
end=end,
)
```You can also use ISO-8601 strings or epoch milliseconds:
=== "🐍 Python"
```python
df = daft.read_kafka(
bootstrap_servers="localhost:9092",
topics="my-topic",
start="2024-01-01T00:00:00Z",
end=1704153600000, # epoch ms
)
```Offset Bounds
Read a specific offset range from a partition:
=== "🐍 Python"
```python
df = daft.read_kafka(
bootstrap_servers="localhost:9092",
topics="my-topic",
start={0: 100}, # partition 0, offset 100
end={0: 200}, # partition 0, offset 200 (exclusive)
)
```Partition Filtering
Read only from specific partitions:
=== "🐍 Python"
```python
df = daft.read_kafka(
bootstrap_servers="localhost:9092",
topics="my-topic",
partitions=[0, 2],
)
```Multiple Topics
Read from multiple topics at once:
=== "🐍 Python"
```python
df = daft.read_kafka(
bootstrap_servers="localhost:9092",
topics=["topic-a", "topic-b"],
)
```With per-topic offset maps:
=== "🐍 Python"
```python
df = daft.read_kafka(
bootstrap_servers="localhost:9092",
topics=["topic-a", "topic-b"],
start={"topic-a": {0: 100}, "topic-b": {0: 50}},
end="latest",
)
```Using Limit
Stop reading early once enough rows are collected:
=== "🐍 Python"
```python
df = daft.read_kafka(bootstrap_servers="localhost:9092", topics="my-topic").limit(1000)
df.show()
```Kafka Client Configuration
Pass additional librdkafka configuration options via kafka_client_config:
=== "🐍 Python"
```python
df = daft.read_kafka(
bootstrap_servers="localhost:9092",
topics="my-topic",
kafka_client_config={
"security.protocol": "SASL_SSL",
"sasl.mechanism": "PLAIN",
"sasl.username": "my-api-key",
"sasl.password": "my-api-secret",
},
)
```!!! note
The following keys are managed internally and cannot be overridden via `kafka_client_config`:
`bootstrap.servers`, `group.id`, `enable.auto.commit`, `enable.auto.offset.store`.
These are configured through the dedicated `bootstrap_servers` and `group_id` parameters of `read_kafka()` instead.Connectors Lance (`docs/connectors/lance.md`)
Reading from and Writing to Lance
Lance is a next-generation columnar storage format for multimodal datasets (images, video, audio, and general columnar data). It supports local POSIX filesystems and cloud object stores (e.g., S3/GCS). Lance is known for extremely fast random access, zero-copy reads, deep integration with PyArrow/DuckDB, and strong performance for vector retrieval workloads.
Daft currently supports:
Parallel and distributed reads: Daft parallelizes reads on the default multithreading runner or on the distributed Ray runner
Skipping filtered data (Data Skipping): Daft uses [
df.where()][daft.DataFrame.where] predicates and file/fragment statistics to skip non-matching dataMulti-cloud and local access: Read from S3, GCS, Azure Blob Storage, and local filesystems with unified IO configuration
Version/time-slice reads: Use
versionandasofparameters to read a specific version or the latest version as of a given timestampScan optimization: Configure
fragment_group_sizeto group fragments and improve scan efficiencyCache tuning: Configure
index_cache_sizeandmetadata_cache_size_bytesto optimize index page caching and metadata retrieval for large datasets
Installing Daft with Lance Support
Daft integrates Lance through an optional dependency:
pip install -U "daft[lance]"Reading a Table
Use [daft.read_lance][daft.read_lance] to read a Lance dataset from a local path or a cloud object store URI.
=== "🐍 Python"
```python
# Read a Lance dataset from local or object storage
import daft
# Local path example
df_local = daft.read_lance("/data/my_lance_dataset")
# Read a specific version or a time slice
df_v1 = daft.read_lance("/data/my_lance_dataset", version=1)
```To access public S3/GCS buckets, configure IO options for authentication and endpoints:
=== "🐍 Python"
```python
import daft
from daft.io import IOConfig, S3Config, GCSConfig
# Public S3 example (anonymous access)
s3_config = S3Config(region_name="us-west-2", anonymous=True)
gcs_config = GCSConfig(credentials="/path/to/gcp-service-account.json")
io_config = IOConfig(s3=s3_config, gcs=gcs_config)
df_s3 = daft.read_lance("s3://daft-oss-public-data/lance/words-test-dataset", io_config=io_config)
# GCS example (service account credentials)
df_gcs = daft.read_lance("gs://my-bucket/lance/my-dataset", io_config=io_config)
```For S3-compatible services (e.g. Volcengine TOS), configure IO options for authentication and endpoints:
=== "🐍 Python"
```python
import daft
from daft.io import IOConfig, S3Config
region = "cn-beijing" # Region of your TOS bucket
bucket_name = "my-tos-bucket" # Name of your TOS bucket
io_config = IOConfig(
s3=S3Config(
endpoint_url=f"https://tos-s3-{region}.ivolces.com",
region_name=region,
force_virtual_addressing=True,
verify_ssl=True,
key_id="your-access-key-id",
access_key="your-secret-access-key",
)
)
df = daft.read_lance(f"s3://{bucket_name}/lance-path", io_config=io_config)
```For datasets with many fragments, group fragments to reduce scheduling and metadata overhead:
=== "🐍 Python"
```python
# Process multiple fragments in a single scan task
df_grouped = daft.read_lance("/data/my_lance_dataset", fragment_group_size=8)
```Filter operations on the Daft df DataFrame object will be pushed down to the Lance data source for efficient data skipping.
=== "🐍 Python"
# Enable strict filter pushdown for more efficient data skipping
daft.context.set_planning_config(enable_strict_filter_pushdown=True)
filtered = df_local.where(df_local["score"] >= 0.8)
filtered.show()Filtering with Custom SQL Expressions
Daft supports passing raw SQL filter strings directly to the Lance scanner via default_scan_options. This allows you to leverage Lance's native SQL capabilities, including GeoSpatial functions, which might not be directly expressible in Daft's DataFrame API yet.
You can also use this mechanism to perform SQL-based projections (calculations) during the scan.
=== "🐍 Python"
```python
import daft
# Example: Using GeoSpatial functions for filtering and projection
# Assume we have a dataset with 'point' and 'linestring' columns (GeoArrow types)
df = daft.read_lance(
"/path/to/geo/dataset",
default_scan_options={
# Calculate distance during scan and project it as a new column
"columns": {"distance": "st_distance(point, linestring)"},
# Filter rows where linestring intersects with a specific geometry
"filter": "st_intersects(linestring, st_geomfromtext('LINESTRING ( 2 0, 0 2 )'))",
"with_row_id": True
}
)
df.show()
```Writing to Lance
Use [df.write_lance()][daft.dataframe.DataFrame.write_lance] to write a DataFrame to a Lance dataset. Supported modes include create, append, overwrite, and insert_overwrite.
=== "🐍 Python"
import daft
df = daft.from_pydict({"a": [1, 2, 3, 4]})
meta = df.write_lance("/tmp/lance/my_table.lance")
meta.show() # Contains metadata such as num_fragments / num_deleted_rows / num_small_files / version
# Overwrite existing table with extra parameters (forwarded to the Lance writer)
meta2 = df.write_lance("/tmp/lance/my_table.lance", mode="overwrite", max_bytes_per_file=1024)
meta2.show()
# Append rows (must be compatible with the existing table schema)
meta3 = df.write_lance("/tmp/lance/my_table.lance", mode="append")Conditional overwrite
insert_overwrite removes rows matching a Lance SQL predicate and adds the input
rows in one commit. The target table must already exist; it is not compatible
with use_mem_wal=True.
replacement = daft.from_pydict({"day": ["2026-09-15"], "value": [42]})
replacement.write_lance(
"/tmp/lance/events.lance",
mode="insert_overwrite",
overwrite_where="day = '2026-09-15'",
)Lance Namespace tables
Address a table through a Lance Namespace by supplying table_id,namespace_impl, and namespace_properties instead of a URI. Namespace
resolution and any catalog-vended storage credentials are handled bydaft-lance.
namespace = {
"namespace_impl": "dir",
"namespace_properties": {"root": "/tmp/lance_tables"},
"table_id": ["orders"],
}
daft.from_pydict({"id": [1, 2]}).write_lance(mode="create", **namespace)
orders = daft.read_lance(**namespace)URI and Namespace targets are mutually exclusive.
Migrating from `mode="merge"`
DataFrame.write_lance(mode="merge") is deprecated in v0.8.0 and will be
removed in v0.9.0. Its behavior depended on the target table and input schema;
migrate each case to an explicit operation:
# No target table: create it explicitly.
df.write_lance("/tmp/lance/my_table.lance", mode="create")
# No new columns: append rows explicitly.
df.write_lance("/tmp/lance/my_table.lance", mode="append")For a per-fragment column merge, call daft_lance.merge_columns_df directly.
Read fragment_id and the join key from Lance first. The default join key is_rowaddr; pass left_on and right_on for a business key.
from daft_lance import merge_columns_df
source = daft.read_lance(
"/tmp/lance/my_table.lance",
default_scan_options={"with_row_address": True},
include_fragment_id=True,
)
updates = (
source.select("fragment_id", "_rowaddr", "score")
.with_column("normalized_score", daft.col("score") / 100)
.select("fragment_id", "_rowaddr", "normalized_score")
)
merge_columns_df(updates, "/tmp/lance/my_table.lance")For example, to use id as the join key, callmerge_columns_df(updates, "/tmp/lance/my_table.lance", left_on="id", right_on="id") after retaining fragment_id and id in updates.
For S3-compatible services (e.g. Volcengine TOS), configure IO options for authentication and endpoints:
=== "🐍 Python"
```python
import daft
from daft.io import IOConfig, S3Config
df = daft.from_pydict({"user_id": [1, 2, 3], "score": [0.5, 0.8, 0.9]})
region = "cn-beijing" # Region of your TOS bucket
bucket_name = "my-tos-bucket" # Name of your TOS bucket
io_config = IOConfig(
s3=S3Config(
endpoint_url=f"https://tos-s3-{region}.ivolces.com",
region_name=region,
force_virtual_addressing=True,
verify_ssl=True,
key_id="your-access-key-id",
access_key="your-secret-access-key",
)
)
meta = df.write_lance(f"s3://{bucket_name}/lance/my_table.lance", io_config=io_config)
```Note: the {region} should match the region of your TOS bucket. e.g. if your bucket is in cn-beijing, you should set region_name="cn-beijing" and endpoint_url="https://tos-s3-cn-beijing.ivolces.com".
Writing with a specific schema
You can provide a pyarrow.Schema to control the on-disk Lance schema. Daft will align the DataFrame to this schema (column order, types, and nullability) before writing:
=== "🐍 Python"
```python
import daft
import pyarrow as pa
df = daft.from_pydict(
{
"id": [1, 2, 3],
"score": [0.5, 0.8, 0.9],
}
)
target_schema = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("score", pa.float32(), nullable=True),
]
)
meta = df.write_lance("/tmp/lance/typed_table.lance", schema=target_schema)
```This ensures that the resulting Lance table uses the exact schema you specify, even if the in-memory DataFrame has compatible but different types or column ordering.
!!! note "Write Schema Control"
- If `schema` is not provided, Daft uses the current DataFrame schema.
- If a `pyarrow.Schema` is provided, data will be aligned to that schema before writing (type/order/nullability).
- If the target dataset already exists and the write is not an overwrite, data is converted to the existing table schema for compatibility.Advanced Usage
Vector Search
Daft can push Lance's vector search options through default_scan_options. This lets
you express nearest-neighbor queries at scan time while keeping the rest of your
pipeline in Daft.
=== "🐍 Python"
```python
import daft
import pyarrow as pa
# Example: dataset with a fixed-size list or embedding column named "vector"
query = pa.array([0.0, 0.0], type=pa.float32())
df = daft.read_lance(
"/data/my_lance_vectors",
# Forward Lance's `nearest` options into the underlying scanner
default_scan_options={
"nearest": {
"column": "vector", # Lance vector column name
"q": query, # Query vector (any Lance-compatible QueryVectorLike)
"k": 5, # Top-K neighbors to return
},
},
)
# The resulting DataFrame contains the K nearest rows according to Lance,
# and you can continue working with it using normal Daft APIs.
df.select("vector").show()
```Data Evolution
If you need to add derived columns in-place to an existing Lance dataset (e.g., apply a UDF across batches and persist the result), use daft_lance.merge_columns from the daft-lance package (installed as part of the daft[lance] extra):
=== "🐍 Python"
from daft_lance import merge_columns
# Example: double the values in column c and write to a new column new_column
import pyarrow.compute as pc
def double_score(batch):
return batch.append_column("new_column", pc.multiply(batch["c"], 2))
merge_columns(
"/tmp/lance/my_table.lance",
transform=double_score,
read_columns=["c"],
)Compaction
Compaction is the process of rewriting a Lance dataset to optimize its structure for query performance. This operation can:
- Merge small files: Combine multiple small data fragments into fewer, larger fragments to reduce metadata overhead and improve read throughput.
- Materialize deletions: Physically remove rows that have been marked for deletion, which can reduce storage and accelerate scans.
- Improve data layout: Reorganize data to improve compression and read efficiency.
Use compaction when a dataset has undergone many small appends, has a high number of deleted rows, or contains a large number of small files.
Daft provides a distributed implementation of compaction through daft_lance.compact_files (from the daft-lance package, installed as part of the daft[lance] extra).
from daft_lance import compact_files
compact_files(
uri,
compaction_options=None,
io_config=None,
# --- other options ---
index_cache_size=None,
block_size=None,
default_scan_options=None,
metadata_cache_size_bytes=None,
partition_num=None,
)uri: Path to the Lance dataset.compaction_options: A dictionary of options to control compaction behavior (e.g.,target_rows_per_fragment,materialize_deletions), see Lance documentation for more details.- Returns: A
CompactionMetricsobject with statistics if compaction was performed, orNoneif no action was needed. TheCompactionMetricsobject contains the following fields:fragments_removed: Number of fragments removed during compaction.fragments_added: Number of fragments added during compaction.files_removed: Number of files removed during compaction.files_added: Number of files added during compaction.
partition_num: On the Ray Runner, this controls the number of parallel compaction tasks. Defaults to 1. On the native runner, this option is ignored.
This example compacts a dataset with multiple fragments into a single, larger fragment, and uses partition_num to control the number of parallel compaction tasks.
=== "🐍 Python"
```python
import daft
import lance
import pandas as pd
import pyarrow as pa
from daft_lance import compact_files
daft.set_runner_ray()
# Create a dataset and delete some rows
data = pa.table({"a": range(800), "b": range(800)})
dataset_path = "/tmp/dataset.lance"
dataset = lance.write_dataset(data, dataset_path, max_rows_per_file=200)
# The initial row count is 800, and the initial fragment count is 4
print(f"Initial row count: {dataset.count_rows()}, and initial fragment count: {len(dataset.get_fragments())}")
metrics = compact_files(
dataset_path,
compaction_options={
"target_rows_per_fragment": 400,
},
partition_num=2,
)
dataset = lance.dataset(dataset_path)
# The final row count is 800, and the final fragment count is 2
print(f"Final row count: {dataset.count_rows()}, and final fragment count: {len(dataset.get_fragments())}")
```Connectors Mcap (`docs/connectors/mcap.md`)
Reading MCAP Files
MCAP is an open-source container file format for multimodal log data, commonly used in robotics and autonomous systems. Daft can read MCAP files using [daft.read_mcap()][daft.io.read_mcap].
Basic Usage
=== "Local File"
```python
import daft
df = daft.read_mcap("/path/to/recording.mcap")
df.show()
```=== "Remote File (S3)"
```python
import daft
from daft.io import IOConfig, S3Config
io_config = IOConfig(s3=S3Config(region_name="us-west-2"))
df = daft.read_mcap("s3://bucket/recordings/data.mcap", io_config=io_config)
df.show()
```=== "Remote File (HTTP/HTTPS)"
```python
import daft
df = daft.read_mcap("https://example.com/recordings/session.mcap")
df.show()
```=== "Directory of Files"
```python
import daft
# Read all MCAP files in a directory
df = daft.read_mcap("/recordings/")
df.show()
```Output Schema
output_format controls the column types returned by read_mcap. The default, output_format="raw", returns:
| Column | Type | Description |
|---|---|---|
source_path |
string |
Path of the MCAP file containing the message |
topic |
string |
The topic name the message was published on |
log_time |
uint64 |
Timestamp when the message was logged (nanoseconds) |
publish_time |
uint64 |
Timestamp when the message was published (nanoseconds) |
sequence |
uint32 |
Sequence number of the message |
data |
binary |
Raw message payload |
output_format="raw_legacy" matches the legacy (before v0.9.0) implementation:
| Column | Type | Description |
|---|---|---|
source_path |
string |
Path of the MCAP file containing the message |
topic |
string |
The topic name the message was published on |
log_time |
int64 |
Timestamp when the message was logged (nanoseconds) |
publish_time |
int64 |
Timestamp when the message was published (nanoseconds) |
sequence |
int32 |
Sequence number of the message |
data |
string |
Message payload |
!!! warning "Deprecated output_format='raw_legacy'"
`output_format="raw_legacy"` is deprecated and will be removed in v0.9.0. Update your script to use the currently emitted column types.Filtering Options
Time Range
Filter messages by time range. The time unit matches the MCAP log_time field (typically nanoseconds):
df = daft.read_mcap(
"/path/to/recording.mcap",
start_time=1609459200000000000, # 2021-01-01 UTC in nanoseconds
end_time=1609545600000000000, # 2021-01-02 UTC in nanoseconds
)
df.show()!!! warning "Unsigned timestamp bounds"
Ensure that `start_time`, `end_time`, and times returned by `topic_start_time_resolver` must be between
`0` and `2**64 - 1` to match the internal MCAP timestamp format. Negative time values raise `OverflowError`.Topic Filtering
Read only specific topics:
df = daft.read_mcap(
"/path/to/recording.mcap",
topics=["/camera/image", "/lidar/points"],
)
df.show()Batch Size
Control memory usage by adjusting the batch size:
df = daft.read_mcap(
"/path/to/recording.mcap",
batch_size=500, # Number of messages per batch (default: 1000)
)Advanced: Topic Start Time Resolver
For advanced use cases, you can provide a callable that computes per-file, per-topic start times. This is useful for resuming reads from specific positions:
def get_topic_start_times(file_path: str) -> dict[str, int]:
"""Return a mapping of topic names to start times."""
# Your logic to determine start times per topic
return {
"/camera/image": 1609459200000000000,
"/lidar/points": 1609459200500000000,
}
df = daft.read_mcap(
"/path/to/recording.mcap",
topic_start_time_resolver=get_topic_start_times,
)When a resolver is provided:
- One scan task is created per (file, topic) combination
- The task's start_time is set to
max(start_time, resolver(file)[topic])
Use Cases
Robotics Data Processing
import daft
from daft import col
# Read sensor data
df = daft.read_mcap("/recordings/*.mcap", topics=["/imu/data"])
# Filter and process
recent_data = df.where(col("log_time") > 1609459200000000000)
recent_data.show()Multi-Topic Analysis
import daft
from daft import col
# Read multiple topics
df = daft.read_mcap(
"/recordings/session.mcap",
topics=["/camera/image", "/gps/fix", "/vehicle/speed"],
)
# Group by topic
topic_counts = df.groupby("topic").count()
topic_counts.show()Connectors Orc (`docs/connectors/orc.md`)
ORC Files
Daft reads ORC files from local storage and remote URLs using [daft.read_orc][daft.read_orc]. This can be used to process raw ORC files produced by tools such as Apache Spark and Hive.
Read ORC Files
import daft
df = daft.read_orc("/path/to/file.orc")
df = daft.read_orc("/path/to/directory")
df = daft.read_orc("/path/to/files-*.orc")
df = daft.read_orc(["/path/to/first.orc", "/path/to/second.orc"])Directories are searched recursively for files matching *.orc. An explicit file path can have a different extension. Overlapping paths and glob patterns are deduplicated. Empty inputs, unmatched paths, unreadable files, and corrupt ORC files raise errors.
Paths follow Daft's native glob syntax, including *, ?, [...], and {...}. A path containing unescaped glob metacharacters is treated as a pattern even when a file with that name exists. For example, a[1].orc matches a1.orc; use a[[]1[]].orc to match the literal filename a[1].orc.
Schema inference occurs when the DataFrame is created. Rows are read when an action such as collect, show, or write_parquet executes. Each file is processed by one task, so multiple files can be read in parallel. Stripes within one file are not scheduled as separate distributed tasks.
Remote Storage
Use the same [IOConfig][daft.io.IOConfig] as other Daft file readers. If io_config is omitted, the reader uses the planning context's default configuration.
from daft.io import IOConfig, S3Config
io_config = IOConfig(s3=S3Config(region_name="us-east-1", anonymous=True))
df = daft.read_orc("s3://my-public-bucket/data/*.orc", io_config=io_config)Query and Batch Size
Select columns and filter rows using the DataFrame API:
df = daft.read_orc("/path/to/data/*.orc", batch_size=65536)
result = df.where(daft.col("score") > 0.5).select("id", "label").limit(100)Column projection reduces the fields requested from the reader. Row filters and limits preserve Daft query semantics; ORC-native predicate pruning and footer-based aggregation are not supported.
limit() restricts the returned rows but does not guarantee early termination of file reads. The shared Python source bridge can continue reading and buffering batches after the returned-row limit is reached, so even .limit(1) can read beyond the first batch and retain multiple batches in memory.
batch_size controls the maximum number of rows in each emitted batch and defaults to 131072. It does not set a memory limit: field sizes, decoder buffers, and queued batches also affect memory usage.
Schema and Compatibility
The schema is inferred from the first matched file and remains fixed for that DataFrame. Later files are aligned to this schema: missing fields become nulls, extra fields are excluded, and fields are cast using Daft's conversion rules, as with Parquet reads. Unsupported type conversions raise an error; some value conversions, such as an invalid numeric string, can produce null. Files with zero rows and a valid schema are supported.
The reader uses the ORC types supported by PyArrow and Daft's Arrow conversion, including numeric values, strings, binary values, dates, timestamps, decimals, lists, structs, and maps. It does not provide full schema merging or an explicit schema override.
This API reads raw ORC files. It does not interpret Hive ACID layouts or table transactions, and does not add ORC support to read_iceberg or provide an ORC writer.
Connectors Paimon (`docs/connectors/paimon.md`)
Reading from and Writing to Apache Paimon
Apache Paimon is an open-source lakehouse storage format designed for high-throughput streaming and batch analytics. It supports ACID transactions, primary-key tables with upserts (via an LSM-tree merge engine), append-only tables, and flexible partition strategies — making it a popular choice on top of object stores like HDFS, OSS, and S3.
Daft integrates with Paimon through pypaimon, the official Apache Paimon Python SDK.
Daft currently supports:
- Distributed Reads: Daft distributes I/O across all available compute resources (Ray workers or local threads).
- Predicate & Partition Pushdown:
df.where()filter expressions are pushed down to Paimon's scan planner for partition pruning and file-level skipping. - Column Projection: Only the requested columns are read from disk.
- Append-only and Primary-Key Tables: Both table types are supported; append-only tables use Daft's native high-performance Parquet reader, while PK tables that require LSM merge fall back to pypaimon's built-in reader.
- Catalog Abstraction: Paimon catalogs integrate with Daft's unified
Catalog/Tableinterfaces, enabling SQL queries anddaft.read_table()access.
Installation
pip install daft
pip install pypaimonFor S3 / S3-compatible storage (e.g. MinIO, Ceph), also install the AWS extra:
pip install "daft[aws]"OSS (Alibaba Cloud Object Storage) is supported via Daft's built-in OpenDAL backend — no extra is required beyond daft and pypaimon.
Tutorial
Reading a Table
Use [daft.read_paimon][daft.read_paimon] to create a DataFrame from a Paimon table. First, obtain a table object through pypaimon:
=== "Python"
```python
import pypaimon
catalog = pypaimon.CatalogFactory.create({"warehouse": "/path/to/warehouse"})
table = catalog.get_table("my_db.my_table")
```Then create a DataFrame:
=== "Python"
```python
import daft
df = daft.read_paimon(table)
df.show()
```Filter operations are pushed down to Paimon's scan planner for efficient partition pruning:
=== "Python"
python # Partition pruning — only reads the matching partition files df = df.where(df["dt"] == "2024-01-01") df.show()
For tables on object stores, pass an IOConfig to supply credentials:
=== "Python"
```python
from daft.io import IOConfig, S3Config
catalog = pypaimon.CatalogFactory.create({
"warehouse": "s3://my-bucket/warehouse",
})
table = catalog.get_table("my_db.my_table")
io_config = IOConfig(
s3=S3Config(
key_id="...",
access_key="...",
region_name="us-west-2",
)
)
df = daft.read_paimon(table, io_config=io_config)
df.show()
```Writing to a Table
Use [df.write_paimon()][daft.DataFrame.write_paimon] to write a DataFrame back to Paimon. Two modes are supported:
Append
=== "Python"
python result = df.write_paimon(table, mode="append") result.show()
The returned DataFrame summarises the written files:
╭───────────┬───────┬───────────┬────────────────────────────────╮
│ operation ┆ rows ┆ file_size ┆ file_name │
│ --- ┆ --- ┆ --- ┆ --- │
│ Utf8 ┆ Int64 ┆ Int64 ┆ Utf8 │
╞═══════════╪═══════╪═══════════╪════════════════════════════════╡
│ APPEND ┆ 1000 ┆ 48291 ┆ data-00001.parquet │
╰───────────┴───────┴───────────┴────────────────────────────────╯Overwrite
=== "Python"
python result = df.write_paimon(table, mode="overwrite") result.show()
Using the Catalog Abstraction
Daft's Catalog and Table interfaces let you access Paimon tables through the same API used by Iceberg, Unity, Glue, and other built-in integrations.
Creating a Catalog
=== "Python"
```python
import pypaimon
from daft.catalog import Catalog
inner = pypaimon.CatalogFactory.create({"warehouse": "/path/to/warehouse"})
catalog = Catalog.from_paimon(inner, name="my_paimon")
```You can also wrap a single pypaimon table directly:
=== "Python"
```python
from daft.catalog import Table
inner_table = inner.get_table("my_db.my_table")
table = Table.from_paimon(inner_table)
```Browsing the Catalog
=== "Python"
```python
# List all namespaces (databases)
catalog.list_namespaces()
# [Identifier('my_db'), ...]
# List all tables
catalog.list_tables()
# [Identifier('my_db', 'my_table'), ...]
# Check existence
catalog.has_namespace("my_db") # True
catalog.has_table("my_db.my_table") # True
```Reading and Writing Through the Catalog
=== "Python"
```python
# Read into a DataFrame
df = catalog.read_table("my_db.my_table")
df.show()
# Append data
catalog.write_table("my_db.my_table", df, mode="append")
# Or use the Table object directly
table = catalog.get_table("my_db.my_table")
table.append(df)
table.overwrite(df)
```Creating Tables
=== "Python"
```python
import daft
from daft.io.partitioning import PartitionField
# Infer schema from an existing DataFrame
schema = daft.from_pydict({"id": [1], "name": ["a"], "dt": ["2024-01-01"]}).schema()
# Provide partition fields (identity transform — Paimon's native model)
dt_field = schema["dt"]
partition_fields = [PartitionField.create(dt_field)]
table = catalog.create_table(
"my_db.new_table",
schema,
partition_fields=partition_fields,
)
```To create a primary-key table, pass primary_keys in the properties dict:
=== "Python"
python table = catalog.create_table( "my_db.pk_table", schema, properties={"primary_keys": ["id", "dt"]}, partition_fields=partition_fields, )
Session and SQL Integration
Once attached to a Session, your Paimon catalog is available from SQL queries and daft.read_table():
=== "Python"
```python
from daft.session import Session
sess = Session()
sess.attach(catalog, "my_paimon")
sess.set_namespace("my_db")
# SQL query resolves through the Paimon catalog
sess.sql("SELECT id, name FROM my_table WHERE dt = '2024-01-01'").show()
# Fully-qualified read
sess.read_table("my_paimon.my_db.my_table").show()
```Type System
Paimon types are mapped through PyArrow to Daft types:
| Paimon | Daft |
|---|---|
BOOLEAN |
daft.DataType.bool() |
TINYINT |
daft.DataType.int8() |
SMALLINT |
daft.DataType.int16() |
INT |
daft.DataType.int32() |
BIGINT |
daft.DataType.int64() |
FLOAT |
daft.DataType.float32() |
DOUBLE |
daft.DataType.float64() |
DECIMAL(precision, scale) |
daft.DataType.decimal128(precision, scale) |
DATE |
daft.DataType.date() |
TIME(precision) |
daft.DataType.int64() |
TIMESTAMP(precision) |
daft.DataType.timestamp(timeunit=..., timezone=None) |
TIMESTAMP_LTZ(precision) |
daft.DataType.timestamp(timeunit=..., timezone="UTC") |
CHAR(n) / VARCHAR(n) / STRING |
daft.DataType.string() |
BINARY(n) / VARBINARY(n) / BYTES |
daft.DataType.binary() |
ARRAY<element_type> |
daft.DataType.list(element_type) |
MAP<key_type, value_type> |
daft.DataType.map(key_type, value_type) |
ROW<[field_name: field_type]> |
daft.DataType.struct(fields) |
FAQs
Do I need to install pypaimon separately?
Yes. Runpip install pypaimon. pypaimon is not bundled with Daft.Which Paimon catalog types are supported?
pypaimon currently ships two catalog implementations:FileSystemCatalog(local / OSS / S3, selected bymetastore=filesystemor by default) andRESTCatalog(selected bymetastore=rest). Both can be wrapped withCatalog.from_paimon().Can I use Daft with an existing Paimon warehouse?
Yes. Create a pypaimon catalog pointing at your warehouse and pass it toCatalog.from_paimon()or usecatalog.get_table()anddaft.read_paimon()directly.Does Daft support schema evolution for Paimon tables?
Reading tables with evolved schemas is handled by pypaimon's reader. Daft does not currently expose DDL operators beyondcreate_table.How do I configure credentials for OSS or S3?
Pass anIOConfigtodaft.read_paimon(), or include the relevantfs.*options in the catalog options dict when creating the pypaimon catalog (Daft will infer anIOConfigautomatically from these).Can I use the Daft Paimon connector inside a Ray cluster?
Yes. Daft's distributed execution on Ray works with Paimon the same way it does with Iceberg — scan tasks are distributed across workers automatically.
Connectors Postgres (`docs/connectors/postgres.md`)
Reading from and Writing to PostgreSQL
PostgreSQL is an open-source object-relational database system known for its reliability, feature robustness, and performance.
Installing Daft with PostgreSQL Support
Install Daft with the daft[postgres] extra, or manually install the required packages: psycopg and pgvector for vector support.
pip install -U "daft[postgres]"Tutorial
Reading a Table
To read from PostgreSQL tables, use Daft's Catalog API. Daft provides high-level APIs for connecting to PostgreSQL databases and accessing tables as DataFrames.
To connect to a PostgreSQL database, provide a connection string to Catalog.from_postgres() and a list of PostgreSQL extensions to install. By default, the pgvector vector extension is automatically installed if available for vector support.
=== "🐍 Python"
```python
import daft
from daft.catalog import Catalog
# Connect to PostgreSQL database
# Note: The pgvector extension is enabled by default for vector support
catalog = Catalog.from_postgres("postgresql://user:password@localhost:5432/mydb", extensions=["vector"])
# List available schemas
schemas = catalog.list_namespaces()
print(schemas)
# List tables in a schema
tables = catalog.list_tables()
print(tables)
```After connecting to your database, reading a table is extremely easy:
=== "🐍 Python"
```python
# Get a table from the catalog
table = catalog.get_table("public.users")
# Read the table as a DataFrame
df = table.read()
df.show()
```Daft supports parallel reads by partitioning on a column:
=== "🐍 Python"
```python
# Read with partitioning for better performance on large tables
df = table.read(partition_col="id", num_partitions=4)
df.show()
```Writing to a Table
To write DataFrames to PostgreSQL tables, you can create tables and use the append or overwrite operations.
=== "🐍 Python"
```python
# Create a sample DataFrame
df = daft.from_pydict({
"id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Charlie", "Diana"],
"age": [25, 30, 35, 28],
"score": [95.5, 87.2, 91.8, 88.9]
})
# Create a table in PostgreSQL
table = catalog.create_table("public.scores", df.schema())
# Write data to the table
table.append(df)
```You can also overwrite existing tables:
=== "🐍 Python"
```python
# Overwrite the table with new data
new_df = daft.from_pydict({
"id": [5, 6],
"name": ["Eve", "Frank"],
"age": [32, 29],
"score": [92.1, 89.3]
})
table.overwrite(new_df)
```Type System
| Daft Type | PostgreSQL Type |
|---|---|
[daft.DataType.bool()][daft.datatype.DataType.bool] |
BOOLEAN |
[daft.DataType.int8()][daft.datatype.DataType.int8] |
SMALLINT |
[daft.DataType.int16()][daft.datatype.DataType.int16] |
SMALLINT |
[daft.DataType.int32()][daft.datatype.DataType.int32] |
INTEGER |
[daft.DataType.int64()][daft.datatype.DataType.int64] |
BIGINT |
[daft.DataType.float32()][daft.datatype.DataType.float32] |
REAL |
[daft.DataType.float64()][daft.datatype.DataType.float64] |
DOUBLE PRECISION |
[daft.DataType.decimal128(precision, scale)][daft.datatype.DataType.decimal128] |
NUMERIC |
[daft.DataType.string()][daft.datatype.DataType.string] |
TEXT |
[daft.DataType.binary()][daft.datatype.DataType.binary] |
BYTEA |
[daft.DataType.date()][daft.datatype.DataType.date] |
DATE |
[daft.DataType.timestamp(timeunit="us", timezone=None)][daft.datatype.DataType.timestamp] |
TIMESTAMP |
[daft.DataType.timestamp(timeunit="us", timezone="UTC")][daft.datatype.DataType.timestamp] |
TIMESTAMPTZ |
[daft.DataType.list(element_type)][daft.datatype.DataType.list] |
element_type[] |
[daft.DataType.struct(fields)][daft.datatype.DataType.struct] |
JSONB |
[daft.DataType.map(key_type, value_type)][daft.datatype.DataType.map] |
JSONB |
[daft.DataType.embedding(size)][daft.datatype.DataType.embedding] |
VECTOR(size) |
Reference
Daft provides high-level Session and Catalog APIs for reading and writing PostgreSQL tables. However, the [Catalog.from_postgres][daft.catalog.Catalog.from_postgres] and table operations are the primary entry points for PostgreSQL interactions.
Catalog Operations
The PostgreSQL catalog supports the full range of catalog operations:
- Namespaces (Schemas):
create_namespace(),drop_namespace(),list_namespaces(),has_namespace() - Tables:
create_table(),create_table_if_not_exists(),drop_table(),get_table(),has_table(),list_tables(),read_table(),write_table()
=== "🐍 Python"
```python
# Create a new schema
catalog.create_namespace("analytics")
# Create a table with custom properties, enabling Row Level Security (default behavior)
schema = daft.Schema.from_pydict({
"user_id": daft.DataType.int64(),
"event": daft.DataType.string(),
})
table = catalog.create_table(
"analytics.events",
schema,
properties={"enable_rls": True}
)
```Table Operations
PostgreSQL tables support read, append, and overwrite operations:
=== "🐍 Python"
```python
# Read operations
df = table.read()
df_partitioned = table.read(partition_col="id", num_partitions=4)
# Write operations
table.append(df)
table.overwrite(df)
```FAQs
Does Daft support PostgreSQL extensions?
Yes! Daft can automatically install and configure PostgreSQL extensions if they're available. The pgvector extension
vectoris installed by default, if available.How does Daft handle PostgreSQL data types?
Daft provides comprehensive type mapping between Daft's Arrow-based types and PostgreSQL types. Complex types like arrays and JSON are supported, with fallbacks to JSONB for unsupported structures.
Does Daft support PostgreSQL Row Level Security?
Yes, Daft automatically enables Row Level Security on newly created tables for enhanced security. This can be controlled via the
enable_rlsproperty when creating tables.How does Daft handle PostgreSQL schemas and search paths?
Daft respects PostgreSQL's schema search path. When no schema is specified, PostgreSQL uses its configured search path to resolve table names.
Can Daft work with PostgreSQL in distributed environments?
Yes, when using Daft's distributed runner, PostgreSQL writes can be distributed across multiple machines for maximum performance.
How does Daft handle vector embeddings in PostgreSQL?
Daft has native support for pgvector's VECTOR type. Embedding columns are automatically converted between Daft's embedding type and PostgreSQL's vector type during reads and writes.
Connectors S3tables (`docs/connectors/s3tables.md`)
Reading from and Writing to AWS S3 Tables
Daft integrates with S3 Tables using its Catalog interface which supports both reading and writing S3 Tables via Iceberg.
Example
/* Detailed source-code truncated for AI context efficiency. */S3 Tables AWS API
You can use the S3 Tables AWS API via a boto3 client or boto3 session.
S3 Tables Iceberg REST API
S3 Tables offers an Iceberg REST compatible endpoint which Daft uses when you create a catalog via [from_s3tables][daft.Catalog.from_s3tables].
You can connect your Iceberg REST client to the Amazon S3 Tables Iceberg REST endpoint and make REST API calls to create, update, or query tables in S3 table buckets. The endpoint implements a set of standardized Iceberg REST APIs specified in the Apache Iceberg REST Catalog Open API specification. The endpoint works by translating Iceberg REST API operations into corresponding S3 Tables operations.
Glue and LakeFormation
You may also use the AWS Glue Iceberg REST endpoint, which provides unified table management, centralized governance, and fine-grained access control (table level). However this requires service enabling integrations in AWS Glue and LakeFormation.
Connectors Sql (`docs/connectors/sql.md`)
Reading from and Writing to SQL Databases
You can read the results of SQL queries from databases, data warehouses, and query engines via the [daft.read_sql()][daft.read_sql] function.
Daft currently supports:
20+ SQL Dialects: Daft supports over 20 databases, data warehouses, and query engines by using SQLGlot to convert SQL queries across dialects. See the full list of supported dialects here.
Parallel + Distributed Reads: Daft parallelizes SQL reads by using all local machine cores with its default multithreading runner, or all cores across multiple machines if using the distributed Ray runner.
Skipping Filtered Data: Daft ensures that only data that matches your [
df.select()][daft.DataFrame.select], [df.limit()][daft.DataFrame.limit], and [df.where()][daft.DataFrame.where] expressions will be read, often skipping entire partitions/columns.
Installing Daft with SQL Support
Install Daft with the daft[sql] extra, or manually install the required packages: ConnectorX, SQLAlchemy and SQLGlot.
pip install -U "daft[sql]"Reading a SQL query
To read a SQL query, provide [daft.read_sql()][daft.read_sql] with the SQL query and a URL for the data source.
The example below creates a local SQLite table for Daft to read.
=== "🐍 Python"
```python
import sqlite3
connection = sqlite3.connect("example.db")
connection.execute(
"CREATE TABLE IF NOT EXISTS books (title TEXT, author TEXT, year INTEGER)"
)
connection.execute(
"""
INSERT INTO books (title, author, year)
VALUES
('The Great Gatsby', 'F. Scott Fitzgerald', 1925),
('To Kill a Mockingbird', 'Harper Lee', 1960),
('1984', 'George Orwell', 1949),
('The Catcher in the Rye', 'J.D. Salinger', 1951)
"""
)
connection.commit()
connection.close()
```After writing this local example table, we can easily read it into Daft as a DataFrame.
=== "🐍 Python"
```python
# Read SQL query
import daft
df = daft.read_sql(
"SELECT * FROM books",
"sqlite:///example.db",
)
```Daft uses ConnectorX under the hood to read SQL data. ConnectorX is a fast, Rust based SQL connector that reads directly into Arrow Tables, enabling zero-copy transfer into Daft. If the database is not supported by ConnectorX (list of supported databases here), Daft will fall back to using SQLAlchemy.
You can also directly provide a SQL alchemy connection via a connection factory. This way, you have the flexibility to provide additional parameters to the engine.
=== "🐍 Python"
```python
# Read SQL query using a connection factory
import daft
from sqlalchemy import create_engine
def create_connection():
return create_engine("sqlite:///example.db", echo=True).connect()
df = daft.read_sql("SELECT * FROM books", create_connection)
```Parallel + Distributed Reads
For large datasets, Daft can parallelize SQL reads by using all local machine cores with its default multithreading runner, or all cores across multiple machines if using the distributed Ray runner.
Supply the [daft.read_sql()][daft.read_sql] function with a partition column and optionally the number of partitions to enable parallel reads.
=== "🐍 Python"
```python
# Read SQL query with parallel reads
import daft
df = daft.read_sql(
"SELECT * FROM table",
"sqlite:///big_table.db",
partition_col="col",
num_partitions=3,
)
```Behind the scenes, Daft will partition the data by appending a WHERE col > ... AND col <= ... clause to the SQL query, and then reading each partition in parallel.
Data Skipping Optimizations
Filter, projection, and limit pushdown optimizations can be used to reduce the amount of data read from the database.
In the example below, Daft reads the top ranked terms from the BigQuery Google Trends dataset. The where and select expressions in this example will be pushed down into the SQL query itself, we can see this by calling the [df.explain()][daft.DataFrame.explain] method.
=== "🐍 Python"
```python
import daft, sqlalchemy, datetime
def create_conn():
engine = sqlalchemy.create_engine(
"bigquery://", credentials_path="path/to/service_account_credentials.json"
)
return engine.connect()
df = daft.read_sql("SELECT * FROM `bigquery-public-data.google_trends.top_terms`", create_conn)
df = df.where((df["refresh_date"] >= datetime.date(2024, 4, 1)) & (df["refresh_date"] < datetime.date(2024, 4, 8)))
df = df.where(df["rank"] == 1)
df = df.select(df["refresh_date"].alias("Day"), df["term"].alias("Top Search Term"), df["rank"])
df = df.distinct()
df = df.sort(df["Day"], desc=True)
df.explain(show_all=True)
```..
== Physical Plan ==
..
| SQL Query = SELECT refresh_date, term, rank FROM
(SELECT * FROM `bigquery-public-data.google_trends.top_terms`)
AS subquery WHERE rank = 1 AND refresh_date >= CAST('2024-04-01' AS DATE)
AND refresh_date < CAST('2024-04-08' AS DATE)The second last line labeled 'SQL Query =' shows the query that Daft executed. Filters such as rank = 1 and projections such as SELECT refresh_date, term, rank have been injected into the query.
Without these pushdowns, Daft would execute the unmodified SELECT * FROM 'bigquery-public-data.google_trends.top_terms' query and read in the entire dataset/table. We tested the code above on Google Colab (12GB RAM):
- With pushdowns, the code ran in 8.87s with a peak memory of 315.97 MiB
- Without pushdowns, the code took over 2 mins before crashing with an out of memory error.
You could modify the SQL query to add the filters and projections yourself, but this may become lengthy and error-prone, particularly with many expressions. That's why Daft automatically handles it for you.
Roadmap
Here are the SQL features that are on our roadmap. Please let us know if you would like to see support for any of these features!
- Write support into SQL databases.
- Reads via ADBC (Arrow Database Connectivity).
Connectors Text (`docs/connectors/text.md`)
Reading Text Files
Daft can read line-oriented text files into DataFrames using [daft.read_text()][daft.io.read_text]. This is useful for processing log files, configuration files, or any other plain text data.
Basic Usage
Read a text file where each line becomes a row:
=== "Local File"
```python
import daft
df = daft.read_text("/path/to/file.txt")
df.show()
```=== "Remote File (S3)"
```python
import daft
from daft.io import S3Config, IOConfig
io_config = IOConfig(s3=S3Config(region_name="us-west-2", anonymous=True))
df = daft.read_text("s3://my-bucket/logs/*.txt", io_config=io_config)
df.show()
```=== "Remote File (GCS)"
```python
import daft
from daft.io import GCSConfig, IOConfig
io_config = IOConfig(gcs=GCSConfig(anonymous=True))
df = daft.read_text("gs://my-bucket/logs/*.txt", io_config=io_config)
df.show()
```Output Schema
The read_text function returns a DataFrame with a single column:
| Column | Type | Description |
|---|---|---|
text |
string |
Lines from the input files (or entire file content when whole_text=True) |
Reading Options
Whole File Mode
Read each file as a single row instead of splitting by lines:
# Each file becomes a single row
df = daft.read_text("/path/to/files/*.txt", whole_text=True)
df.show()This is useful when you need to process entire file contents at once, such as for document processing or when the file content shouldn't be split by newlines.
Skip Blank Lines
By default, empty lines (after stripping whitespace) are skipped. To include them:
df = daft.read_text("/path/to/file.txt", skip_blank_lines=False)When whole_text=True, this option skips files that are entirely blank.
File Encoding
Specify the character encoding of input files (defaults to UTF-8):
df = daft.read_text("/path/to/file.txt", encoding="latin-1")Include File Path
Add a column with the source file path:
df = daft.read_text("/path/to/files/*.txt", file_path_column="source_file")
df.show()Hive Partitioning
Infer partition columns from the file path:
# For paths like /data/year=2024/month=01/file.txt
df = daft.read_text("/data/**/*.txt", hive_partitioning=True)
df.show() # Includes 'year' and 'month' columnsWildcard Patterns
read_text supports glob patterns for reading multiple files:
| Pattern | Description |
|---|---|
* |
Matches any number of characters |
? |
Matches any single character |
[...] |
Matches any single character in the brackets |
** |
Recursively matches directories |
=== "Examples"
```python
# All .txt files in a directory
df = daft.read_text("/logs/*.txt")
# All .log files recursively
df = daft.read_text("/logs/**/*.log")
# Files matching a pattern
df = daft.read_text("/logs/server-[0-9].txt")
```Use Cases
Processing Log Files
import daft
from daft import col
# Read log files and filter for errors
df = daft.read_text("/var/log/app/*.log", file_path_column="log_file")
errors = df.where(col("text").contains("ERROR"))
errors.show()Document Processing
import daft
# Read entire documents
df = daft.read_text("/documents/*.txt", whole_text=True, file_path_column="doc_path")
# Process with embeddings or other analysis
df.show()Connectors Turbopuffer (`docs/connectors/turbopuffer.md`)
Writing to turbopuffer
We recommend turbopuffer as a fast search engine that combines vector and full-text search using object storage, making all your data easily searchable.
Example
This example does the following:
- Reads the
Open-Orca/OpenOrcadataset from Hugging Face - Computes text embeddings on the
responsecolumn, using either an OpenAI model or theBAAI/bge-base-en-v1.5open model on Hugging Face with Sentence Transformers - Writes the results to turbopuffer
=== "🐍 Python"
```python
# $ pip install -U "daft[turbopuffer]"
# $ pip install -U "daft[openai]"
# OR
# $ pip install -U "daft[transformers]"
import os
import daft
from daft.functions.ai import embed_text
# Create an embedding with OpenAI, or Sentence Transformers
# Requires OPENAI_API_KEY to be set (https://platform.openai.com/settings/organization/api-keys)
if os.getenv("OPENAI_API_KEY"):
provider = "openai"
model = "text-embedding-3-small"
else:
provider = "sentence_transformers"
model = "BAAI/bge-base-en-v1.5"
turbopuffer_config = {
"namespace": "daft-tpuf-example",
"region": "gcp-us-central1",
"distance_metric": "cosine_distance",
"schema": {
"text": {
"type": "string",
"full_text_search": True,
}
}
}
(
daft.read_huggingface("Open-Orca/OpenOrca")
.limit(8)
.with_column("vector", embed_text(daft.col("response"), provider=provider, model=model))
.write_turbopuffer(**turbopuffer_config) # Requires TURBOPUFFER_API_KEY to be set (https://turbopuffer.com/dashboard)
)
```For a more a detailed example, check out our guide on generating text embeddings.
Check out our [DataFrame.write_turbopuffer documentation][daft.DataFrame.write_turbopuffer] for more customization options.
Connectors Unity Catalog (`docs/connectors/unity_catalog.md`)
Reading from and Writing to Unity Catalog
Unity Catalog is an open-sourced catalog developed by Databricks. Users of Unity Catalog are able to work with data assets such as tables (Parquet, CSV, Iceberg, Delta), volumes (storing raw files), functions and models.
To use Daft with the Unity Catalog, you will need to install Daft with the unity option specified like so:
pip install daft[unity]!!! warning "Warning"
These APIs are in beta and may be subject to change as the Unity Catalog continues to be developed.Connecting to the Unity Catalog
Daft includes an abstraction for the Unity Catalog. For more information, see also Unity Catalog Documentation.
Authentication options
You can authenticate either with a personal access token (PAT) or with OAuth credentials.
To use OAuth, create a Databricks service principal and generate an OAuth secret for it. See the Databricks docs for the exact steps: Databricks OAuth M2M docs.
=== "🐍 Python (PAT)"
```python
from daft.catalog.__unity import UnityCatalogClient as UnityCatalog
unity = UnityCatalog(
endpoint="https://<databricks_workspace_id>.cloud.databricks.com",
# Authentication can be retrieved from your provider of Unity Catalog
token="my-token",
)
# See all available catalogs
print(unity.list_catalogs())
# See available schemas in a given catalog
print(unity.list_schemas("my_catalog_name"))
# See available tables in a given schema
print(unity.list_tables("my_catalog_name.my_schema_name"))
```=== "🐍 Python (OAuth client credentials)"
```python
from daft.catalog.__unity import UnityCatalogClient as UnityCatalog
from daft.catalog.__unity import OAuth2Credentials
unity = UnityCatalog(
endpoint="https://<databricks_workspace_id>.cloud.databricks.com",
oauth=OAuth2Credentials(
client_id="your-client-id",
client_secret="your-client-secret",
),
)
# See all available catalogs
print(unity.list_catalogs())
# See available schemas in a given catalog
print(unity.list_schemas("my_catalog_name"))
# See available tables in a given schema
print(unity.list_tables("my_catalog_name.my_schema_name"))
```Loading a Dataframe from a Delta Lake table in Unity Catalog
=== "🐍 Python"
```python
unity_table = unity.load_table("my_catalog_name.my_schema_name.my_table_name")
df = daft.read_deltalake(unity_table)
df.show()
```Any subsequent filter operations on the Daft df DataFrame object will be correctly optimized to take advantage of DeltaLake features:
=== "🐍 Python"
```python
# Filter which takes advantage of partition pruning capabilities of Delta Lake
df = df.where(df["partition_key"] < 1000)
df.show()
```See also Delta Lake for more information about how to work with the Delta Lake tables provided by the Unity Catalog.
Downloading files in Unity Catalog volumes
Daft supports downloading from Unity Catalog volumes using [Expression.download()][daft.expressions.expressions.download]. File paths that start with vol+dbfs:/ or dbfs:/ will be downloaded using the configurations in [IOConfig.unity][daft.daft.IOConfig.unity]. These configurations can be created using UnityCatalog.to_io_config, or automatically derived from the global session.
=== "🐍 Python"
```python
df = daft.from_pydict({
"files": [
"/Volumes/my_catalog/my_schema_name/my_volume_name/file1.txt",
"/Volumes/my_catalog/my_schema_name/my_volume_name/file2.txt"
]
})
# explicitly specify the unity catalog
io_config = unity.to_io_config()
data_df = df.select(("vol+dbfs:" + df["files"]).download(io_config=io_config))
data_df.show()
# use the global session
from daft.catalog import Catalog
import daft.session
catalog = Catalog.from_unity(unity)
daft.session.attach(catalog)
data_df = df.select(df["files"].download())
data_df.show()
```Roadmap
- Unity Iceberg integration for reading tables using the Iceberg interface instead of the Delta Lake interface
Please make issues on the Daft repository if you have any use-cases that Daft does not currently cover! For the overall Daft development plan, see Daft Roadmap.
Connectors Webdataset (`docs/connectors/webdataset.md`)
WebDataset
WebDataset stores multimodal samples
as consecutive members of TAR shards. Daft reads these shards with
[daft.read_webdataset()][daft.read_webdataset].
Reading shards
Pass a TAR file, directory, glob, or list of paths:
import daft
df = daft.read_webdataset("/datasets/images/*.tar")Each row represents one sample. Consecutive members with the same prefix are
grouped together, and the suffix becomes the column name. For example,000001.jpg, 000001.json, and 000001.txt produce one row with jpg,json, and txt columns. Daft also includes:
__key__: the sample prefix__url__: the shard containing the sample
JSON, text, and class/index sidecars are decoded eagerly. Image, audio, video,
and other binary members are represented as lazy [File][daft.File]
references into their TAR shard. Selecting only metadata columns therefore
avoids reading media payloads:
metadata = df.select("__key__", "json", "txt")
images = df.select("__key__", "jpg")Reading from Hugging Face
Use format="webdataset" with
[daft.read_huggingface()][daft.read_huggingface]:
df = daft.read_huggingface(
"laion/conceptual-captions-12m-webdataset",
format="webdataset",
)You can also read explicit Hugging Face paths:
df = daft.read_webdataset(
"hf://datasets/laion/conceptual-captions-12m-webdataset/data/*.tar"
)Format requirements
Daft currently requires:
- Uncompressed
.tarshards, because lazy member reads use byte offsets. - Non-sparse TAR members, because sparse file contents are not stored in one
contiguous byte range. - Consistent member suffixes and JSON shapes across shards.
Schema inference uses the first five samples in the first shard. If a later
sample introduces an incompatible suffix or JSON shape, Daft raises an error
instead of silently discarding data.
Contributing Contributing Ai Functions (`docs/contributing/contributing-ai-functions.md`)
Contributing New AI Functions
This page contains references and guides for developing new AI Functions in daft. These steps will guide you through implementing a model expression like:
embed_textembed_imageclassify_textprompt
Step 1. Define the Protocol and Descriptor
All model expressions are backed by a Protocol and Descriptor. These protocols are based on the verb and modality, and the
descriptor is used to instantiate the model at runtime.
# daft.ai.protocols
@runtime_checkable
class TextClassifier(Protocol):
"""Protocol for text classification implementations."""
def classify_text(self, text: list[str], labels: list[str]) -> list[str]:
"""Classifies a batch of text strings using the given label(s)."""
...
class TextClassifierDescriptor(Descriptor[TextClassifier]):
"""Descriptor for a TextClassifier implementation."""Step 2. Add to the Provider Interface
You must update the Provider interface with a new method to create your descriptor. This should have
a default implementation which simply raises; this makes it so that you need not update all existing providers.
# daft.ai.provider
class Provider(ABC):
# ... existing code
def get_text_classifier(self, model: str | None = None, **options: Any) -> TextClassifierDescriptor:
"""Returns a TextClassifierDescriptor for this provider."""
raise not_implemented_err(self, method="classify_text")Step 3. Define the Function.
In daft.functions.ai you can add the function, and then re-export it in daft.functions.__init__.py.
The implementation is responsible for resolving the provider from the given arguments, then you
will call the appropriate provider method to get the relevant descriptor.
import daft
from daft import DataType, Series
from daft.ai.protocols import TextClassifier, TextClassifierDescriptor
def classify_text(
text: Expression,
labels: list[str],
*,
provider: str | Provider | None = None,
model: str | None = None,
) -> Expression:
# Load a TextClassifierDescriptor from the resolved provider
text_classifier = _resolve_provider(provider, "transformers").get_text_classifier(model)
# Create the stateful class UDF
classifier = _TextClassifierExpression(text_classifier, labels)
# Return the expression
return classifier.classify(text)
@daft.cls
class _TextClassifierExpression:
"""Function expression implementation for a TextClassifier protocol."""
def __init__(self, descriptor: TextClassifierDescriptor, labels: list[str]):
# Instantiate from the descriptor in __init__
self.text_classifier = descriptor.instantiate()
self.labels = labels
@daft.method.batch(return_dtype=DataType.string())
def classify(self, text: Series) -> list[str]:
text_list = text.to_pylist()
if not text_list:
return []
return self.text_classifier.classify_text(text_list, self.labels)Step 4. Implement the Protocol for some Provider.
Here is a simplified example implementation of embed_text for OpenAI. This should give you
and idea of where you actual logic should live, and the previous steps are to properly
hook your new expression into the provider/model system.
@dataclass
class OpenAITextEmbedderDescriptor(TextEmbedderDescriptor):
model: str # store some metadata
# We can use the stored metadata to instantiate the protocol implementation
def instantiate(self) -> TextEmbedder:
return OpenAITextEmbedder(client=OpenAI(), model=self.model)
@dataclass
class OpenAITextEmbedder(TextEmbedder):
client: OpenAI
model: str
# This is a simple version using the batch API. The full implementation
# uses dynamic batching and has error handling mechanisms.
def embed_text(self, text: list[str]) -> list[Embedding]:
response = self.client.embeddings.create(
input=text,
model=self.model,
encoding_format="float",
)
return [np.array(embedding.embedding) for embedding in response.data]Step 5. Expression Usage
You can now use this like any other expression.
import daft
df = daft.read_parquet("/path/to/file.parquet") # assuming has some column 'text'
df = df.with_column("embedding", embed_text(df["text"], provider="openai")) # <- set provider to 'openai'
df.show()Contributing Development (`docs/contributing/development.md`)
Development Guide
💡 Already set up?
See our quick tutorial on how to add a new expression to Daft.
Development Environment
To set up your development environment:
- Install uv. You can run
curl -LsSf https://astral.sh/uv/install.sh | shon macOS and Linux. - Install the Rust compilation toolchain
- Install Node.js (22.x LTS) and npm in order to build docs and the daft-dashboard functionality.
- Install cmake. If you use homebrew, you can run
brew install cmake. - Install protoc. You will need this for release builds --
make build-release. With homebrew, installation isbrew install protobuf. - Clone the Daft repo:
git clone [email protected]:Eventual-Inc/Daft.git - Run
make .venvfrom your newly cloned Daft repository to create a new virtual environment with all of Daft's development dependencies installed - Run
make hooksto install pre-commit hooks: these will run tooling on every commit to ensure that your code meets Daft development standards
Developing
make build: recompile your code after modifying any Rust code insrc/DAFT_RUNNER=native make test: run tests, you can set additional run parameters throughEXTRA_ARGSDAFT_RUNNER=ray make test: set the runner to the Ray runner and run testsmake docs: build docsmake docs-serve: build docs in development servermake format: format all Python and Rust codemake lint: lint all Python and Rust codemake check-format: check that all Python and Rust code is formatted, aliasmake format-checkmake precommit: run all pre-commit hooks, must install pre-commit first(pip install pre-commit)make build-release: perform a full release build of Daftmake build-whl: recompile your code after modifying any Rust code insrc/for development, only generatewhlfile without installationmake clean: clean all build artifacts, including the python virtual environment. You can skip cleaning the virtual environment by settingSKIP_VENV=true
Note about Developing `daft-dashboard`
!!! note
If you just want to use the Daft Dashboard (not modify it), see the Daft Dashboard user guide. The instructions below are only needed for contributing to the dashboard's frontend code.
If you wish to enable, or work on the daft-dashboard functionality, it requires Node.js (LTS) and npm. Install Node.js, then run npm install and npm run build in the src/daft-dashboard/frontend directory.
Next (make sure Daft is installed), you can launch the dashboard using the daft dashboard command, for example:
# You can learn more about this command by `daft dashboard -h`
daft dashboard start -v -a 127.0.0.1 -p 3238Before executing a specific Daft job, enable reporting query execution data to the dashboard by setting the DAFT_DASHBOARD_URL environment variable, for example:
export DAFT_DASHBOARD_URL="http://127.0.0.1:3238"Next, you can access and view the dashboard through a web browser, for example, via address http://127.0.0.1:3238.
Developing with Ray
Running a development version of Daft on a local Ray cluster is as simple as including daft.set_runner_ray() in your Python script and then building and executing it as usual.
To use a remote Ray cluster, run the following steps on the same operating system version as your Ray nodes, in order to ensure that your binaries are executable on Ray.
mkdir wd: this is the working directory, it will hold all the files to be submitted to Ray for a jobln -s daft wd/daft: create a symbolic link from the Python module to the working directorymake build-release: an optimized build to ensure that the module is small enough to be successfully uploaded to Ray. Run this after modifying any Rust code insrc/ray job submit --working-dir wd --address "http://<head_node_host>:8265" -- python script.py: submitwd/script.pyto be run on Ray
Debugging
The debugging feature uses a special VSCode launch configuration to start the Python debugger with a script at tools/attach_debugger.py, which takes the target script's name as input. This script finds the process ID, updates the launch.json file, compiles the target script, and runs it. It then attaches a Rust debugger to the Python debugger, allowing both to work together. Breakpoints in Python code hit the Python debugger, while breakpoints in Rust code hit the Rust debugger.
Preparation
CodeLLDB Extension for Visual Studio Code:
This extension is useful for debugging Rust code invoked from Python.Setting Up the Virtual Environment Interpreter
(Ctrl+Shift+P -> Python: Select Interpreter -> .venv)Debug Settings in launch.json
This file is usually found in the.vscodefolder of your project root. See the official VSCode documentation for more information about the launch.json file.**launch.json**text```json { "configurations": [ { "name": "Debug Rust/Python", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/tools/attach_debugger.py", "args": [ "${file}" ], "console": "internalConsole", "serverReadyAction": { "pattern": "pID = ([0-9]+)", "action": "startDebugging", "name": "Rust LLDB" } }, { "name": "Rust LLDB", "pid": "0", "type": "lldb", "request": "attach", "program": "${command:python.interpreterPath}", "stopOnEntry": false, "sourceLanguages": [ "rust" ], "presentation": { "hidden": true } } ] } ```
Running the debugger
Create a Python script containing Daft code. Ensure that your virtual environment is set up correctly.
Set breakpoints in any
.rsor.pyfile.In the
Run and Debugpanel on the left, selectDebug Rust/Pythonfrom the drop-down menu on top and click theStart Debuggingbutton. This will start a debugging session using the file that is currently opened in the VSCode editor.
At this point, your debugger should stop on breakpoints in any .rs file located within the codebase.
Note:
On some systems, the LLDB debugger will not attach unless ptrace protection is disabled.
To disable, run the following command:shellecho 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
Testing
We run test suites across Python and Rust. Python tests focus on high-level DataFrame and Expression functionality, while Rust tests validate individual kernel implementations at a lower level.
Python tests
Our python tests are located in the tests directory, you can run all the tests at once with make test.
To run specific tests, set the runner for the tests in the environment and then run the tests directly using pytest or make test EXTRA_ARGS="..." as follows:
# Using pytest
DAFT_RUNNER=native pytest tests/dataframe/test_limit_offset.py::test_limit
# Using make test
DAFT_RUNNER=native make test EXTRA_ARGS="tests/dataframe/test_limit_offset.py::test_limit"To enable debug logs from tests, set the --log-cli-level option, as well as disable capturing.
# Using pytest
DAFT_RUNNER=native pytest tests/dataframe/test_limit_offset.py::test_limit -s --log-cli-level=DEBUG
# Using make test
DAFT_RUNNER=native make test EXTRA_ARGS="tests/dataframe/test_limit_offset.py::test_limit -s --log-cli-level=DEBUG"Rust tests
Our rust tests are distributed across crates, you can run all tests with cargo test --no-default-features --workspace.
To run rust tests that call into Python, the --features python flag and libpython3.*.so dynamic libraries are required. Please ensure that these are installed, here's a table of common locations on different os:
| Operating System | Package Manager | Architecture | Library Path Pattern |
|---|---|---|---|
| Ubuntu/Debian | apt | x86_64 | /usr/lib/x86_64-linux-gnu/libpython3.x.so.1.0 |
| Other | /usr/lib/libpython3.x.so.1.0 |
||
| Red Hat/CentOS | yum/dnf | x86_64 | /usr/lib64/libpython3.x.so.1.0 |
| macOS (Homebrew) | Homebrew | Intel | /usr/local/opt/[email protected]/lib/libpython3.x.dylib |
| Apple Silicon | /opt/homebrew/opt/[email protected]/lib/libpython3.x.dylib |
||
| macOS (System) | Installer | All | /Library/Frameworks/Python.framework/Versions/3.x/lib/libpython3.x.dylib |
Tip: you can run the following python command to get the full path to the pylib
python -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR') + '/' + sysconfig.get_config_var('LDLIBRARY'))"Set environment variables to locate the Python library:
export PYO3_PYTHON=".venv/bin/python"
export PYO3_PYTHON_PYLIB="/usr/lib/x86_64-linux-gnu/libpython3.11.so.1"
export RUSTFLAGS="-C link-arg=-Wl,-rpath,${PYO3_PYTHON_PYLIB%/*} -C link-arg=-L${PYO3_PYTHON_PYLIB%/*} -C link-arg=-lpython3.11"Execute the test after configuration:
cargo test -p daft-dsl --features python -- expr::testsBenchmarking
Benchmark tests are located in tests/benchmarks. If you would like to run benchmarks, make sure to first do make build-release instead of make build in order to compile an optimized build of Daft.
pytest tests/benchmarks/[test_file.py] -m benchmark: Run all benchmarks in a filepytest tests/benchmarks/[test_file.py] -k [test_name] -m benchmark: Run a specific benchmark in a file
More information about writing and using benchmarks can be found on the pytest-benchmark docs.
Adding new expressions
Since new expressions are a very common feature request, we wanted to make it easy for new contributors to add these. Adding a new expression requires implementation in Rust and exposing it to Python.
Step 1: Implement the function in Rust
Add your function to the appropriate crate (daft-functions-json, daft-functions-utf8, etc.).
For more advanced use cases, see existing implementations in daft-functions-utf8
/* Detailed source-code truncated for AI context efficiency. */Step 2: Register the function
Okay, now that we have the actual function implementation available, we're not quite done yet. We also need to register this to our FUNCTION_REGISTRY which is a global registry of all expressions/functions.
Whatever crate/module you are in, there should be a daft_dsl::functions::FunctionModule implementation that registers all of the functions. So all you need to do is add your new struct into there.
for the utf8 functions, it's defined here src/daft-functions-utf8/src/lib.rs
impl daft_dsl::functions::FunctionModule for Utf8Functions {
fn register(parent: &mut daft_dsl::functions::FunctionRegistry) {
// ...
parent.add_fn(MyToUpperCase); // add this line here
}
}Step 3: Add python bindings
Create expression method in daft/expressions/expressions.py
# the method name should usually match that of which you defined in your `ScalarUDF` implementation
def to_uppercase(self) -> Expression:
# make sure to add a docstring with a runnable `doctest` example
"""Convert UTF-8 string to all upper.
Returns:
Expression: a String expression which is `self` uppercased
Examples:
>>> import daft
>>> df = daft.from_pydict({"x": ["foo", "bar", "baz"]})
>>> df = df.select(df["x"].to_uppercase())
>>> df.show()
╭──────╮
│ x │
│ --- │
│ Utf8 │
╞══════╡
│ FOO │
├╌╌╌╌╌╌┤
│ BAR │
├╌╌╌╌╌╌┤
│ BAZ │
╰──────╯
<BLANKLINE>
(Showing first 3 of 3 rows)
"""
# Get the function from our global `FUNCTION_REGISTRY`
f = native.get_function_from_registry("to_uppercase")
return Expression._from_pyexpr(f(self._expr))For functions with additional arguments, you will need to convert those all to expressions before calling the function_registry function.
def extract_all(self, pattern: str | Expression, index: int = 0) -> Expression:
pattern_expr = Expression._to_expression(pattern)
idx = Expression._to_expression(index)
f = native.get_function_from_registry("extract_all")
# Pass scalar values as kwargs
return Expression._from_pyexpr(f(self._expr, pattern_expr._expr, index=idx._expr))Add Series method in daft/series.py:
For series, It just delegates out to the expression implementation, so we can just call the helper method _eval_expressions
def to_uppercase(self) -> Series:
return self._eval_expressions("to_uppercase")and for functions with additional arguments:
def extract_all(self, pattern: Series, index: int = 0) -> Series:
# Pass scalar values as kwargs
return self._eval_expressions("extract_all", pattern, index=index)Docstring Template
We follow Google style python docstrings.
def method(args) -> return:
"""Summary of method
Args:
arg1: description
Returns:
return1: description
Raises: title (optional)
description
Warning: title (optional)
description
Note: title (optional)
description
Examples: (make sure this is plural!)
>>> code example (this needs to be a runnable `doctest` example)
output
Tip: title (optional)
description
"""Step 4: Write tests
For testing, you can add a new file, or update an existing one in tests/expressions/
We have a fixture test_expression that will do most of the heavy lifting and ensure that the apis are consistent across expr, series, and sql.
here's an example of testing the extract function using the test_expression fixture
def test_extract(test_expression):
test_data = ["123-456", "789-012", "345-678"]
regex = r"(\d)(\d*)"
expected = ["123", "789", "345"]
test_expression(
data=test_data,
expected=expected,
name="regexp_extract",
sql_name="regexp_extract", # if this is not provided, it will be the same as `name`
args=[regex],
kwargs={}
)Pull Requests
Best practices for pull requests
For the best chance of having your pull request accepted, please follow these guidelines:
Include unit tests for all changes and new features. Pull requests without tests will not be merged.
Keep changes focused. Aim to solve one problem per pull request and avoid unrelated changes.
Review before submitting. Whenever possible, ask another contributor to review your code first or perform a thorough self-review. Ask yourself: Is it clear why these changes are being made? Are they easy to understand?
Use Conventional Commit messages for pull request titles. For example:
- New feature:
feat: adding API - Bug fix:
fix: issue with API - Documentation:
docs: adding API documentation
- New feature:
Test error cases. Ensure your tests cover failure scenarios and provide clear, user-friendly error messages.
Review process
Draft vs. Open status
- Leave your pull request in Draft status if it is still a work in progress.
- Mark it as Open once it is ready for review.
Checks on GitHub
- Ensure all automated checks have passed. PRs with failing checks will not be prioritized for review.
Reviewer assignment
- Contributors with write access: add a reviewer to the Assignee field.
- Other contributors: reviewers will be assigned to your PR shortly.
During review
- Assignees will review your PR and provide feedback if changes are needed.
- Address review comments promptly to keep momentum. PRs without author engagement for more than a week may lose priority.
- Iterate until assignees approve your PR.
Merging
- Once the build is passing and approvals are in place, committers will merge the PR.
Custom Code Cls (`docs/custom-code/cls.md`)
Stateful Class UDFs with `@daft.cls`
When your UDF requires expensive initialization such as loading a machine learning model, establishing database connections, or pre-computing lookup tables use @daft.cls to amortize the cost across multiple rows. The class is initialized once per worker, and the same instance processes all rows on that worker.
Quick Example
import daft
@daft.cls
class TextClassifier:
def __init__(self, model_path: str):
# This expensive initialization happens once per worker
self.model = load_model(model_path)
def __call__(self, text: str) -> str:
return self.model.predict(text)
# Create an instance with initialization arguments
classifier = TextClassifier("path/to/model.pkl")
df = daft.from_pydict({"text": ["hello world", "goodbye world"]})
# Use the instance directly as a Daft function
df = df.select(classifier(df["text"]))How It Works
Lazy Initialization: When you create an instance like
classifier = TextClassifier("path/to/model.pkl"), the__init__method is not called immediately. Instead, Daft saves the initialization arguments.Worker Initialization: During query execution, Daft calls
__init__on each instance with the saved arguments. Instances are reused for multiple rows.Method Calls: Methods can be called with either:
- Expressions (like
df["text"]) - returns an Expression for DataFrame operations - Scalars (like
"hello") - executes immediately, initializing a local instance if needed
- Expressions (like
Method Variants
Similarly to daft.func, Daft supports the same variants for daft.method to optimize for different use cases:
- Row-wise (default): Regular Python functions process one row at a time
- Async row-wise: Async Python functions process rows concurrently
- Generator: Generator functions produce multiple output rows per input row
- Batch (
@daft.method.batch): Process entire batches of data withdaft.Seriesfor high performance
@daft.cls
class Something:
def __call__(self, x: float) -> float: ...
def my_method(self, x: float) -> float: ...
async def async_method(self, x: float) -> float: ...
@daft.method.batch(return_dtype=DataType.float64())
def my_batch_method(self, s: Series) -> Series: ...
@daft.method.batch(return_dtype=DataType.float64())
async def async_batch_method(self, s: Series) -> Series: ...Daft automatically detects which variant to use for regular functions based on your function signature. For batch functions, you must use the @daft.method.batch decorator.
Resource Control
Control computational resources, concurrency, and error handling with decorator parameters:
@daft.cls(
cpus=2, # Request 2 CPUs per instance (placement hint)
gpus=1, # Request 1 GPU per instance
max_concurrency=4, # Cap concurrent instances at 4
use_process=True, # Run each instance in its own process
max_retries=3, # Retry failing calls up to 3 times with backoff
on_error="log", # On final failure, log and emit None instead of raising
)
class ImageClassifier:
def __init__(self, model_name: str):
import torch
self.model = torch.load(model_name).cuda()
def __call__(self, image_path: str) -> str:
image = load_image(image_path)
return self.model(image)
classifier = ImageClassifier("resnet50.pth")
df = daft.from_pydict({"images": ["img1.jpg", "img2.jpg"]})
df = df.select(classifier(df["images"]))Parameters:
cpus: CPUs per instance — placement hint used by the scheduler (fractional values allowed).gpus: GPUs per instance (default: 0). Fractional values up to 1.0 are allowed; values above 1.0 must be integers.use_process: Whether to run each instance in a separate process for isolation.max_concurrency: Maximum number of concurrent instances across all workers.max_retries: Number of retry attempts for failing calls (exponential backoff starting at 100 ms, ±25% jitter, capped at 60 s).on_error:"raise"(default),"log", or"ignore". Controls behavior once retries are exhausted.name_override: Display name for the UDF in plans and progress bars.
Using `@daft.method`
By default, all methods in a @daft.cls class can be used as Daft functions. Use the @daft.method decorator to override default behavior:
import daft
from daft import DataType
from typing import Iterator
@daft.cls
class TextProcessor:
def __init__(self, prefix: str):
self.prefix = prefix
# No decorator needed - works with default inference
def __call__(self, text: str) -> str:
return f"{self.prefix}{text}"
# Override return type
@daft.method(return_dtype=DataType.list(DataType.string()))
def split_words(self, text: str):
return text.split()
# Unnest struct fields
@daft.method(
return_dtype=DataType.struct({
"word_count": DataType.int64(),
"char_count": DataType.int64()
}),
unnest=True
)
def analyze(self, text: str):
words = text.split()
return {
"word_count": len(words),
"char_count": len(text)
}
processor = TextProcessor(">> ")
df = daft.from_pydict({"text": ["hello world", "foo bar"]})
df = df.select(
processor(df["text"]).alias("prefixed"), # Using __call__
processor.split_words(df["text"]).alias("words"),
processor.analyze(df["text"]) # Expands into word_count and char_count columns
)Method Variants
Like @daft.func, methods support multiple execution patterns:
Async Methods
import aiohttp
@daft.cls
class APIClient:
def __init__(self, api_key: str):
self.api_key = api_key
async def fetch_data(self, url: str) -> str:
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {self.api_key}"}
async with session.get(url, headers=headers) as response:
return await response.text()
client = APIClient("my-secret-key")
df = daft.from_pydict({"urls": ["https://api.example.com/1", "https://api.example.com/2"]})
df = df.select(client.fetch_data(df["urls"]))When max_concurrency is set on a class with async methods, it controls the number of concurrent coroutines rather than the number of actor pool processes:
@daft.cls(max_concurrency=10)
class APIClient:
def __init__(self, api_key: str):
self.api_key = api_key
async def fetch_data(self, url: str) -> str:
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {self.api_key}"}
async with session.get(url, headers=headers) as response:
return await response.text()Generator Methods
from typing import Iterator
@daft.cls
class TokenGenerator:
def __init__(self, tokenizer_name: str):
from transformers import AutoTokenizer
self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
def tokenize(self, text: str) -> Iterator[str]:
tokens = self.tokenizer.tokenize(text)
for token in tokens:
yield token
tokenizer = TokenGenerator("bert-base-uncased")
df = daft.from_pydict({"text": ["Hello world", "Daft is great"]})
# Each row produces multiple tokens
df = df.select("text", tokenizer.tokenize(df["text"]).alias("token"))Multiple Instances
You can create multiple instances of the same class with different configurations:
@daft.cls
class Normalizer:
def __init__(self, mean: float, std: float):
self.mean = mean
self.std = std
def normalize(self, value: float) -> float:
return (value - self.mean) / self.std
normalizer_a = Normalizer(mean=10.0, std=2.0)
normalizer_b = Normalizer(mean=50.0, std=5.0)
df = daft.from_pydict({
"metric_a": [8, 10, 12],
"metric_b": [45, 50, 55]
})
df = df.select(
normalizer_a.normalize(df["metric_a"]).alias("norm_a"),
normalizer_b.normalize(df["metric_b"]).alias("norm_b")
)Eager Execution
Call methods with scalar arguments to execute immediately:
@daft.cls
class Calculator:
def __init__(self, multiplier: int):
self.multiplier = multiplier
def __call__(self, x: int) -> int:
return x * self.multiplier
calc = Calculator(10)
# Lazy execution - returns Expression
expr = calc(df["value"])
# Eager execution - initializes instance and returns result
result = calc(5) # Returns 50Best Practices
- Costly Initialization: Use
@daft.clswhen some an expensive initialization step can be reused across multiple rows (e.g., loading models, establishing connections). The initialization cost is amortized across all rows processed by each worker. - Simple Functions: Use
@daft.funcfor operations that don't require expensive setup. - Resource Management: Request GPUs only when needed with the
gpusparameter - Concurrency: Set
max_concurrencyto limit the number of concurrent instances. - Process Isolation: Use
use_process=Trueto run each instance in a separate process. This is useful for isolating instances when they are not thread-safe or to improve performance by avoiding GIL contention.
Batch Methods with `@daft.method.batch`
Similar to @daft.func.batch, use @daft.method.batch for batch processing in Daft classes:
import daft
from daft import DataType, Series
@daft.cls
class Model:
def __init__(self, model_name: str):
self.model = load_model(model_name)
@daft.method.batch(return_dtype=DataType.int64())
def predict(self, x: Series) -> Series:
predictions = self.model.predict(x.to_arrow().to_numpy())
return predictions
model = Model("resnet50")
df = daft.from_pydict({"x": [1, 2, 3]})
df = df.select(model.predict(df["x"]))Batch Sizing
You can configure the maximum number of rows in each batch by setting the batch_size parameter:
@daft.func.batch(return_dtype=DataType.int64(), batch_size=1024)
def batch_func_with_batch_size(x: Series) -> Series:
assert len(x) <= 1024
return xConsiderations for tuning batch size:
- Avoiding out-of-memory errors: Large batches can exhaust memory, especially with large rows (e.g., images, embeddings) or when using GPUs.
- Improving performance: The batch size for your function depends on your workload. Experiment to find the optimal size.
- GPU utilization: Match your batch size to your model's preferred inference batch size for best GPU performance.
Custom Code Func (`docs/custom-code/func.md`)
Stateless UDFs with `@daft.func`
When Daft's built-in functions aren't sufficient for your needs, the @daft.func and @daft.cls decorators let you run your own Python code over each row of data. Simply decorate a Python function or class, and it becomes usable in Daft DataFrame operations.
Quick Example
import daft
@daft.func
def add_and_format(a: int, b: int) -> str:
return f"Sum: {a + b}"
df = daft.from_pydict({"x": [1, 2, 3], "y": [4, 5, 6]})
df = df.select(add_and_format(df["x"], df["y"]))
df.show()╭───────────╮
│ x │
│ --- │
│ Utf8 │
╞═══════════╡
│ Sum: 5 │
├╌╌╌╌╌╌╌╌╌╌╌┤
│ Sum: 7 │
├╌╌╌╌╌╌╌╌╌╌╌┤
│ Sum: 9 │
╰───────────╯Function Variants
Daft supports multiple function variants to optimize for different use cases:
- Row-wise (default): Regular Python functions process one row at a time
- Async row-wise: Async Python functions process rows concurrently
- Generator: Generator functions produce multiple output rows per input row
- Batch (
@daft.func.batch): Process entire batches of data withdaft.Seriesfor high performance
Daft automatically detects which variant to use for regular functions based on your function signature. For batch functions, you must use the @daft.func.batch decorator.
Row-wise Functions
Row-wise functions are the default variant. They process one row at a time and return one value per row.
import daft
@daft.func
def multiply(a: int, b: int) -> int:
return a * b
df = daft.from_pydict({"x": [1, 2, 3], "y": [10, 20, 30]})
df = df.select(multiply(df["x"], df["y"]))
df.show()╭───────╮
│ x │
│ --- │
│ Int64 │
╞═══════╡
│ 10 │
├╌╌╌╌╌╌╌┤
│ 40 │
├╌╌╌╌╌╌╌┤
│ 90 │
╰───────╯Type Inference
Daft automatically infers the return type from your function's type hint:
@daft.func
def tokenize(text: str) -> list[int]:
vocab = {char: i for i, char in enumerate(set(text))}
return [vocab[char] for char in text]
df = daft.from_pydict({"text": ["hello", "world"]})
df = df.select(tokenize(df["text"]))
# The return type is automatically inferred as List[Int64]
print(df.schema())If you need to override the inferred type, use the return_dtype parameter:
@daft.func(return_dtype=daft.DataType.int32())
def add(a: int, b: int) -> int:
return a + bMixing Expressions and Literals
You can mix DataFrame expressions with literal values:
@daft.func
def add_constant(value: int, constant: int) -> int:
return value + constant
df = daft.from_pydict({"x": [1, 2, 3]})
df = df.select(add_constant(df["x"], 100)) # constant is a literal
df.show()╭───────╮
│ x │
│ --- │
│ Int64 │
╞═══════╡
│ 101 │
├╌╌╌╌╌╌╌┤
│ 102 │
├╌╌╌╌╌╌╌┤
│ 103 │
╰───────╯Keyword Arguments
Functions with default arguments work as expected:
@daft.func
def format_number(value: int, prefix: str = "$", suffix: str = "") -> str:
return f"{prefix}{value}{suffix}"
df = daft.from_pydict({"amount": [10, 20, 30]})
# Use defaults
df.select(format_number(df["amount"])).show()
# Override with literals
df.select(format_number(df["amount"], prefix="€", suffix=" EUR")).show()
# Override with expressions
df.select(format_number(df["amount"], suffix=df["amount"].cast(daft.DataType.string()))).show()Eager Evaluation
When called without any expressions, functions execute immediately:
@daft.func
def add(a: int, b: int) -> int:
return a + b
# This executes immediately and returns 8
result = add(3, 5)
print(result) # 8
# This returns a Daft Expression
expr = add(df["x"], df["y"])Async Row-wise Functions
Decorate async functions to enable concurrent execution across rows:
import daft
import asyncio
import aiohttp
@daft.func
async def fetch_url(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
df = daft.from_pydict({
"urls": [
"https://api.example.com/1",
"https://api.example.com/2",
"https://api.example.com/3",
]
})
# Requests are made concurrently
df = df.select(fetch_url(df["urls"]))Use max_concurrency to limit the number of concurrent coroutines, for example to rate-limit API calls:
@daft.func(max_concurrency=10)
async def fetch_url(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()Generator Functions
Generator functions use yield to produce multiple output rows per input row. Other columns in the DataFrame are automatically broadcast to match the number of generated values. You may only use one generator function per DataFrame operation.
import daft
from typing import Iterator
@daft.func
def repeat_value(value: str, count: int) -> Iterator[str]:
for _ in range(count):
yield value
df = daft.from_pydict({
"id": [1, 2, 3],
"word": ["hello", "world", "daft"],
"times": [2, 3, 1]
})
df = df.select("id", repeat_value(df["word"], df["times"]))
df.show()╭───────┬───────╮
│ id ┆ word │
│ --- ┆ --- │
│ Int64 ┆ Utf8 │
╞═══════╪═══════╡
│ 1 ┆ hello │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 1 ┆ hello │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 2 ┆ world │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 2 ┆ world │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 2 ┆ world │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ 3 ┆ daft │
╰───────┴───────╯Notice how the id column values are repeated to match the number of generated values.
Type Hints for Generators
Use Iterator[T] or Generator[T, None, None] type hints to indicate the yielded type:
from typing import Iterator
@daft.func
def split_text(text: str) -> Iterator[str]:
for word in text.split():
yield wordAlternatively, specify the return type explicitly:
@daft.func(return_dtype=daft.DataType.string())
def split_text(text: str):
for word in text.split():
yield wordHandling Empty Generators
If a generator yields no values for a particular input row, a null value is inserted:
@daft.func
def yield_if_positive(value: int) -> Iterator[int]:
if value > 0:
yield value
df = daft.from_pydict({"x": [-1, 0, 5, 10]})
df = df.select(yield_if_positive(df["x"]))
df.show()╭───────╮
│ x │
│ --- │
│ Int64 │
╞═══════╡
│ None │
├╌╌╌╌╌╌╌┤
│ None │
├╌╌╌╌╌╌╌┤
│ 5 │
├╌╌╌╌╌╌╌┤
│ 10 │
╰───────╯Batch UDFs with `@daft.func.batch`
For performance-critical operations, batch UDFs process entire batches of data at once using daft.Series objects instead of individual values. This allows you to leverage optimized libraries like PyArrow or NumPy for efficient vectorized operations.
When to Use Batch UDFs
Use batch UDFs when:
- Performance is critical: Vectorized operations may be significantly faster than row-wise processing
- Working with optimized libraries: You want to use PyArrow compute functions, NumPy operations, or other libraries that support vectorized operations on batch data
- Running batch inference: You are running a model that supports batch inference
Basic Batch UDF
Batch UDFs receive daft.Series objects as arguments and return a daft.Series, list, numpy.ndarray, or pyarrow.Array:
import daft
from daft import DataType, Series
@daft.func.batch(return_dtype=DataType.int64())
def add_series(a: Series, b: Series) -> Series:
import pyarrow.compute as pc
# Convert to PyArrow for efficient computation
a_arrow = a.to_arrow()
b_arrow = b.to_arrow()
result = pc.add(a_arrow, b_arrow)
return result
df = daft.from_pydict({"x": [1, 2, 3], "y": [4, 5, 6]})
df = df.select(add_series(df["x"], df["y"]))
df.show()╭───────╮
│ x │
│ --- │
│ Int64 │
╞═══════╡
│ 5 │
├╌╌╌╌╌╌╌┤
│ 7 │
├╌╌╌╌╌╌╌┤
│ 9 │
╰───────╯Mixing Series and Scalar Arguments
Batch UDFs can accept both Series and scalar arguments. Scalar arguments are passed through without modification:
@daft.func.batch(return_dtype=DataType.int64())
def add_constant(a: Series, constant: int) -> Series:
import pyarrow.compute as pc
a_arrow = a.to_arrow()
result = pc.add(a_arrow, constant)
return result
df = daft.from_pydict({"x": [1, 2, 3]})
df = df.select(add_constant(df["x"], 100))
df.show()╭───────╮
│ x │
│ --- │
│ Int64 │
╞═══════╡
│ 101 │
├╌╌╌╌╌╌╌┤
│ 102 │
├╌╌╌╌╌╌╌┤
│ 103 │
╰───────╯Eager Evaluation
Like regular functions, batch UDFs execute immediately when called with scalars:
@daft.func.batch(return_dtype=DataType.int64())
def multiply(a: Series, b: Series) -> Series:
import pyarrow.compute as pc
a_arrow = a.to_arrow()
b_arrow = b.to_arrow()
result = pc.multiply(a_arrow, b_arrow)
return result
# Lazy execution - returns Expression
expr = multiply(df["x"], df["y"])
# Eager execution - computes immediately
result = multiply(5, 10) # Returns 50Resources, Concurrency, and Error Handling
@daft.func and @daft.func.batch accept a common set of keyword arguments for concurrency control, scheduling, and error handling:
@daft.func(
cpus=2, # Each invocation needs 2 CPUs
gpus=1, # Each invocation needs 1 GPU
use_process=True, # Run the function in a subprocess instead of a thread
max_retries=3, # Retry a failing invocation up to 3 times with backoff
on_error="log", # On final failure, log the exception and emit None
)
def call_flaky_api(url: str) -> str:
import requests
return requests.get(url).text`cpus` and `gpus`
cpus and gpus are used for concurrency control and scheduling — not placement. Daft uses them to decide how many invocations of your function can run in parallel on a given machine.
If you annotate a function with cpus=2 and it runs on a machine with 4 CPUs, Daft runs at most 2 invocations in parallel on that machine. gpus works the same way: @daft.func(gpus=1) on a machine with 2 GPUs means at most 2 concurrent invocations.
Both accept fractional values (e.g. cpus=0.5, gpus=0.5). gpus values above 1.0 must be integers.
`max_concurrency`
max_concurrency controls the maximum number of concurrent invocations of an async @daft.func or @daft.func.batch. It does not apply to synchronous (non-async) UDFs — setting it on a sync function raises.
@daft.func(max_concurrency=10)
async def fetch_url(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()`use_process`
Runs the function in a subprocess instead of a thread on the main process. Use this when your function is not thread-safe or holds the GIL heavily. The default (None) lets Daft pick at runtime based on observed performance.
`max_retries` and `on_error`
Control what happens when a function invocation raises an exception.
max_retries=Nretries failing invocations up toNtimes with exponential backoff starting at 100 ms, doubling each attempt, capped at 60 s, with ±25% jitter. If the raised exception is adaft.ai.utils.RetryAfterError, the specified retry-after delay is honored instead of the default backoff.on_errordecides what to do after retries are exhausted:"raise"(default) — fail the query."log"— log the exception and emitNonefor that invocation."ignore"— silently emitNonefor that invocation.
Advanced Features
Unnesting Struct Returns
When your function returns a struct (dictionary), you can use unnest=True to automatically expand the struct fields into separate columns:
import daft
@daft.func(
return_dtype=daft.DataType.struct({
"first": daft.DataType.string(),
"last": daft.DataType.string(),
"age": daft.DataType.int64()
}),
unnest=True
)
def parse_person(full_name: str, age: int):
parts = full_name.split()
return {"first": parts[0], "last": parts[1], "age": age}
df = daft.from_pydict({
"name": ["Alice Smith", "Bob Jones"],
"age": [30, 25]
})
df = df.select(parse_person(df["name"], df["age"]))
df.show()╭───────┬───────┬───────╮
│ first ┆ last ┆ age │
│ --- ┆ --- ┆ --- │
│ Utf8 ┆ Utf8 ┆ Int64 │
╞═══════╪═══════╪═══════╡
│ Alice ┆ Smith ┆ 30 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ Bob ┆ Jones ┆ 25 │
╰───────┴───────┴───────╯Without unnest=True, you would get a single column containing struct values.
Combining Generators with Unnest
You can combine generator functions with unnest=True to yield multiple structs that get expanded into columns:
from typing import Iterator
@daft.func(
return_dtype=daft.DataType.struct({
"index": daft.DataType.int64(),
"char": daft.DataType.string()
}),
unnest=True
)
def enumerate_chars(text: str) -> Iterator[dict]:
for i, char in enumerate(text):
yield {"index": i, "char": char}
df = daft.from_pydict({"word": ["hi", "bye"]})
df = df.select(enumerate_chars(df["word"]))
df.show()╭───────┬──────╮
│ index ┆ char │
│ --- ┆ --- │
│ Int64 ┆ Utf8 │
╞═══════╪══════╡
│ 0 ┆ h │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌┤
│ 1 ┆ i │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌┤
│ 0 ┆ b │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌┤
│ 1 ┆ y │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌┤
│ 2 ┆ e │
╰───────┴──────╯Custom Code Gpu (`docs/custom-code/gpu.md`)
Working with GPUs
For GPU work, always reach for @daft.cls: it initializes your model once in __init__ and reuses it across rows, so the expensive model load is amortized over the whole query. Request a GPU with the gpus parameter on the class decorator.
Basic pattern
import daft
from daft import DataType, Series
@daft.cls(gpus=1)
class Embedder:
def __init__(self, model_name: str):
from sentence_transformers import SentenceTransformer
self.model = SentenceTransformer(model_name).cuda()
@daft.method.batch(return_dtype=DataType.list(DataType.float32()), batch_size=64)
def encode(self, text: Series) -> list[list[float]]:
return self.model.encode(text.to_pylist()).tolist()
embedder = Embedder("all-MiniLM-L6-v2")
df = daft.from_pydict({"text": ["hello", "world", "daft"]})
df = df.select(embedder.encode(df["text"]))The class is initialized once per Python worker; the model stays resident on the GPU for the whole query.
Packing multiple invocations per GPU
gpus accepts fractional values up to 1.0. Use this when a single invocation cannot saturate the GPU — for example, a small model with meaningful preprocessing overhead.
@daft.cls(gpus=0.5, max_concurrency=2)
class SmallModel:
def __init__(self, name: str):
import torch
self.model = torch.load(name).cuda()
@daft.method.batch(return_dtype=DataType.float64(), batch_size=16)
def infer(self, x: Series) -> Series:
return self.model(x.to_arrow().to_numpy())With gpus=0.5 and max_concurrency=2, two instances share one physical GPU. Daft isolates CUDA_VISIBLE_DEVICES per process, so device ordinals stay consistent inside each instance.
Values above 1.0 must be integers (e.g. gpus=2 for model parallelism across two GPUs).
Examples by model family
Sentence embeddings (Sentence Transformers)
@daft.cls(gpus=1)
class SentenceEmbedder:
def __init__(self, model_name: str = "BAAI/bge-large-en-v1.5"):
from sentence_transformers import SentenceTransformer
self.model = SentenceTransformer(model_name, device="cuda")
@daft.method.batch(return_dtype=DataType.list(DataType.float32()), batch_size=128)
def embed(self, text: Series) -> list[list[float]]:
return self.model.encode(text.to_pylist(), batch_size=128).tolist()Image classification / CLIP (Transformers)
@daft.cls(gpus=1)
class CLIPScorer:
def __init__(self, model_name: str = "openai/clip-vit-base-patch32"):
import torch
from transformers import CLIPModel, CLIPProcessor
self.model = CLIPModel.from_pretrained(model_name).to("cuda").eval()
self.processor = CLIPProcessor.from_pretrained(model_name)
self.torch = torch
@daft.method.batch(return_dtype=DataType.list(DataType.float32()), batch_size=32)
def embed_images(self, image_bytes: Series) -> list[list[float]]:
from io import BytesIO
from PIL import Image
images = [Image.open(BytesIO(b)).convert("RGB") for b in image_bytes.to_pylist()]
inputs = self.processor(images=images, return_tensors="pt").to("cuda")
with self.torch.no_grad():
features = self.model.get_image_features(**inputs)
return features.cpu().tolist()Text generation (vLLM)
For production-grade LLM inference, Daft ships a built-in vLLM integration — see AI Functions › Prompt. For smaller custom generation tasks:
@daft.cls(gpus=1)
class Generator:
def __init__(self, model_name: str = "Qwen/Qwen2.5-1.5B-Instruct"):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype=torch.bfloat16, device_map="cuda"
).eval()
self.torch = torch
@daft.method(return_dtype=DataType.string())
def generate(self, prompt: str) -> str:
inputs = self.tokenizer(prompt, return_tensors="pt").to("cuda")
with self.torch.no_grad():
output = self.model.generate(**inputs, max_new_tokens=128)
return self.tokenizer.decode(output[0], skip_special_tokens=True)Whisper (audio transcription)
@daft.cls(gpus=1)
class Whisper:
def __init__(self, model_name: str = "openai/whisper-large-v3"):
import torch
from transformers import pipeline
self.pipe = pipeline(
"automatic-speech-recognition",
model=model_name,
torch_dtype=torch.float16,
device="cuda",
)
@daft.method(return_dtype=DataType.string())
def transcribe(self, audio_path: str) -> str:
return self.pipe(audio_path)["text"]Choosing a batch size
GPU throughput is batch-size sensitive. A few rules of thumb:
- Start by matching the batch size to the model's preferred inference batch size.
- Halve it if you hit out-of-memory errors — large rows (images, embeddings) eat VRAM fast.
- Increase it gradually while watching GPU utilization: if you are under 80%, the GPU is waiting on you.
See Classes & Methods › Batch Sizing for additional considerations.
Retries and error handling
GPU inference can fail for reasons unrelated to your code — OOM, transient driver errors, preempted instances. Use max_retries and on_error to keep long queries going:
@daft.cls(gpus=1, max_retries=2, on_error="log")
class Model:
...Custom Code Migration (`docs/custom-code/migration.md`)
Migrating to Daft's new UDF API
Daft now offers a new UDF API via the @daft.func and @daft.cls decorators, replacing the legacy @daft.udf decorator, which was removed in Daft 0.8.0. The new API is more powerful and Pythonic.
This guide will walk you through the steps to migrate your existing UDFs to the new API.
Function UDF
Python functions decorated with @daft.udf can be easily converted into a batch function using the @daft.func.batch decorator.
# ====== Legacy API ======
@daft.udf(...)
def my_func(a: daft.Series, b: daft.Series):
...
# ====== New API ======
@daft.func.batch(...)
def my_func(a: daft.Series, b: daft.Series):
...Then, they can be used exactly the same as before.
Just like with legacy UDFs, inputs to batch functions are daft.Series objects, and the same return types are supported as well: daft.Series, list, numpy.ndarray, or pyarrow.Array.
Most decorator parameters also have equivalent parameters in the legacy UDF decorator. See the Decorator Parameters section for more details.
Class UDF
Creation
Python classes decorated with @daft.udf can be easily converted into a class UDF with a batch method using the @daft.cls and @daft.method.batch decorators.
# ====== Legacy API ======
@daft.udf(...)
class MyClass:
def __init__(self):
...
def __call__(self, value: daft.Series):
...
# ====== New API ======
@daft.cls(...)
class MyClass:
def __init__(self):
...
@daft.method.batch(...)
def __call__(self, value: daft.Series):
...Usage
Unlike legacy class UDFs, which can be called directly as a function, for new class UDFs, you must first create an instance of the class and then call the method on the instance.
# ====== Legacy API ======
df.select(
MyClass(df["value"])
)
# ====== New API ======
my_instance = MyClass()
df.select(
my_instance(df["value"])
)Instead of using .with_init_args(...) to specify the arguments to the __init__ method, you can set those arguments in the construction of the class instance instead.
# ====== Legacy API ======
MyClassWithArgs = MyClass.with_init_args(arg1=1, arg2=2)
df.select(
MyClassWithArgs(df["value"])
)
# ====== New API ======
my_instance_with_args = MyClass(arg1=1, arg2=2)
df.select(
my_instance_with_args(df["value"])
)!!! note Class Methods
Whereas legacy class UDFs require the implementation of the __call__ method, new class UDFs allow you to implement other methods in the class and use them as UDFs.
For example, here is a class with a `generate` method that can be used as a UDF:
```python
@daft.cls(...)
class MyClass:
def __init__(self):
...
def generate(self, value: daft.Series):
...
```
Then, you can use the `generate` method as follows:
```python
my_instance = MyClass()
df.select(
my_instance.generate(df["value"])
)
```Decorator Parameters
The following parameters stay the same between the legacy and new APIs:
- return_dtype
- batch_size
- use_process
- ray_options
For these parameters, here's what you can do with the new API:
- concurrency: The new API offers a
max_concurrencyparameter instead, which guarantees that at mostmax_concurrencyinstances of the UDF will be running at any given time, instead of exactlyconcurrencyinstances. - num_cpus: The new API offers a
cpusparameter on@daft.func,@daft.func.batch, and@daft.clswith the same placement semantics. Fractional values (e.g.0.5) are supported. - num_gpus: The new API offers a
gpusparameter on@daft.func,@daft.func.batch, and@daft.clswith the same placement semantics. Fractional values up to 1.0 are supported. - memory_bytes: The new API currently has no replacement for
memory_bytes.ray_options={"memory": ...}is explicitly rejected (see #6711). If you were usingmemory_bytesprimarily to limit concurrency, usemax_concurrencyinstead.
New parameters (no legacy equivalent)
The new API adds two parameters for controlling error handling that had no equivalent in @daft.udf:
- max_retries: Retry failing calls with exponential backoff (100 ms → 60 s, ±25% jitter). Also honors
daft.ai.utils.RetryAfterErrorfor rate-limit-aware retries. - on_error:
"raise"(default),"log", or"ignore". Controls behavior once retries are exhausted —"log"and"ignore"emitNonefor the failing row so the query keeps running.
Both are available on @daft.func, @daft.func.batch, and @daft.cls. (@daft.method / @daft.method.batch accept the kwargs but currently ignore them — set the class-level value on @daft.cls instead.) See the Resources, Concurrency, and Error Handling section for details.
New Features
Here are some features that are available in the new API that may help simplify your code and improve performance during your migration.
See the main Function UDF and Class UDF pages for a detailed description of the new API.
Row-wise Functions
If you find that your UDF is simply iterating over the rows of the input data and computing a result for each row without any vectorized or batch operations, consider implementing it as a row-wise function using the @daft.func decorator.
Row-wise functions receive a single row of input data at a time, and return a single value for that row. Daft will automatically handle batching and conversion between Daft and Python types under the hood.
Example:
# ====== Legacy Batch API ======
@daft.udf(return_dtype=daft.DataType.int64())
def my_sum(a: daft.Series, b: daft.Series):
result = []
for a_val, b_val in zip(a, b):
result.append(a_val + b_val)
return result
# ====== New Row-wise API ======
@daft.func
def my_sum(a: int, b: int) -> int:
return a + bReturn Type Inference
With row-wise functions, Daft will also automatically infer the return type of the function based on the Python type annotations. For example, in the above example, by specifying -> int, Daft will automatically infer the return dtype to be daft.DataType.int64().
The return_dtype parameter is still supported, but it is not required. See the Type Conversions page for a mapping from Python types to Daft types.
Async Functions
The new UDF API supports async Python functions natively for both row-wise and batch functions. Simply specify the async keyword in front of the function definition and use it like a regular Daft function.
Daft will handle the asynchronous execution of the function under the hood, so if you are calling to async functions from within your UDF, you can now just await them directly.
Example:
@daft.func
async def my_api_call(prompt: str) -> str:
response = await make_api_call(prompt)
return response.textKnown Limitations
- The new API does not yet expose a
memory_bytesparameter, andray_options={"memory": ...}is explicitly rejected (#6711). If you were usingmemory_bytesprimarily to bound concurrency, prefermax_concurrency. True memory-based placement on Ray is not currently supported by the new API; follow #6711 for updates.
If you have any questions or feedback about the new UDF API, please submit an issue on GitHub or reach out to us on Slack.
Custom Code Udaf (`docs/custom-code/udaf.md`)
Aggregate UDFs with `@daft.udaf`
When Daft's built-in aggregation functions (sum, mean, count, etc.) aren't sufficient, @daft.udaf lets you define custom aggregations in Python. UDAFs work with groupby().agg() and global agg(), and support Daft's three-stage aggregation pipeline for efficient distributed execution.
Quick Example
import daft
from daft import DataType, Series
@daft.udaf(return_dtype=DataType.float64(), state=DataType.float64())
class MySum:
def aggregate(self, values: Series) -> float:
return sum(values.to_pylist())
def combine(self, states: Series) -> float:
return sum(states.to_pylist())
def finalize(self, state: float) -> float:
return state
my_sum = MySum()
df = daft.from_pydict({"cat": ["a", "a", "b", "b"], "val": [1.0, 2.0, 3.0, 4.0]})
df.groupby("cat").agg(my_sum(daft.col("val")).alias("total")).show()╭──────┬─────────╮
│ cat ┆ total │
│ --- ┆ --- │
│ Utf8 ┆ Float64 │
╞══════╪═════════╡
│ a ┆ 3.0 │
├╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ b ┆ 7.0 │
╰──────┴─────────╯How It Works
A UDAF class defines a three-stage aggregation pipeline:
Aggregation: aggregate(inputs) -> partial state
Combination: combine(states) -> merged state (associative & commutative)
Finalization: finalize(state) -> final outputaggregate(*inputs: Series) -> value | dict— Aggregation stage. Receives input columns asSeriesobjects, returns a partial state value.combine(states: Series | dict[str, Series]) -> value | dict— Combination stage. Merges multiple partial states into one. Must be commutative and associative.finalize(state: value | dict) -> value— Finalization stage. Converts the final merged state into the output value.
Intermediate state is typed: the state parameter declares one data type per state component. The framework carries state between stages using these types, which lets Arrow and the query planner reason about intermediate results. Daft's planner automatically decomposes UDAFs into aggregation and finalization stages so partial aggregation happens close to the data.
Single-State UDAF
For simple accumulators, pass a single DataType as state:
@daft.udaf(return_dtype=DataType.float64(), state=DataType.float64())
class MySum:
def aggregate(self, values: Series) -> float:
return sum(values.to_pylist())
def combine(self, states: Series) -> float:
return sum(states.to_pylist())
def finalize(self, state: float) -> float:
return statecombinereceives aSeriesof partial state valuesfinalizereceives a single state value
Multi-State UDAF
For aggregations that need to track multiple fields (e.g., both a sum and a count for computing a mean), pass a dict of {name: DataType} as state:
@daft.udaf(
return_dtype=DataType.float64(),
state={"sum": DataType.float64(), "count": DataType.int64()},
)
class MyMean:
def aggregate(self, values: Series) -> dict:
vals = values.to_pylist()
return {"sum": float(sum(vals)), "count": len(vals)}
def combine(self, states: dict[str, Series]) -> dict:
return {
"sum": float(sum(states["sum"].to_pylist())),
"count": int(sum(states["count"].to_pylist())),
}
def finalize(self, state: dict) -> float:
return state["sum"] / state["count"]aggregatereturns a dict with one key per state fieldcombinereceives a dict mapping field names toSeriesof partial valuesfinalizereceives a dict mapping field names to single values
Parameterized UDAF
UDAFs can accept constructor arguments via __init__:
@daft.udaf(return_dtype=DataType.float64(), state=DataType.float64())
class BoundedSum:
def __init__(self, max_val: float):
self.max_val = max_val
def aggregate(self, values: Series) -> float:
return float(sum(min(v, self.max_val) for v in values.to_pylist()))
def combine(self, states: Series) -> float:
return sum(states.to_pylist())
def finalize(self, state: float) -> float:
return state
bounded_sum = BoundedSum(max_val=10.0)
df.groupby("cat").agg(bounded_sum(daft.col("val")).alias("total"))Multi-Input UDAF
UDAFs can consume multiple input columns:
@daft.udaf(return_dtype=DataType.float64(), state=DataType.float64())
class WeightedSum:
def aggregate(self, values: Series, weights: Series) -> float:
v = values.to_pylist()
w = weights.to_pylist()
return float(sum(a * b for a, b in zip(v, w)))
def combine(self, states: Series) -> float:
return sum(states.to_pylist())
def finalize(self, state: float) -> float:
return state
ws = WeightedSum()
df.groupby("cat").agg(ws(daft.col("val"), daft.col("weight")).alias("weighted_total"))Global Aggregation
UDAFs work without groupby for whole-table aggregation:
my_sum = MySum()
df = daft.from_pydict({"val": [1.0, 2.0, 3.0, 4.0]})
df.agg(my_sum(daft.col("val")).alias("total")).show()╭─────────╮
│ total │
│ --- │
│ Float64 │
╞═════════╡
│ 10.0 │
╰─────────╯Multiple UDAFs in One Aggregation
You can use multiple UDAFs (or the same UDAF on different columns) in a single .agg() call:
my_sum = MySum()
my_mean = MyMean()
df.groupby("cat").agg(
my_sum(daft.col("val")).alias("total"),
my_mean(daft.col("val")).alias("avg"),
).show()Custom Code Udfs (`docs/custom-code/udfs.md`)
Legacy User-Defined Functions (UDFs)
!!! danger "Removed in 0.8.0"
The legacy @daft.udf API was deprecated in Daft 0.7.0 and removed in Daft 0.8.0.
Use [`@daft.func`](func.md) and [`@daft.cls`](cls.md) instead, and see the [migration guide](migration.md) for how to update your code.Datasets Common Crawl (`docs/datasets/common-crawl.md`)
How to use Common Crawl with Daft
Common Crawl is one of the most important open web datasets, containing more than 250 billion web pages that span 18 years of crawls. Since 2020, it has become a critical source of training data for generative AI, with the vast majority of data used to train models like GPT-3 coming from Common Crawl. Mozilla Foundation's research noted that "Generative AI in its current form would probably not be possible without Common Crawl".
Daft provides a simple, performant, and responsible way to access Common Crawl data.
!!! warning "Warning"
These APIs are in beta and may be subject to change as the Common Crawl dataset continues to be developed.Access Methods
There are 3 main methods for accessing Common Crawl data:
| Method | URL Scheme | Best For | Credentials Required | Warnings |
|---|---|---|---|---|
| AWS S3 | s3://commoncrawl/... |
Inside AWS (us-east-1) | Yes | Data transfer fees apply if outside of AWS us-east-1. |
| HuggingFace Buckets | hf://buckets/commoncrawl/commoncrawl/... |
Cross-region, outside AWS, cheapest | No (Optional, preferred) | Only 2026+ crawls are available |
| HTTPS | https://data.commoncrawl.org/... |
Fallback when no credentials | No | Slowest option |
The access method chosen depends on the arguments passed to daft.datasets.common_crawl
- If
in_aws=Trueorsource="s3", Daft will read from AWS S3. - If
source="hf"orsource=Noneand thecrawlID is 2026 or later, Daft will read from the official HuggingFace bucket. - Otherwise, Daft will read from the HTTPS endpoint.
!!! warning "Warning"
`in_aws` is deprecated and will be removed in `v0.9.0`. Use `source="s3"` instead.Accessing Common Crawl from AWS
Common Crawl data is hosted by the Amazon Web Services' Open Data Sets Sponsorships program which makes it freely accessible.
However, access does require AWS authentication when downloading Common Crawl data from S3 directly. (Outside of AWS, you can access Common Crawl without an AWS account).
NOTE: When using daft.datasets.common_crawl, you must provide in_aws=True when accessing data within the AWS Cloud!
All Common Crawl data is stored in the us-east-1 region. It is highly recommended to access the data from an AWS service in the same region. From the Common Crawl website:
The connection to S3 should be faster and you avoid the minimal fees for inter-region data transfer (you have to send requests which are charged as outgoing traffic).
!!! warning
Using S3 from outside AWS incurs data transfer egress fees. Consider using the HuggingFace or HTTP sources instead.Authentication option 1: AWS Configurations
If your environment has AWS credentials configured, Daft will automatically detect and use them. The default order of precedence is:
- AWS environment variables (e.g.
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN) - Shared AWS CLI or SSO credentials (e.g.
~/.aws/config,~/.aws/credentials) - IAM roles for EC2, ECS, EKS, or other AWS services
Authentication option 2: Configure IOConfig
import daft
from daft.io import IOConfig, S3Config
io_config = IOConfig(
s3=S3Config(
key_id="your_access_key",
access_key="your_secret_key",
session_token="your_session_token",
region_name="us-east-1", # Access Common Crawl data where it's located.
)
)
# Use io_config when reading from the Common Crawl dataset
daft.datasets.common_crawl("CC-MAIN-2025-33", io_config=io_config, in_aws=True)
# NOTE: When using `daft.datasets.common_crawl`, you _must_ provide `in_aws=True` when accessing data within the AWS Cloud!Accessing Common Crawl from HuggingFace
If you are running outside of AWS, we recommend using the Hugging Face Buckets interface as it is the most accessible option. Daft will read from the official HuggingFace Common Crawl bucket if the crawl is available or if source="hf" is set. Currently, only crawls from 2026 onwards are available.
import daft
daft.datasets.common_crawl("CC-MAIN-2026-25", source="hf")You can also access WARC files directly via daft.read_warc:
import daft
# Read a single WARC file from HF Buckets
daft.read_warc("hf://buckets/commoncrawl/commoncrawl/crawl-data/CC-MAIN-2026-17/segments/1775805908305.14/warc/CC-MAIN-20260410081153-20260410111153-00000.warc.gz")
# Read WARC files matching a glob pattern
daft.read_warc("hf://buckets/commoncrawl/commoncrawl/crawl-data/CC-MAIN-2026-17/segments/*/warc/*.warc.gz")Authentication
For the public Common Crawl data, no authentication is needed; however, this may lead to more aggressive rate limiting. For best access, pass a token via IOConfig:
from daft.io import IOConfig
daft.read_warc(
"hf://buckets/commoncrawl/commoncrawl/crawl-data/CC-MAIN-2025-33/segments/...",
io_config=IOConfig(hf=daft.io.HuggingFaceConfig(token="hf_xxxxxxxxxxxxxxxxxxxx"))
)You can also authenticate using hf auth login; the token is picked up automatically.
Accessing Common Crawl from HTTP
As a fallback, we also support reading from the HTTP endpoint. This is the slowest option and is not recommended for production use, but is useful for testing and development.
import daft
# Use HTTPS as a fallback
daft.datasets.common_crawl("CC-MAIN-2025-33", source="http")Quickstart
The simplest way to get started with Common Crawl is to load a small sample of data:
import daft
daft.datasets.common_crawl("CC-MAIN-2025-33", num_files=1).show()/* Detailed source-code truncated for AI context efficiency. */Basic usage
Loading different content types (WARC, WET, WAT)
Common Crawl provides three types of content:
Raw Web ARChive (WARC) files (default) - Full HTTP responses with headers and content:
# Raw WARC data (default)
daft.datasets.common_crawl("CC-MAIN-2025-33", content="raw")
# or equivalently
daft.datasets.common_crawl("CC-MAIN-2025-33", content="warc")Extracted text, aka WET files - Plain text content extracted from web pages:
# Extracted text content
daft.datasets.common_crawl("CC-MAIN-2025-33", content="text")
# or equivalently
daft.datasets.common_crawl("CC-MAIN-2025-33", content="wet")Metadata, aka WAT files - Information about crawled pages without content:
# Metadata only
daft.datasets.common_crawl("CC-MAIN-2025-33", content="metadata")
# or equivalently
daft.datasets.common_crawl("CC-MAIN-2025-33", content="wat")Loading a subset of data
For quick testing and development, it's helpful to limit the number of crawl files accessed:
# Process only 1 crawl file for testing
daft.datasets.common_crawl("CC-MAIN-2025-33", num_files=1)Working with specific segments
Each crawl is split into 100 segments. You can target a specific segment:
daft.datasets.common_crawl("CC-MAIN-2025-33", segment="1754151279521.11")Data schema
Daft's Common Crawl dataset includes these key columns:
| Column | Type | Description |
|---|---|---|
WARC-Record-ID |
String | Unique identifier for each WARC record |
WARC-Target-URI |
String | The URL that was crawled |
WARC-Type |
String | Type of record (response, request, warcinfo, etc.) |
WARC-Date |
Timestamp | When the page was crawled |
WARC-Identified-Payload-Type |
String | MIME type of the content |
warc_content |
Binary | The actual content (HTML, text, etc.) |
warc_headers |
String | All WARC record headers as JSON |
For more details on the WARC file format, check out the WARC specification.
Examples
Analyzing content types
Find the most common MIME types in a crawl:
(
daft.datasets.common_crawl("CC-MAIN-2025-33", num_files=1)
.select(daft.col("WARC-Identified-Payload-Type"))
.groupby("WARC-Identified-Payload-Type")
.agg(daft.c