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 (a true/false
spatial test) 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)), ]
# 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 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;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 (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.6 sf_1.1-2 DuckDBDataFrame_0.99.23
#> [4] IRanges_2.47.2 S4Vectors_0.51.7 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] blob_1.3.0 fastmap_1.2.0 jsonlite_2.0.0
#> [13] Matrix_1.7-6 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.5 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.6 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.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 sys_3.4.3 MatrixGenerics_1.25.0
#> [55] knitr_1.51 htmltools_0.5.9 rmarkdown_2.31
#> [58] maketools_1.3.2 wk_0.9.5 compiler_4.6.1