---
title: "Introduction to DuckDBDataFrame"
author:
- name: Patrick Aboyoun
  email: aboyounp@gene.com
  affiliation: Genentech, Inc.
date: "Compiled: `r BiocStyle::doc_date()`; Modified: 6 July 2026"
package: DuckDBDataFrame
vignette: >
  %\VignetteIndexEntry{1. Introduction to DuckDBDataFrame}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
output:
  BiocStyle::html_document:
    number_sections: true
    toc: true
    toc_depth: 2
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>",
                      error = FALSE, warning = FALSE, message = FALSE)
```

# Introduction

Bioconductor objects increasingly carry large *tabular* metadata: a
`SingleCellExperiment` with millions of cells has a `colData` of the same height, a
big sample sheet, per-feature annotations. Holding such a table fully in memory is
wasteful when a workflow only ever reads a few columns or a filtered subset of rows.

`r Biocpkg("DuckDBDataFrame")` is a `r Biocpkg("S4Vectors")` `DataFrame` backed by
[DuckDB](https://duckdb.org) over columnar **Parquet**. It behaves like an ordinary
`DataFrame`, `$`, `[`, `mcols()`, `cbind()`, but the data stays **on disk** and
operations are recorded as lazy SQL queries: you can subset, add computed columns,
and aggregate **without loading the table into memory**. Each column read comes back
only when you ask for it.

The typical pattern is that an upstream tool (or an earlier pipeline step) writes a
table, often larger than memory, to Parquet, and `DuckDBDataFrame` opens it in
place: you explore and query it with the familiar `DataFrame` API while DuckDB reads
only the columns and rows each operation touches.

It is the tabular foundation of the **BiocDuckDB** suite: `r Biocpkg("DuckDBArray")`
(DuckDB-backed `DelayedArray`) and `r Biocpkg("DuckDBGRanges")` (DuckDB-backed
`GRanges`) both build on it.

This vignette is a practical introduction. For the design of the underlying
`DuckDBTable` and how other packages extend it, see
*Design and extension of DuckDBDataFrame*.

## Relationship to arrow

If you have used `r CRANpkg("arrow")`, a `DuckDBDataFrame` is lazy over Parquet in
much the same way as an Arrow `FileSystemDataset`: the data stays on disk and
operations are deferred. The differences are in the interface and the engine:

- **No explicit `collect()`.** An Arrow dataset requires `collect()` (or
  `as.data.frame()`) to realize results before most R operations. A
  `DuckDBDataFrame` presents the full `DataFrame` API directly, `head()`, `[`,
  `$`, arithmetic, `mean()`, aggregation all work on the lazy object, and only the
  values you actually extract (e.g. `as.vector()`, `as.data.frame()`) are pulled
  into memory. Each operation is pushed down to DuckDB and evaluated on demand.
- **DuckDB engine, DataFrame API.** Computation runs in DuckDB's SQL engine rather
  than the Arrow compute kernels, and the object behaves like a Bioconductor
  `DataFrame` (so it drops into `SingleCellExperiment`, `SummarizedExperiment`,
  etc.) rather than a dplyr/Arrow pipeline. The tradeoff is a dependency on DuckDB
  and that results are materialized through it.
- **Multi-file datasets** are supported: point the constructor at a directory or a
  glob (e.g. `"data/*.parquet"`) and DuckDB scans the parts as one table.

## Installation

```{r install, eval=FALSE}
if (!require("BiocManager"))
    install.packages("BiocManager")
BiocManager::install("DuckDBDataFrame")
```

```{r load}
library(DuckDBDataFrame)
```

# Quick start

We write the built-in `mtcars` table to Parquet and open it as a
`DuckDBDataFrame`.

```{r quickstart}
library(arrow)
mtcars_df <- cbind(model = rownames(mtcars), mtcars)
path <- tempfile(fileext = ".parquet")
write_parquet(mtcars_df, path)

df <- DuckDBDataFrame(path, datacols = colnames(mtcars),
                      keycol = list(model = mtcars_df$model))
df
```

Only the `path` is required, `DuckDBDataFrame(path)` opens every column and uses
generated row numbers. The other arguments are optional refinements:

- **`datacols`** selects (and orders) which columns to expose; omit it to take all.
- **`keycol`** designates the *row key*, the `DuckDBDataFrame` equivalent of a
  `DataFrame`'s row names. It takes two forms: a **column name** already in the file
  (`keycol = "model"`), which promotes that column to the key; or a **named list of
  values** (`keycol = list(model = mtcars_df$model)`), which supplies the key from R,
  handy when the key is not stored as a column. Both give a `DataFrame` keyed by
  `model`; they differ only in where the key comes from (and therefore its row
  order). With no `keycol`, rows are addressed by generated numbers instead of names.
  The deeper key-dimension model is covered in *Design and extension of
  DuckDBDataFrame*.

It looks and behaves like a `DataFrame`, but the values live in the Parquet file:

```{r quickstart-ops}
dim(df)
df$mpg[1:5]
```

# Working with a DuckDBDataFrame

## Column access

`$` returns a single column as a `DuckDBColumn` (still lazy); `[` selects columns:

```{r columns}
df[, c("mpg", "cyl", "hp")]
```

## Row subsetting

Rows can be selected by name, by a logical column, or by position (note that
positional order is not guaranteed, as rows map to a set on disk):

```{r rows}
df[df$mpg > 25, c("mpg", "cyl")]
```

## Computed columns

Assigning an expression of existing columns records a new lazy column; nothing is
evaluated until the values are pulled:

```{r computed}
df$efficiency <- df$mpg / df$hp
df[1:3, c("mpg", "hp", "efficiency")]
```

This changes only the in-memory `DuckDBDataFrame` object: `efficiency` is stored as
a SQL expression (`mpg / hp`) in the object's query and computed on demand. **The
Parquet file on disk is not modified**, the new column exists only for this
object and any results derived from it. To persist a derived table (including
computed columns) back to disk, materialize it (`as.data.frame()`) and write it out
explicitly, e.g. with `arrow::write_parquet()`.

## Column metadata

`mcols()` works as it does for any `DataFrame`:

```{r metadata}
mcols(df) <- DataFrame(row.names = colnames(df),
                       label = sub("\\..*", "", colnames(df)))
mcols(df)[1:3, , drop = FALSE]
```

# Columns come in three flavors

Depending on the underlying Parquet type, extracting a column yields:

- a **`DuckDBColumn`** for atomic columns, vector-like and lazy
  (`length()`, `[`, arithmetic, `mean()`), materialized with `as.vector()`;
- a **`DuckDBAtomicList`** for DuckDB `LIST[]` columns (variable-length list
  columns), supporting `elementNROWS()`, `[[`, `unlist()`;
- a **`DuckDBEmbeddings`** for DuckDB `ARRAY[n]` columns (fixed-length numeric
  vectors, e.g. embeddings), which behaves like a matrix with one row per element.

```{r column-flavors}
eff <- df$mpg / df$hp     # DuckDBColumn
class(eff)
as.vector(eff)[1:5]       # materialize on demand
```

# Reaching for SQL directly

Because the backend is DuckDB, its full SQL function library is available.
`sql_fun()` lists functions applicable to a column, and `sql_call()` applies one:

```{r sql}
sql_call(df$mpg, "round", 0)[1:5]
```

For custom work you can reach the shared connection with `dbconn(df)` and run
arbitrary `DBI::dbGetQuery()` calls against it.

# When to use DuckDBDataFrame

A good fit when the table is **larger than memory** (or you want to keep memory
free), when the workload is **columnar** (filtering, aggregation, selecting a few
columns of a wide table), or when the data already lives on disk as **Parquet** that
other tools should read. An in-memory `DataFrame` remains preferable for small tables
and for row-wise or heavy random-access work.

For how the `DuckDBTable` abstraction works and how to build on it, see
*Design and extension of DuckDBDataFrame*.

# Session information

```{r sessioninfo}
sessionInfo()
```
