Introduction to DuckDBSpatial

Introduction

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.

Installation

if (!require("BiocManager"))
    install.packages("BiocManager")
BiocManager::install("DuckDBSpatial")
library(DuckDBSpatial)
library(sf)

The DuckDB spatial extension loads automatically the first time a spatial operation runs (it is fetched once and cached).

Lazy spatial columns

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  0

Measurements (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 EMPTY

Spatial predicates and filtering

Spatial 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 FALSE

The table-level st_filter() is the convenient way to keep only the rows whose geometry satisfies a predicate against a query — evaluated in DuckDB:

filtered <- st_filter(df, query_pt)
nrow(filtered)
#> [1] 10

Layer-level engines for coordinate columns

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)

GeoParquet I/O

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.

ddb <- readGeoParquet(spatial_path)
nrow(ddb)
#> [1] 24
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)

When to use DuckDBSpatial

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.

Session information

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