--- 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} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", error = FALSE, warning = FALSE, message = FALSE) has_airway <- requireNamespace("airway", quietly = TRUE) ``` # 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 Biocpkg("airway")` bulk RNA-seq dataset is a `RangedSummarizedExperiment`, so it also exercises the genomic-coordinate path. ```{r airway-check, echo=FALSE, results='asis'} if (!has_airway) { cat("_The `airway` package is not installed; the code below is shown but", "not evaluated._\n") } ``` ```{r roundtrip, eval=has_airway} 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, eval=has_airway} 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. ## Advanced: writing and attaching resources directly The rest of this section is for interoperating with other tools or writing data incrementally; skip ahead to "Operations run as SQL" below if you only need the `writeParquet()`/`readParquet()` round trip above. The `datapackage.json` that `writeParquet()` writes follows the [Frictionless Data Package](https://datapackage.org) specification, a widely used, language-agnostic way to describe a directory of data files. BiocDuckDB adds a few of its own fields on top (`dimension` and `layout`, used below), but the file otherwise reads like any other Frictionless package. In practice that means two things: another tool, or another language, can write COO Parquet files with a matching `datapackage.json` and `readParquet()` will read them directly, with no R object involved in producing them; and R code that already has COO Parquet on disk from some other process can attach to it directly (below), instead of writing it out again through `writeParquet()`. To build a `datapackage.json` from pieces written separately, for example a dataset streamed in one part at a time, or migrated from another store, collect the descriptor each `writeParquet()` call returns and pass them all to `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) ``` ### Streaming a flat resource from a cursor The pattern above assumes each piece already exists as a real in-memory object (a `data.frame`, an array). A source with no natural in-memory array representation -- a database cursor, an API page, anything read in a loop -- is a different case, and `writeStreamingResource()` is the dedicated writer for it: it owns the offset/part/append bookkeeping, types the row index wide enough for a resource that will end up over 2^31 rows (`index_max`), and writes an `_INCOMPLETE` marker so a crash mid-stream can never be read back as a complete resource by mistake. ```{r stream, eval=FALSE} cur <- DBI::dbSendQuery(con, "SELECT * FROM samples") res <- writeStreamingResource( blocks = function(i) { chunk <- DBI::dbFetch(cur, n = 10000) if (nrow(chunk) == 0) NULL else chunk # NULL signals end of stream }, path = file.path(dir, "samples"), dimension = "sample", index_max = expected_row_count) ``` `blocks()` is called with the 1-based part index and must return the next block as a `data.frame`, or `NULL` once exhausted; empty blocks are skipped without consuming a part number. This covers a flat (non-array) resource -- for a hive-partitioned coordinate array (a sparse assay) streamed from the same kind of source, see the "Streaming a hive-partitioned coordinate array" paragraph on `?writeParquet`, which documents the equivalent `writeParquet(slab, ..., append = TRUE, along = , offset = , group_offset = )` recipe. Each slab's own `grid` still defaults to a dense element budget, so for a sparse slab call `setAutoBlockSize()`/`setAutoBlockShape("scale")` *before* that `writeParquet()` call, not after -- `grid = NULL` sizes itself from whatever is currently set: ```{r stream-grid, eval=FALSE} density <- Matrix::nnzero(slab) / prod(dim(slab)) setAutoBlockSize(target_bytes_per_partition / density) setAutoBlockShape("scale") writeParquet(slab, path, append = TRUE, along = , offset = , group_offset = ) ``` See `?writeCoordArray`'s "Sizing grid for large or sparse arrays" section for why the default needs this correction and the hard ceiling on how far it can be pushed. 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__")) ``` `coo_path` is the directory holding the COO Parquet; `datacol` names the column holding the stored values, and `keycols` names the two index columns that locate each value in the matrix. 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, eval=has_airway} 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, eval=has_airway} c(in_memory = format(object.size(airway), units = "MB"), duckdb = format(object.size(airway_ddb), 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, eval=has_airway} 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*. Step 3's `runPCA()` is a partial exception to needing step 2 first: `r Biocpkg("BiocSingular")`'s `runSVD()` (and so `scran::fixedPCA()` / `scater::runPCA()`) already compute a correct PCA directly on `sce_ddb`, through the SQL-pushdown `%*%`/`crossprod` methods `r Biocpkg("DuckDBArray")` implements, no realization required for correctness. Speed is a separate question: the ordinary `BSPARAM = BiocSingular::IrlbaParam()` calls `%*%` once per solver iteration, so on a `DuckDBMatrix` that cost is paid repeatedly rather than once, covered in detail (with measured numbers) in *Benchmarking BiocDuckDB*. Passing `BSPARAM = DuckDBIrlbaParam()` instead closes that gap: it materializes just the matrix `runPCA()` touches (already filtered and HVG-selected here, not the whole `SingleCellExperiment` the way step 2's `as(sce_ddb, "SingleCellExperiment")` would) and drives `irlba` directly on it, falling back to the ordinary path whenever that materialization doesn't apply. It is a lighter-weight, PCA-specific alternative to full realization, not just a slower substitute for it. # 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() ```