Contents

1 Scope

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)

2 Architecture

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.

3 From sf generics to spatial SQL

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

4 Two dispatch paths

There are two ways to run a spatial query, depending on how coordinates are stored:

Both paths run entirely in DuckDB; the choice is only about the on-disk column layout.

5 GeoParquet I/O

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:

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.

6 Extending, and the BiocDuckDB integration

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.

7 Session information

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