---
title: "Introduction to DuckDBSpatial"
author:
- name: Patrick Aboyoun
  email: aboyounp@gene.com
  affiliation: Genentech, Inc.
date: "Compiled: `r BiocStyle::doc_date()`; Modified: 6 July 2026"
package: DuckDBSpatial
vignette: >
  %\VignetteIndexEntry{1. Introduction to DuckDBSpatial}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
output:
  BiocStyle::html_document:
    number_sections: true
    toc: true
    toc_depth: 2
---

```{r setup, include=FALSE}
library(BiocStyle)
knitr::opts_chunk$set(collapse = TRUE, comment = "#>",
                      error = FALSE, warning = FALSE, message = FALSE)

# Probe once whether DuckDB's spatial extension is available in this build
# environment (it is fetched on first use, or loaded from a provisioned cache).
# The live spatial examples below are gated on this so the vignette still builds
# where the extension cannot be obtained (e.g. an offline developer machine);
# CI / a networked build renders them for real.
.spatial_ok <- tryCatch({
    con <- DuckDBDataFrame::acquireDuckDBConn()
    DBI::dbGetQuery(con,
        "SELECT ST_Area(ST_GeomFromText('POLYGON((0 0,0 1,1 1,1 0,0 0))')) AS a")
    TRUE
}, error = function(e) FALSE)
```

```{r spatial-unavailable-note, echo=FALSE, results='asis', eval=!.spatial_ok}
cat("> **Note:** DuckDB's `spatial` extension could not be loaded in this build ",
    "environment, so the live spatial examples below are shown but not evaluated. ",
    "See the README for how to make the extension available (it is fetched ",
    "automatically where the DuckDB extension repository is reachable).\n", sep = "")
```

# 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](https://r-spatial.github.io/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.

`r Biocpkg("DuckDBSpatial")` extends `r Biocpkg("DuckDBDataFrame")` with
`r CRANpkg("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
`r Biocpkg("DuckDBDataFrame")` uses to serve `GEOMETRY` columns, and it underpins
the `MultiAssaySpatialExperiment` on-disk format in `r Biocpkg("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

```{r install, eval=FALSE}
if (!require("BiocManager"))
    install.packages("BiocManager")
BiocManager::install("DuckDBSpatial")
```

```{r load}
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 `r CRANpkg("sf")` generics to the geometry column
without materializing the table.

```{r columns, eval=.spatial_ok}
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))
head(st_area(geom))
```

Measurements (`st_area()`, `st_length()`, `st_perimeter()`) and geometry
transforms (`st_centroid()`, `st_buffer()`, `st_convex_hull()`) all return **lazy
`DuckDBColumn`s** --- nothing is computed until the values are pulled:

```{r transforms, eval=.spatial_ok}
centroids <- st_centroid(geom)
class(centroids)
head(st_as_text(centroids))
```

# Spatial predicates and filtering

Spatial predicates (`st_intersects()`, `st_within()`, `st_contains()`, ...) build
SQL against a query geometry and stay lazy:

```{r predicates, eval=.spatial_ok}
query_pt <- st_sfc(st_point(c(30, 10)))
hits <- st_intersects(geom, query_pt)
head(as.vector(hits))
```

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:

```{r filter, eval=.spatial_ok}
filtered <- st_filter(df, query_pt)
nrow(filtered)
```

# 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:

```{r layers, eval=.spatial_ok}
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
layerSubsetByGeometry(pts, poly, coords = c("x", "y"))  # row indices inside poly
unlink(pts_path)
```

# GeoParquet I/O

`readGeoParquet()` opens a GeoParquet file as a lazy `DuckDBDataFrame` with a
native `GEOMETRY` column; `writeGeoParquet()` writes an `r CRANpkg("sf")` object
with GeoParquet 1.0 metadata (requires the suggested `r CRANpkg("nanoparquet")`
package). The result is readable by any GeoParquet-aware tool.

```{r geoparquet-read, eval=.spatial_ok}
ddb <- readGeoParquet(spatial_path)
nrow(ddb)
```

```{r geoparquet-write, eval=.spatial_ok && requireNamespace("nanoparquet", quietly=TRUE)}
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)
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 `r CRANpkg("sf")` object remains preferable for small layers
and for interactive geometry editing.

Within `r Biocpkg("BiocDuckDB")`, `r Biocpkg("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

```{r sessioninfo}
sessionInfo()
```
