--- title: "Introduction to DuckDBArray" author: - name: Patrick Aboyoun email: aboyounp@gene.com affiliation: Genentech, Inc. date: "Compiled: `r BiocStyle::doc_date()`; Modified: 6 July 2026" package: DuckDBArray vignette: > %\VignetteIndexEntry{1. Introduction to DuckDBArray} %\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 Modern single-cell and genomic assays routinely produce matrices that do not fit in memory. The 10x Genomics 1.3 million brain cell dataset, for example, is a `27,998 x 1,306,127` matrix of UMI counts --- over 36 billion entries, or more than 130 GB as an ordinary dense integer matrix. Even stored sparsely, datasets like this are awkward to hold in an R session alongside the intermediate objects an analysis creates. `r Biocpkg("DelayedArray")` solves half of this problem: it lets an array live *on disk* behind the ordinary R array API, so `dim()`, `[`, `rowSums()`, and arithmetic all work without loading the data into memory. What remains is the choice of **backend** --- the on-disk representation that actually stores and serves the data. `r Biocpkg("HDF5Array")` provides an HDF5 backend and `r Biocpkg("TileDBArray")` a TileDB one. `r Biocpkg("DuckDBArray")` provides a backend built on **columnar Parquet files queried through [DuckDB](https://duckdb.org)**. Rather than treating a count matrix as a grid of blocks to be read and processed chunk by chunk, DuckDBArray treats it as a *database table* and lets DuckDB's SQL engine do the work: a `rowSums()` becomes a `SUM ... GROUP BY`, a `rowVars()` a `VAR_SAMP`, and the query planner handles memory and multi-threading for you. As with any `r Biocpkg("DelayedArray")` backend, **none of these operations load the matrix into memory** --- they read only what they need from disk. This vignette is a practical introduction: how to create a `DuckDBMatrix`, how the familiar Bioconductor array operations behave on it, and when the DuckDB backend is a good fit. Two companion vignettes go further: - *Benchmarking DuckDBArray* compares the backend against in-memory, `r Biocpkg("HDF5Array")`, and `r Biocpkg("TileDBArray")` on real single-cell data. - *Implementing the DuckDBArray backend* documents the seed contract and SQL translation for developers extending the package. ## Installation ```{r install, eval=FALSE} if (!require("BiocManager")) install.packages("BiocManager") BiocManager::install("DuckDBArray") ``` ```{r load} library(DuckDBArray) ``` # Quick start A `DuckDBMatrix` is backed by a Parquet file in **coordinate (COO) form**: one row per stored entry, with key columns giving the position and a data column giving the value. For a small example we write such a file with `r CRANpkg("arrow")`, storing a gene-by-cell count matrix as `(gene, cell, count)` triples. ```{r quickstart-data} library(arrow) counts_df <- expand.grid(gene = paste0("Gene", 1:100), cell = paste0("Cell", 1:20), stringsAsFactors = FALSE) set.seed(1L) counts_df$count <- rpois(nrow(counts_df), lambda = 3) parquet_path <- tempfile(fileext = ".parquet") write_parquet(counts_df, parquet_path) ``` We construct the matrix by naming the data column and the key columns that index each dimension. The key columns carry the dimension names and their order: ```{r quickstart-construct} mat <- DuckDBMatrix(parquet_path, datacol = "count", keycols = list(gene = paste0("Gene", 1:100), cell = paste0("Cell", 1:20))) mat ``` It looks and behaves like a matrix, but the data stays on disk: ```{r quickstart-ops} dim(mat) rowSums(mat)[1:5] colMeans(mat)[1:5] ``` Nothing above read the whole matrix into memory: each summary is evaluated as a DuckDB query over the Parquet file. # Working with a DuckDBMatrix ## Writing a matrix to the DuckDB backend In practice you usually start from an existing (possibly sparse) matrix. Use `writeCoordArray()` to serialize it to Parquet, and `createDimTables()` to build the dimension lookup tables that let DuckDB prune to the rows and columns a query touches. Storing the matrix *transposed* puts features in columns, which Parquet's columnar layout makes cheap to scan feature-by-feature. ```{r write-matrix} library(Matrix) m <- Matrix(rpois(200 * 50, lambda = 1), nrow = 200, ncol = 50, sparse = TRUE) rownames(m) <- paste0("Gene", seq_len(nrow(m))) colnames(m) <- paste0("Cell", seq_len(ncol(m))) path <- tempfile() writeCoordArray(m, path) dimtbls <- createDimTables(m) mat2 <- DuckDBMatrix(path, datacol = "value", keycols = list(index1 = setNames(seq_len(nrow(m)), rownames(m)), index2 = setNames(seq_len(ncol(m)), colnames(m))), dimtbls = dimtbls) dim(mat2) ``` ## Subsetting Subsetting is delayed: it records the selection and is resolved only when values are needed. ```{r subset} sub <- mat[1:5, 1:4] sub as.matrix(sub) ``` ## Matrix statistics `r Biocpkg("DuckDBArray")` implements the `r Biocpkg("MatrixGenerics")` row/column summaries as SQL aggregations, so the usual functions work directly: ```{r matrixstats} library(MatrixGenerics) rowSums(mat)[1:5] rowVars(mat)[1:5] colSds(mat)[1:5] ``` Two helpers are added for count-based feature selection --- `rowNnzs()` / `colNnzs()` count non-zero entries (detection rate), and `rowDeviances()` computes binomial/Poisson deviance for highly-variable-gene selection ([Townes et al. 2019](https://doi.org/10.1186/s13059-019-1861-6)): ```{r feature-selection} rowNnzs(mat)[1:5] rowDeviances(mat, family = "binomial")[1:5] ``` ## Sparse data Because only stored entries live in the Parquet file, sparse matrices stay compact end to end. A `DuckDBMatrix` reports its sparsity and coerces to the familiar sparse containers when you need them in memory: ```{r sparse} is_sparse(mat2) as(mat2[1:5, 1:4], "dgCMatrix") ``` ## Lazy evaluation A `DuckDBMatrix` *is* a `r Biocpkg("DelayedArray")`, so delayed operations accumulate without touching the data, and materialize only on demand: ```{r lazy} transformed <- log1p(mat) * 2 class(transformed) as.matrix(transformed[1:3, 1:3]) ``` ## Realizing large results in blocks Most work never materializes the matrix: reductions and grouped statistics push down to SQL (see *Matrix statistics*), so they stay memory-bounded regardless of size. Whole-object coercions like `as.matrix()` or `as(x, "dgCMatrix")`, by contrast, build the **entire** result at once --- ideal when it fits, but not when it doesn't. When you need to consume a result too large for memory --- to feed a chunked/minibatch step, or to re-write it to another on-disk backend --- realize it a block at a time instead. Because a `DuckDBMatrix` is a `r Biocpkg("DelayedArray")`, the standard block machinery applies: `blockApply()` reads and processes the matrix one block at a time, and each block is a separate, **pruned** query (only that block's entries are read), so peak memory is bounded by a single block rather than the full result: ```{r block-stream} # one block in memory at a time; each block is a separate, pruned query block_sums <- blockApply(mat, function(block) sum(block)) sum(unlist(block_sums)) ``` `realize(x, BACKEND = "HDF5Array")` uses the same block-streaming path to write a large result to another on-disk format without ever holding it whole, and `DelayedArray::setAutoBlockSize()` tunes how much is read per block. # N-dimensional arrays The same backend serves arrays of any dimension through `DuckDBArray()`; a `DuckDBMatrix` is simply the two-dimensional case. Here the key columns index three dimensions: ```{r nd-array} arr_df <- expand.grid(i = 1:4, j = 1:3, k = 1:2) arr_df$value <- rpois(nrow(arr_df), lambda = 2) arr_path <- tempfile(fileext = ".parquet") write_parquet(arr_df, arr_path) arr <- DuckDBArray(arr_path, datacol = "value", keycols = list(i = 1:4, j = 1:3, k = 1:2)) dim(arr) arr[1:2, 1:2, 1] ``` # When to use DuckDBArray DuckDBArray is a good fit when: - the matrix is **larger than memory**, or you want to keep memory free for the rest of the analysis; - the data is **sparse** (e.g. single-cell counts, scATAC-seq accessibility); - your workload is dominated by **columnar aggregations** --- row/column summaries, grouped statistics, QC and feature selection; - the data already lives on disk as **Parquet**, or benefits from a format that other tools (Python, Julia, cloud query engines) can read directly. Other backends remain the better choice when data fits comfortably in memory (an in-memory `dgCMatrix` is hard to beat for small data), or for workloads built around dense linear algebra or heavy random element access. For a head-to-head comparison with in-memory, `r Biocpkg("HDF5Array")`, and `r Biocpkg("TileDBArray")` backends --- including where each one wins --- see the *Benchmarking DuckDBArray* vignette. To understand or extend the backend itself, see *Implementing the DuckDBArray backend*. # Session information ```{r sessioninfo} sessionInfo() ```