Design and extension of DuckDBGRanges

Scope

This vignette is for developers: it documents how DuckDBGRanges represents genomic ranges on top of DuckDBDataFrame, how range operations become SQL, and how the classes are structured so you can maintain or extend the package. For day-to-day use see Introduction to DuckDBGRanges; for performance see Benchmarking DuckDBGRanges.

library(DuckDBGRanges)
library(GenomicRanges)

Architecture

DuckDBGRanges layers two classes on the DuckDBDataFrame foundation, each extending its in-memory GenomicRanges counterpart:

Class Extends Role
DuckDBGRanges GRanges genomic intervals with a DuckDB backend
DuckDBGRangesList GRangesList grouped features (transcripts, per-gene peaks)

Each holds its coordinates in a DuckDBDataFrame (from DuckDBDataFrame) describing a Parquet file, rather than in memory. Because a DuckDBGRanges is a GRanges, the full GenomicRanges API applies unchanged; the package overrides the operations that benefit from running as SQL, and falls back to materialization for the rest.

showClass("DuckDBGRanges")
#> Class "DuckDBGRanges" [package "DuckDBGRanges"]
#> 
#> Slots:
#>                                                                       
#> Name:            frame         seqinfo elementMetadata     elementType
#> Class: DuckDBDataFrame         Seqinfo       DataFrame       character
#>                       
#> Name:         metadata
#> Class:            list
#> 
#> Extends: 
#> Class "GenomicRanges", directly
#> Class "Ranges", by class "GenomicRanges", distance 2
#> Class "GenomicRanges_OR_missing", by class "GenomicRanges", distance 2
#> Class "GenomicRanges_OR_GenomicRangesList", by class "GenomicRanges", distance 2
#> Class "GenomicRanges_OR_GRangesList", by class "GenomicRanges", distance 2
#> Class "List", by class "GenomicRanges", distance 3
#> Class "Vector", by class "GenomicRanges", distance 4
#> Class "list_OR_List", by class "GenomicRanges", distance 4
#> Class "Annotated", by class "GenomicRanges", distance 5
#> Class "vector_OR_Vector", by class "GenomicRanges", distance 5

Slots

A DuckDBGRanges carries three slots:

  • frame — a DuckDBDataFrame with the coordinate columns (see The five required columns). Extra columns become metadata columns.
  • seqinfo — a Seqinfo with sequence lengths and genome build, kept materialized (it is small and needed for validation).
  • elementMetadata — an optional DuckDBDataFrame of metadata columns (mcols) sharing the same backend as frame.

DuckDBGRangesList has the same slots, but its coordinate columns are DuckDB LIST[] columns (one row per group) and its elementMetadata is per group rather than per range (see Grouped ranges as LIST columns).

The five required columns

Every DuckDBGRanges resolves to five coordinate columns, mirroring the components of a GRanges:

Column Meaning
seqnames sequence/chromosome name ("chr1", …)
start start position (1-based, inclusive)
end end position (1-based, inclusive)
width interval width (end - start + 1)
strand "+", "-", or "*"

The constructor names the columns backing these components; anything else named in mcols= becomes a metadata column. Only the columns an operation actually needs are ever read from the Parquet file.

gr <- GRanges(c("chr1:100-150:+", "chr1:200-275:-", "chr2:300-360:+"),
              gene_id = paste0("GENE", 1:3))
gr_df <- as.data.frame(gr)
gr_df$range_id <- paste0("range", seq_len(nrow(gr_df)))
gr_path <- tempfile(fileext = ".parquet"); arrow::write_parquet(gr_df, gr_path)

gr_ddb <- DuckDBGRanges(gr_path,
                        seqnames = "seqnames", start = "start",
                        end = "end", strand = "strand",
                        keycol = list(range_id = gr_df$range_id),
                        mcols = "gene_id")
gr_ddb
#> DuckDBGRanges object with 3 ranges and 1 metadata column:
#>           seqnames     start       end     width      strand |     gene_id
#>        <character> <integer> <integer> <integer> <character> | <character>
#> range1        chr1       100       150        51           + |       GENE1
#> range2        chr1       200       275        76           - |       GENE2
#> range3        chr2       300       360        61           + |       GENE3
#> -------
#> seqinfo: 2 sequences from an unspecified genome; no seqlengths

From range operations to SQL

The performance story rests on one idea: rather than materialize the ranges and run the in-memory algorithm, DuckDBGRanges translates the operations that map cleanly onto set-based queries into SQL on the underlying DuckDBDataFrame, so DuckDB does the scan and the arithmetic and only the result crosses back into R.

Coordinate filters (restrict(), logical subsetting) become WHERE clauses, which DuckDB pushes into the Parquet reader — so a region filter reads only the matching row groups instead of scanning everything:

-- restrict(x, start = s, end = e)  becomes
SELECT * FROM ranges WHERE start >= s AND end <= e

Overlap joins (findOverlaps() and friends) become a self- or cross-join on the interval-overlap predicate:

SELECT a.id, b.id
FROM ranges_a a JOIN ranges_b b
  ON a.seqnames = b.seqnames
  AND a.start <= b.end AND a.end >= b.start
  AND (a.strand = b.strand OR a.strand = '*' OR b.strand = '*')

