Spatial datasets are increasingly large: a spatial-transcriptomics
experiment can carry millions of transcript centroids and cell-boundary
polygons, and imaging or geographic layers run to hundreds of millions
of features. The sf
package is the standard R interface for such data —
st_area(), st_intersects(),
st_centroid() — but an ordinary sf object
holds every geometry in memory.
DuckDBSpatial extends DuckDBDataFrame with sf-compatible spatial methods that run on DuckDB-backed columns and tables, powered by DuckDB’s native spatial extension. The geometries stay on disk in columnar Parquet, and spatial operations are recorded as lazy SQL that DuckDB pushes down — so you can measure, filter, and transform geometries without loading the layer into memory. It reads and writes GeoParquet 1.0, so the same files interoperate with GeoPandas, GDAL, QGIS, and DuckDB itself.
Within the BiocDuckDB suite it is the spatial layer:
it is what DuckDBDataFrame
uses to serve GEOMETRY columns, and it underpins the
MultiAssaySpatialExperiment on-disk format in BiocDuckDB.
This vignette is a practical introduction. For how the
sf generics map onto DuckDB spatial SQL and how GeoParquet
I/O works, see Design and extension of DuckDBSpatial.
The package bundles a small example layer as partitioned Parquet.
Open it as a lazy DuckDBDataFrame and apply sf generics to the
geometry column without materializing the table.
spatial_path <- system.file("extdata", "spatial", package = "DuckDBSpatial")
df <- DuckDBDataFrame(spatial_path)
df <- df[which(!is.na(df$type)), ]
geom <- df[["geometry"]]
head(st_geometry_type(geom))
#> DuckDBColumn of length 6
#> 1 2 3 4 5
#> LINESTRING LINESTRING <NA> MULTILINESTRING MULTILINESTRING
#> 6
#> MULTILINESTRING
#> 8 Levels: POINT LINESTRING POLYGON MULTIPOINT MULTILINESTRING ... UNKNOWN
head(st_area(geom))
#> DuckDBColumn of length 6
#> 1 2 3 4 5 6
#> 0 0 NA 0 0 0Measurements (st_area(), st_length(),
st_perimeter()) and geometry transforms
(st_centroid(), st_buffer(),
st_convex_hull()) all return lazy
DuckDBColumns — nothing is computed until the
values are pulled:
centroids <- st_centroid(geom)
class(centroids)
#> [1] "DuckDBColumn"
#> attr(,"package")
#> [1] "DuckDBDataFrame"
head(st_as_text(centroids))
#> DuckDBColumn of length 6
#> 1
#> POINT (22.639320225002102 27.917960675006306)
#> 2
#> POINT EMPTY
#> 3
#> <NA>
#> 4
#> POINT (22.639320225002102 27.917960675006306)
#> 5
#> POINT (25.75049408851471 24.624752955742647)
#> 6
#> POINT EMPTYSpatial predicates (st_intersects(),
st_within(), st_contains(), …) build SQL
against a query geometry and stay lazy:
query_pt <- st_sfc(st_point(c(30, 10)))
hits <- st_intersects(geom, query_pt)
head(as.vector(hits))
#> 1 2 3 4 5 6
#> TRUE FALSE NA TRUE TRUE FALSEThe table-level st_filter() is the convenient way to
keep only the rows whose geometry satisfies a predicate against a query
— evaluated in DuckDB:
Point data often lives as plain x/y columns
(for example transcript centroids) rather than a geometry column. The
layer* helpers run spatial queries directly on coordinate
columns, so no geometry column is needed:
pts_path <- tempfile(fileext = ".csv")
write.csv(data.frame(x = c(1, 5, 30), y = c(1, 5, 10)), pts_path, row.names = FALSE)
pts <- DuckDBDataFrame(pts_path, datacols = c("x", "y"))
poly <- st_as_sfc("POLYGON((0 0, 6 0, 6 6, 0 6, 0 0))")
layerSpatialOverlaps(pts, poly, coords = c("x", "y")) # which points fall in poly
#> [1] TRUE TRUE FALSE
layerSubsetByGeometry(pts, poly, coords = c("x", "y")) # row indices inside poly
#> [1] 1 2
unlink(pts_path)readGeoParquet() opens a GeoParquet file as a lazy
DuckDBDataFrame with a native GEOMETRY column;
writeGeoParquet() writes an sf object with
GeoParquet 1.0 metadata (requires the suggested nanoparquet
package). The result is readable by any GeoParquet-aware tool.
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)A good fit when the spatial layer is larger than memory, when the workload is filter-heavy (selecting features by region or predicate before the expensive step), or when the data lives on disk as GeoParquet shared with other spatial tooling. An in-memory sf object remains preferable for small layers and for interactive geometry editing.
Within BiocDuckDB,
DuckDBSpatial
is what makes a MultiAssaySpatialExperiment hold its cell
boundaries and landmarks on disk as lazy GEOMETRY columns.
For the SQL translation and GeoParquet details, see Design and
extension of DuckDBSpatial.
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