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.
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
a transform such as st_centroid() becomes
ST_Centroid(geometry), and a predicate against a query
geometry q becomes a boolean column:
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 0There 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;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
DuckDBDataFrames 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 (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