---
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}
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]
```

## Introspecting a Parquet file you did not write

The example above already knows `keycols` because it wrote `arr_path` itself.
Given a COO Parquet file produced by another tool, use `r CRANpkg("arrow")` to
find the key and data column names first: the schema names the columns and
their types.

```{r introspect-schema}
open_dataset(arr_path)$schema
```

`scanKeycols()` then scans the named key columns for their distinct values,
sorted, and returns them already in the `keycols` shape the
`DuckDBArray()`/`DuckDBMatrix()` constructors expect, so there's no need to
repeat the `distinct()`/`arrange()`/`collect()` steps by hand for each key
column:

```{r introspect-keycols}
scanKeycols(arr_path, keycols = c("i", "j", "k"))
```

```{r introspect-construct}
arr2 <- DuckDBArray(arr_path, datacol = "value",
                    keycols = scanKeycols(arr_path, c("i", "j", "k")))
dim(arr2)
```

# 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()
```
