Design and extension of DuckDBSpatial

Scope

This vignette is for developers: it documents how DuckDBSpatial implements sf-compatible spatial operations on DuckDBDataFrame objects, how those operations become DuckDB spatial SQL, and how GeoParquet I/O works. For day-to-day use see Introduction to DuckDBSpatial.

library(DuckDBSpatial)
library(sf)

Architecture

DuckDBSpatial adds a spatial method layer to the two core DuckDBDataFrame classes, backed by DuckDB’s native spatial extension:

Target What it carries Spatial API
DuckDBColumn one lazy GEOMETRY column st_area(), st_centroid(), … on a single column
DuckDBDataFrame / DuckDBTable a table with a geometry column table-level st_filter(), st_join(), predicates

The methods are registered as S3 methods on the sf generics (st_area.DuckDBColumn, st_intersects.DuckDBTable, …), so existing sf-based code dispatches to them automatically. None of them materialize geometries: each records a SQL expression on the underlying DuckDBDataFrame, evaluated only when values are pulled. The DuckDB spatial extension is obtained on demand — DuckDB autoloads it on first use of an ST_* function (fetching from the configured extension repository, or loading a pre-provisioned copy from the extension directory), so callers never manage it explicitly.

From sf generics to spatial SQL

Every spatial method translates its sf generic to the corresponding DuckDB ST_* function, applied to the geometry column inside the lazy query. A measurement such as st_area() becomes

SELECT ST_Area(geometry) FROM layer

a transform such as st_centroid() becomes ST_Centroid(geometry), and a predicate against a query geometry q becomes a boolean column:

SELECT ST_Intersects(geometry, ST_GeomFromText('<q as WKT>')) FROM layer

Because these are ordinary columns in the query, DuckDB applies its usual optimizations — column pruning (only the geometry column is read) and predicate pushdown (a spatial filter is evaluated during the Parquet scan). The result of a column method is a lazy DuckDBColumn; the result of st_filter() is a lazy DuckDBDataFrame.

spatial_path <- system.file("extdata", "spatial", package = "DuckDBSpatial")
df <- DuckDBDataFrame(spatial_path)
df <- df[which(!is.na(df$type)), ]

area <- st_area(df[["geometry"]])   # records ST_Area(geometry); nothing computed yet
class(area)
#> [1] "DuckDBColumn"
#> attr(,"package")
#> [1] "DuckDBDataFrame"
head(as.vector(area))               # pulled from DuckDB on demand
#>  1  2  3  4  5  6 
#>  0  0 NA  0  0  0

Two dispatch paths

There are two ways to run a spatial query, depending on how coordinates are stored:

  • Geometry-column methods — the sf generics above, for tables that carry a GEOMETRY column (native DuckDB geometry, or WKB read from GeoParquet).
  • Layer-level engines (layerSpatialOverlaps(), layerSubsetByGeometry(), layerSubsetByBbox(), layerSpatialMatch()) — for tables that store plain coordinate columns (x, y) with no geometry column. These construct points from the coordinates inside the query (ST_Point(x, y)) and apply the predicate against a supplied geometry, which is the common shape for spatial-transcriptomics centroids.

Both paths run entirely in DuckDB; the choice is only about the on-disk column layout.

GeoParquet I/O

readGeoParquet() opens a GeoParquet file, enabling DuckDB’s GeoParquet conversion so the geometry column is exposed as a native GEOMETRY, and returns a lazy DuckDBDataFrame. writeGeoParquet() writes an sf object as GeoParquet 1.0:

  • geometries are encoded as Well-Known Binary (WKB);
  • a geo file-metadata key records the version, the primary geometry column, and per-column encoding, geometry types, and bounding box;
  • the result is readable by GeoPandas, GDAL, QGIS, DuckDB, and sf.
pts_sf <- st_sf(id = 1:2, geometry = st_sfc(st_point(0:1), st_point(2:3)))
path <- tempfile(fileext = ".parquet")
writeGeoParquet(pts_sf, path)
readGeoParquet(path)
#> DuckDBDataFrame with 2 rows and 2 columns
#>          id     geometry
#>   <integer>   <geometry>
#> 1         1 01,01,00,...
#> 2         2 01,01,00,...
unlink(path)

GeoParquet metadata generation uses the suggested nanoparquet and jsonlite packages; writeGeoParquet() errors with an install hint if they are absent.

Extending, and the BiocDuckDB integration

Adding a spatial method means registering an S3 method on the sf generic for DuckDBColumn (and/or DuckDBTable) that emits the appropriate ST_* expression — everything else (laziness, connection, materialization) comes from DuckDBDataFrame.

The main consumer is BiocDuckDB: a MultiAssaySpatialExperiment writes its spatial layers (cell boundaries, landmarks) as GeoParquet via writeGeoParquet(), and reads them back as lazy DuckDBDataFrames with GEOMETRY columns — so a spatial experiment keeps its geometry on disk and queries it through the same sf API used here.

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] DuckDBSpatial_0.99.00  sf_1.1-1               DuckDBDataFrame_0.99.6
#> [4] IRanges_2.47.2         S4Vectors_0.51.5       BiocGenerics_0.59.10  
#> [7] generics_0.1.4         bit64_4.8.2            BiocStyle_2.41.0      
#> 
#> loaded via a namespace (and not attached):
#>  [1] sass_0.4.10           class_7.3-23          SparseArray_1.13.2   
#>  [4] KernSmooth_2.23-26    lattice_0.22-9        digest_0.6.39        
#>  [7] magrittr_2.0.5        evaluate_1.0.5        grid_4.6.1           
#> [10] blob_1.3.0            fastmap_1.2.0         jsonlite_2.0.0       
#> [13] Matrix_1.7-5          e1071_1.7-17          DBI_1.3.0            
#> [16] BiocManager_1.30.27   purrr_1.2.2           jquerylib_0.1.4      
#> [19] duckdb_1.5.4.3        abind_1.4-8           cli_3.6.6            
#> [22] rlang_1.3.0           units_1.0-1           dbplyr_2.6.0         
#> [25] XVector_0.53.0        withr_3.0.3           cachem_1.1.0         
#> [28] DelayedArray_0.39.3   yaml_2.3.12           otel_0.2.0           
#> [31] S4Arrays_1.13.0       tools_4.6.1           dplyr_1.2.1          
#> [34] assertthat_0.2.1      buildtools_1.0.0      vctrs_0.7.3          
#> [37] R6_2.6.1              proxy_0.4-29          classInt_0.4-11      
#> [40] matrixStats_1.5.0     lifecycle_1.0.5       bit_4.6.0            
#> [43] arrow_25.0.0          pkgconfig_2.0.3       bslib_0.11.0         
#> [46] pillar_1.11.1         Rcpp_1.1.2            glue_1.8.1           
#> [49] nanoparquet_0.5.1     xfun_0.60             tibble_3.3.1         
#> [52] tidyselect_1.2.1      sys_3.4.3             MatrixGenerics_1.25.0
#> [55] knitr_1.51            htmltools_0.5.9       rmarkdown_2.31       
#> [58] maketools_1.3.2       compiler_4.6.1