DuckDBSpatial 0.99.6
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)
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.
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 (a true/false spatial test) 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)), ]
# df[["geometry"]] is an sf-style geometry column (a list, one WKB-encoded
# shape per row); see the "Lazy spatial columns" section of the introduction
# vignette for what that means if `sf` is new to you.
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
There are two ways to run a spatial query, depending on how coordinates are stored:
GEOMETRY column (native DuckDB geometry, or WKB read from
GeoParquet).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.
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:
geo file-metadata key records the version, the primary geometry column, and
per-column encoding, geometry types, and bounding box;st_sf() pairs a data frame of attributes with an st_sfc() geometry list
column into a full sf object (see the introduction vignette’s “GeoParquet
I/O” section for a walkthrough of this same construction):
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.
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 DuckDBDataFrame objects
with GEOMETRY columns, so a spatial experiment keeps its geometry on disk and
queries it through the same sf API used here.
sessionInfo()
#> R version 4.6.1 Patched (2026-06-24 r90190)
#> Platform: x86_64-apple-darwin20
#> Running under: macOS Ventura 13.7.8
#>
#> Matrix products: default
#> BLAS: /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRblas.0.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
#>
#> locale:
#> [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#>
#> time zone: America/New_York
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] DuckDBSpatial_0.99.6 sf_1.1-2 DuckDBDataFrame_0.99.23
#> [4] IRanges_2.47.2 S4Vectors_0.51.6 BiocGenerics_0.59.12
#> [7] generics_0.1.4 bit64_4.8.4 BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 class_7.3-24 SparseArray_1.13.2
#> [4] KernSmooth_2.23-27 lattice_0.23-1 digest_0.6.39
#> [7] magrittr_2.0.5 evaluate_1.0.5 grid_4.6.1
#> [10] bookdown_0.47 blob_1.3.0 fastmap_1.2.0
#> [13] jsonlite_2.0.0 Matrix_1.7-6 e1071_1.7-17
#> [16] DBI_1.3.0 BiocManager_1.30.27 purrr_1.2.2
#> [19] jquerylib_0.1.4 abind_1.4-8 duckdb_1.5.5
#> [22] cli_3.6.6 rlang_1.3.0 units_1.0-1
#> [25] dbplyr_2.6.0 XVector_0.53.0 withr_3.0.3
#> [28] cachem_1.1.0 DelayedArray_0.39.6 yaml_2.3.12
#> [31] otel_0.2.0 S4Arrays_1.13.0 tools_4.6.1
#> [34] dplyr_1.2.1 assertthat_0.2.1 vctrs_0.7.3
#> [37] R6_2.6.1 proxy_0.4-29 matrixStats_1.5.0
#> [40] lifecycle_1.0.5 classInt_0.4-11 bit_4.6.0
#> [43] arrow_25.0.0 pkgconfig_2.0.3 bslib_0.12.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 MatrixGenerics_1.25.0 knitr_1.51
#> [55] htmltools_0.5.9 rmarkdown_2.31 wk_0.9.5
#> [58] compiler_4.6.1