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

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

# Introduction

Bioconductor's experiment containers --- `SummarizedExperiment`,
`SingleCellExperiment`, `MultiAssayExperiment` --- hold their assays, annotations,
and reduced dimensions in memory. For modern datasets that is increasingly
impractical: a million-cell single-cell experiment is tens of gigabytes, and a
multi-omics study multiplies that across assays.

`r Biocpkg("BiocDuckDB")` is the integration layer of the **BiocDuckDB** suite. It
provides two high-level functions ---

- **`writeParquet()`** serializes a Bioconductor experiment object to a structured
  directory of columnar **Parquet** files, and
- **`readParquet()`** reads that directory back as a **DuckDB-backed** experiment
  object

--- so an experiment keeps its assays on disk while presenting the *same*
Bioconductor API. Assays become `r Biocpkg("DuckDBArray")` matrices, `rowData` and
`colData` become `r Biocpkg("DuckDBDataFrame")`s, and `rowRanges` becomes a
`r Biocpkg("DuckDBGRanges")` list --- and operations on them run as SQL against the
Parquet files. The on-disk format is a self-describing
[Frictionless Data Package](https://frictionlessdata.io/), readable by other tools
and languages, not just R.

This vignette covers the round-trip workflow and the operations it enables. For the
SQL-optimized `r Biocpkg("scran")`/`r Biocpkg("scuttle")` analysis methods and how
they perform at scale, see *Benchmarking BiocDuckDB*.

## Installation

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

```{r load}
library(BiocDuckDB)
library(SummarizedExperiment)
```

# The round-trip

We write a `SummarizedExperiment` to Parquet and read it back as a DuckDB-backed
object. The `r CRANpkg("airway")` bulk RNA-seq dataset is a `RangedSummarizedExperiment`,
so it also exercises the genomic-coordinate path.

```{r roundtrip}
data(airway, package = "airway")

se_path <- file.path(tempdir(), "airway_se")
writeParquet(airway, se_path)

airway_ddb <- readParquet(se_path)
class(airway_ddb)
dim(airway_ddb)
```

## The storage layout

`writeParquet()` lays out each component as its own Parquet under a
Frictionless-style directory, described by a `datapackage.json`:

```{r layout}
head(list.files(se_path, recursive = TRUE), 10)
```

Assays are stored in **coordinate (COO) form** --- one row per stored entry with
`__feature__`/`__sample__` index columns and a `value` --- which is compact for the
sparse matrices typical of single-cell data, and lets DuckDB scan only what a query
touches.

## Targeting the storage contract

The `datapackage.json` layout is a stable [Frictionless](https://datapackage.org)
profile --- BiocDuckDB's extensions are additive and base-conformant --- so any
producer can *target* it and any consumer can *attach* it. These are the write and
read halves of an ingest/attach contract, exposed as public functions so a store
that already holds COO Parquet does not need to reconstruct an in-memory object to
interoperate with BiocDuckDB.

To assemble a manifest from resources written incrementally (a dataset streamed in
parts, or promoted from another store), collect the descriptors returned by the
primitive `writeParquet()` methods and call `writeDatapackage()`:

```{r ingest, eval=FALSE}
resources <- c(
    writeParquet(features, file.path(dir, "features"), dimension = "feature"),
    writeParquet(assays,   dir,                        dimension = "crossed"))
writeDatapackage("summarized_experiment", resources, dir)
```

Going the other way, the `DuckDBMatrix()` / `DuckDBArray()` / `DuckDBTable()`
constructors attach an existing COO Parquet in place --- wrapping it as a lazy,
out-of-core object without copying or materializing it:

```{r attach, eval=FALSE}
mat <- DuckDBMatrix(coo_path, datacol = "value",
                    keycols = c("__feature__", "__sample__"))
```

The documented `model` values and the `dimension` x `layout` dispatch that maps each
resource to its accessor are described on `?writeParquet` and `?readParquet`.

## Operations run as SQL

The DuckDB-backed object supports the standard API; component access, subsetting,
and summaries are pushed to DuckDB:

```{r operations}
assay(airway_ddb, "counts")
colData(airway_ddb)[, 1:3]

## subset on disk, then summarize
sub <- airway_ddb[1:1000, 1:4]
colSums(assay(sub, "counts"))
```

Because only stored values and the touched columns are read, the in-memory
footprint of the object stays small relative to the full data:

```{r memory}
c(in_memory = object.size(airway),
  duckdb    = object.size(airway_ddb)) |> format(units = "MB")
```

## Genomic coordinates are preserved

`writeParquet()` stores a `RangedSummarizedExperiment`'s `rowRanges` as Parquet
`LIST[]` columns, so they come back as a `r Biocpkg("DuckDBGRanges")` list with the
genomic API intact:

```{r ranges}
rr <- rowRanges(airway_ddb)
class(rr)
elementNROWS(rr)[1:8]      # exons per gene, queried from disk
```

# Single-cell data

Single-cell experiments are where the on-disk representation pays off most: the
matrices are large and sparse, so COO storage compresses well and the DuckDB-backed
object holds essentially only metadata. A small sparse `SingleCellExperiment`
illustrates the round-trip:

```{r sce}
library(SingleCellExperiment)
library(Matrix)

set.seed(1L)
counts <- Matrix(rpois(2000 * 500, lambda = 0.3), nrow = 2000, ncol = 500,
                 sparse = TRUE)
rownames(counts) <- paste0("Gene", seq_len(nrow(counts)))
colnames(counts) <- paste0("Cell", seq_len(ncol(counts)))
sce <- SingleCellExperiment(assays = list(counts = counts))

sce_path <- file.path(tempdir(), "demo_sce")
writeParquet(sce, sce_path)
sce_ddb <- readParquet(sce_path)

## QC filtering stays on disk (SQL), then summarize
totals <- colSums(assay(sce_ddb, "counts"))
keep <- sce_ddb[, totals > median(totals)]
dim(keep)
```

# The filter, realize, analyze pattern

For data larger than memory, the idiomatic workflow uses DuckDB for the parts it
does well --- filtering and summarizing on disk --- and only then realizes the
manageable subset into memory for the standard analysis pipeline:

```{r pattern, eval=FALSE}
library(scran)
library(scater)

sce_ddb <- readParquet("data/raw_counts")          # DuckDB-backed, low RAM

## 1. filter on disk (SQL-optimized)
keep <- colSums(assay(sce_ddb, "counts")) > 1000
sce_ddb <- sce_ddb[, keep]
detected <- rowSums(assay(sce_ddb, "counts") > 0)
sce_ddb <- sce_ddb[detected >= 10, ]

## 2. realize the filtered subset into memory
sce <- as(sce_ddb, "SingleCellExperiment")

## 3. standard in-memory pipeline
sce <- logNormCounts(sce)
dec <- modelGeneVar(sce)
sce <- runPCA(sce[getTopHVGs(dec, n = 2000), ])

## 4. persist results back to Parquet
writeParquet(sce, "data/processed")
```

Many `r Biocpkg("scran")`/`r Biocpkg("scuttle")` steps can run *directly* on the
DuckDB-backed object as SQL, before any realization --- that is the subject of
*Benchmarking BiocDuckDB*.

# Multi-omics

For a `MultiAssayExperiment`, write each experiment to its own directory and read
them back as DuckDB-backed experiments:

```{r mae, eval=FALSE}
library(MultiAssayExperiment)

writeParquet(rna_sce,     file.path(mae_path, "rna"))
writeParquet(protein_se,  file.path(mae_path, "protein"))

mae <- MultiAssayExperiment(experiments = list(
    rna     = readParquet(file.path(mae_path, "rna")),
    protein = readParquet(file.path(mae_path, "protein"))))
```

Each modality stays on disk until accessed. Spatial experiments
(`MultiAssaySpatialExperiment`) are supported too, with their geometry written as
GeoParquet via `r Biocpkg("DuckDBSpatial")`.

# When to use BiocDuckDB

A good fit for experiments **larger than memory**, for **exploration, QC, and
filtering** of large datasets, and for a **self-describing, cross-language** on-disk
format (the Parquet files are readable by Python, Julia, and DuckDB directly).
Parquet is also a compact archival format --- typically several-fold smaller than
`.rds` for sparse data, and self-describing.

Realize a subset to an ordinary in-memory object for iterative algorithms with
random access patterns or dense linear algebra; and small datasets that fit
comfortably in memory need no backend at all.

# Session information

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