Distance becomes a CASE expression over the gap between two intervals:

SELECT CASE
    WHEN a.end < b.start THEN b.start - a.end
    WHEN b.end < a.start THEN a.start - b.end
    ELSE 0
END AS distance
FROM ranges_a a, ranges_b b

Filters compose lazily — a chain of restrict() and logical subsets becomes a single query, evaluated only when results are pulled. Operations without a natural set-based form fall back to materializing the (already filtered) ranges to an in-memory GRanges, which is correct for any input and cheap once the data has been cut down. Benchmarking DuckDBGRanges shows which operations win in SQL and which are better left to the in-memory interval-tree implementations.

Grouped ranges as LIST columns

DuckDBGRangesList stores grouped features using DuckDB LIST[] columns: each row is one group, and the coordinate columns hold lists of per-range values.

transcript_id | seqnames        | start        | width       | strand
ENST001       | [chr1,chr1,chr1]| [100,200,300]| [50,75,60]  | [+,-,+]
ENST002       | [chr2,chr2]     | [150,250]    | [80,45]     | [-,+]

This is compact (one row per group instead of one per range), matches the biological grouping directly, and lets DuckDB’s list functions operate on groups without unnesting. elementNROWS() reads the list lengths; extracting a group unnests it back to a GRanges.

grl_data <- data.frame(
    transcript_id = c("ENST001", "ENST002"),
    seqnames = I(list(rep("chr1", 3), rep("chr2", 2))),
    start  = I(list(c(100L, 200L, 300L), c(150L, 250L))),
    width  = I(list(c(50L, 75L, 60L),   c(80L, 45L))),
    strand = I(list(c("+", "-", "+"),   c("-", "+"))))
grl_path <- tempfile(fileext = ".parquet"); arrow::write_parquet(grl_data, grl_path)

grl_ddb <- DuckDBGRangesList(grl_path,
                             seqnames = "seqnames", start = "start",
                             width = "width", strand = "strand",
                             keycol = list(transcript_id = grl_data$transcript_id))
elementNROWS(grl_ddb)
#> ENST001 ENST002 
#>       3       2

Materialization and interoperability

A DuckDBGRanges coerces to an ordinary GRanges with as(x, "GRanges"), which runs the accumulated query and reads the result into memory — the materialize half of the filter, then materialize pattern. Coercion is how overlap-heavy work is handed off to the in-memory interval-tree algorithms.

as(gr_ddb[seqnames(gr_ddb) == "chr1"], "GRanges")
#> GRanges object with 2 ranges and 1 metadata column:
#>          seqnames    ranges strand |     gene_id
#>             <Rle> <IRanges>  <Rle> | <character>
#>   range2     chr1   200-275      - |       GENE2
#>   range1     chr1   100-150      + |       GENE1
#>   -------
#>   seqinfo: 2 sequences from an unspecified genome; no seqlengths

Because the on-disk representation is plain Parquet, the same files are readable outside R (Python, Julia, DuckDB itself, cloud query engines) — the property the BiocDuckDB package relies on for its language-neutral on-disk experiment format. A new range-bearing backend mostly needs to define how its data maps onto the five coordinate columns and which operations to push into SQL; everything else follows from the GenomicRanges contract.

Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] arrow_25.0.0           DuckDBGRanges_0.99.1   GenomicRanges_1.65.1  
#>  [4] Seqinfo_1.3.0          DuckDBDataFrame_0.99.6 IRanges_2.47.2        
#>  [7] S4Vectors_0.51.5       BiocGenerics_0.59.10   generics_0.1.4        
#> [10] bit64_4.8.2            BiocStyle_2.41.0      
#> 
#> loaded via a namespace (and not attached):
#>  [1] sass_0.4.10           SparseArray_1.13.2    lattice_0.22-9       
#>  [4] digest_0.6.39         magrittr_2.0.5        evaluate_1.0.5       
#>  [7] grid_4.6.1            blob_1.3.0            fastmap_1.2.0        
#> [10] jsonlite_2.0.0        Matrix_1.7-5          DBI_1.3.0            
#> [13] BiocManager_1.30.27   purrr_1.2.2           jquerylib_0.1.4      
#> [16] duckdb_1.5.4.3        abind_1.4-8           cli_3.6.6            
#> [19] rlang_1.3.0           dbplyr_2.6.0          XVector_0.53.0       
#> [22] withr_3.0.3           cachem_1.1.0          DelayedArray_0.39.3  
#> [25] yaml_2.3.12           otel_0.2.0            S4Arrays_1.13.0      
#> [28] tools_4.6.1           dplyr_1.2.1           assertthat_0.2.1     
#> [31] buildtools_1.0.0      vctrs_0.7.3           R6_2.6.1             
#> [34] matrixStats_1.5.0     lifecycle_1.0.5       bit_4.6.0            
#> [37] pkgconfig_2.0.3       bslib_0.11.0          pillar_1.11.1        
#> [40] glue_1.8.1            xfun_0.60             tibble_3.3.1         
#> [43] tidyselect_1.2.1      sys_3.4.3             MatrixGenerics_1.25.0
#> [46] knitr_1.51            htmltools_0.5.9       rmarkdown_2.31       
#> [49] maketools_1.3.2       compiler_4.6.1