--- title: "Design and extension of DuckDBSpatial" author: - name: Patrick Aboyoun email: aboyounp@gene.com affiliation: Genentech, Inc. date: "Compiled: `r BiocStyle::doc_date()`; Modified: 6 July 2026" package: DuckDBSpatial vignette: > %\VignetteIndexEntry{2. Design and extension of DuckDBSpatial} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} output: BiocStyle::html_document: number_sections: true toc: true toc_depth: 3 --- ```{r setup, include=FALSE} library(BiocStyle) knitr::opts_chunk$set(collapse = TRUE, comment = "#>", error = FALSE, warning = FALSE, message = FALSE) # Gate the live spatial examples on the DuckDB spatial extension being available, so # the vignette still builds where it cannot be obtained (e.g. offline dev machines); # CI / a networked build renders them for real. .spatial_ok <- tryCatch({ con <- DuckDBDataFrame::acquireDuckDBConn() DBI::dbGetQuery(con, "SELECT ST_Area(ST_GeomFromText('POLYGON((0 0,0 1,1 1,1 0,0 0))')) AS a") TRUE }, error = function(e) FALSE) ``` ```{r spatial-unavailable-note, echo=FALSE, results='asis', eval=!.spatial_ok} cat("> **Note:** DuckDB's `spatial` extension could not be loaded in this build ", "environment, so the live examples below are shown but not evaluated.\n", sep = "") ``` # Scope This vignette is for developers: it documents how `r Biocpkg("DuckDBSpatial")` implements `r CRANpkg("sf")`-compatible spatial operations on `r Biocpkg("DuckDBDataFrame")` objects, how those operations become DuckDB spatial SQL, and how GeoParquet I/O works. For day-to-day use see *Introduction to DuckDBSpatial*. ```{r load} library(DuckDBSpatial) library(sf) ``` # Architecture `r Biocpkg("DuckDBSpatial")` adds a spatial method layer to the two core `r Biocpkg("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 `r CRANpkg("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 `r CRANpkg("sf")` generic to the corresponding DuckDB `ST_*` function, applied to the geometry column inside the lazy query. A measurement such as `st_area()` becomes ```sql 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: ```sql SELECT ST_Intersects(geometry, ST_GeomFromText('')) 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`. ```{r sql-demo, eval=.spatial_ok} 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) head(as.vector(area)) # pulled from DuckDB on demand ``` # Two dispatch paths There are two ways to run a spatial query, depending on how coordinates are stored: - **Geometry-column methods** --- the `r CRANpkg("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 `r CRANpkg("sf")` object as [GeoParquet 1.0](https://geoparquet.org/): - 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 `r CRANpkg("sf")`. ```{r geoparquet, eval=.spatial_ok && requireNamespace("nanoparquet", quietly=TRUE)} 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) unlink(path) ``` GeoParquet metadata generation uses the suggested `r CRANpkg("nanoparquet")` and `r CRANpkg("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 `r CRANpkg("sf")` generic for `DuckDBColumn` (and/or `DuckDBTable`) that emits the appropriate `ST_*` expression --- everything else (laziness, connection, materialization) comes from `r Biocpkg("DuckDBDataFrame")`. The main consumer is `r Biocpkg("BiocDuckDB")`: a `MultiAssaySpatialExperiment` writes its spatial layers (cell boundaries, landmarks) as GeoParquet via `writeGeoParquet()`, and reads them back as lazy `DuckDBDataFrame`s with `GEOMETRY` columns --- so a spatial experiment keeps its geometry on disk and queries it through the same `r CRANpkg("sf")` API used here. # Session information ```{r sessioninfo} sessionInfo() ```