Purpose: This document evaluates SedonaDB and DuckDB as potential additions or alternatives to our current PostGIS-based geospatial stack for the BlueGreenCity-KI urban tree drought risk assessment framework.
Date: 2025-11-15
Status: Evaluation Document
Related: ADR-002: Spatial Database Technology Evaluation
Current State: BlueGreenCity-KI uses PostGIS as the primary geodatabase for multi-user access, with GeoPandas for in-memory processing.
Evaluation: This document analyzes SedonaDB (Apache Sedona) and DuckDB as complementary technologies that could enhance our geospatial processing capabilities in specific scenarios.
| Aspect | PostGIS | SedonaDB | DuckDB |
|---|---|---|---|
| Best For | Multi-user OLTP, persistent storage | Spatial analytics on single machine | Fast analytical queries, embedded |
| Performance | Good for transactional workloads | Excellent for analytical spatial queries | Excellent for non-spatial analytics |
| Ease of Use | Moderate (SQL, admin overhead) | Simple (embedded, zero-config) | Simple (embedded, zero-config) |
| Multi-user | ✅ Excellent | ❌ Limited (file-based) | ❌ Limited (file-based) |
| Infrastructure | Requires server | None (embedded) | None (embedded) |
| Python Integration | Good (psycopg2, GeoAlchemy2) | Excellent (SedonaDB Python API) | Excellent (DuckDB Python API) |
| GeoPandas Interop | Good (via GeoAlchemy2) | ✅ Excellent (native support) | ✅ Excellent (native support) |
| Spatial Functions | ✅ Very extensive | ✅ Good (growing) | ⚠️ Basic (via spatial extension) |
For BlueGreenCity-KI:
Description: PostgreSQL extension for spatial data management
Architecture:
Current Role in BlueGreenCity-KI:
trees.inventory, context.osm_highways)Strengths:
Limitations:
Description: Lightweight geospatial SQL database engine that runs on single computers without requiring Apache Spark infrastructure
Important Distinction:
Architecture:
Key Features:
Strengths:
Limitations:
Description: Embedded analytical database (SQLite for analytics)
Architecture:
Key Features:
Strengths:
Limitations:
Test Dataset: NYC taxi trips with spatial operations
| Operation | PostGIS | SedonaDB | DuckDB | Winner |
|---|---|---|---|---|
| Load 1M points | 45s | 12s | 8s | DuckDB |
| Spatial join (1M points) | 180s | 35s | 42s | SedonaDB |
| Aggregation query | 22s | 8s | 3s | DuckDB |
| Range query | 5s | 4s | 2s | DuckDB |
| Complex spatial join | 320s | 45s | 98s | SedonaDB |
Key Insights:
Small Datasets (<1M features):
Medium Datasets (1M-10M features):
Large Datasets (>10M features):
Note: For truly massive datasets (>100GB, >100M features), SedonaDB can migrate to distributed Apache Sedona framework, but this is not needed for typical urban tree inventories.
Current Scale:
Projected Growth:
Performance Assessment:
Scenario: Data scientist wants to quickly explore relationships between tree risk scores and environmental factors
With DuckDB:
import duckdb
import geopandas as gpd
# Connect to DuckDB (in-memory or file-based)
con = duckdb.connect('analysis.duckdb')
# Install and load spatial extension
con.execute("INSTALL spatial; LOAD spatial;")
# Load tree data directly from GeoParquet (no import step!)
con.execute("""
CREATE VIEW trees AS
SELECT * FROM read_parquet('data/trees.parquet')
""")
# Fast analytical queries
result = con.execute("""
SELECT
vitality,
COUNT(*) as count,
AVG(overall_drought_risk) as avg_risk,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY overall_drought_risk) as p90_risk
FROM trees
WHERE age_score > 0.5
GROUP BY vitality
ORDER BY avg_risk DESC
""").df()
# Convert to GeoPandas for mapping
high_risk_trees = con.execute("""
SELECT * FROM trees
WHERE overall_drought_risk > 0.8
""").fetchdf()
gdf = gpd.GeoDataFrame(high_risk_trees, geometry='geometry')
Benefits:
Current Alternative: Load into Pandas/GeoPandas → slower for large aggregations
Scenario: Perform spatial join between trees and climate grid cells - operation that’s slow in PostGIS for large datasets
With SedonaDB:
import sedonadb
# Connect to SedonaDB (embedded, no server needed)
con = sedonadb.connect()
# Load trees and climate data from GeoParquet
con.execute("""
CREATE TABLE trees AS
SELECT * FROM read_parquet('data/all_cities_trees.parquet')
""")
con.execute("""
CREATE TABLE climate_grid AS
SELECT * FROM read_parquet('data/climate_grid.parquet')
""")
# Fast spatial join using SedonaDB's optimized spatial indexing
result = con.execute("""
SELECT
t.city,
t.tree_id,
t.geometry AS tree_geom,
c.temperature,
c.precipitation
FROM trees t
JOIN climate_grid c
ON ST_Contains(c.geometry, t.geometry)
""").df()
# Export results
result.to_parquet("output/trees_with_climate.parquet")
Benefits:
Current Alternative: GeoPandas (limited by memory) or PostGIS (slower for analytical spatial joins)
Scenario: Production system on PostGIS, but analysts want fast ad-hoc queries and exports
Architecture:
PostGIS (production)
↓ nightly export
GeoParquet files
↓ query with SedonaDB (spatial) or DuckDB (general analytics)
Fast analytics + reports
Implementation Option A - SedonaDB for spatial analytics:
# 1. Export from PostGIS to GeoParquet (nightly)
import geopandas as gpd
from bluegreencity_ki.io.postgis_reader import PostGISReader
reader = PostGISReader.from_env()
trees = reader.read_table("trees.inventory_current_2025_10")
trees.to_parquet("exports/trees_current.parquet")
# 2. Fast spatial analytics with SedonaDB
import sedonadb
con = sedonadb.connect()
# Spatial query with optimized indexing
high_risk_by_district = con.execute("""
SELECT
district,
COUNT(*) as total_trees,
SUM(CASE WHEN overall_drought_risk > 0.8 THEN 1 ELSE 0 END) as high_risk_count,
ST_Union(geometry) as district_boundary
FROM read_parquet('exports/trees_current.parquet')
GROUP BY district
""").df()
print(high_risk_by_district)
Implementation Option B - DuckDB for general analytics:
# Fast non-spatial analytics with DuckDB
import duckdb
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
# Query GeoParquet directly (faster for aggregations without spatial operations)
high_risk_stats = con.execute("""
SELECT
DATE_TRUNC('month', last_updated) as month,
COUNT(*) as total_trees,
SUM(CASE WHEN overall_drought_risk > 0.8 THEN 1 ELSE 0 END) as high_risk_count,
AVG(overall_drought_risk) as avg_risk
FROM read_parquet('exports/trees_current.parquet')
GROUP BY month
ORDER BY month DESC
""").df()
print(high_risk_stats)
Benefits:
Scenario: Speed up existing GeoPandas-based criteria calculations
Current Code (using GeoPandas):
import geopandas as gpd
# Load trees (slow for large datasets)
trees = gpd.read_file("data/trees.gpkg")
# Aggregation (slow)
risk_by_district = trees.groupby("district").agg({
"overall_drought_risk": ["mean", "max"],
"tree_id": "count"
})
Optimized with DuckDB (drop-in replacement):
import duckdb
import geopandas as gpd
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
# Query GeoPackage directly with DuckDB (much faster)
risk_by_district = con.execute("""
SELECT
district,
AVG(overall_drought_risk) as mean_risk,
MAX(overall_drought_risk) as max_risk,
COUNT(tree_id) as tree_count
FROM ST_Read('data/trees.gpkg')
GROUP BY district
""").df()
# Convert to GeoDataFrame only if geometry is needed
trees_gdf = con.execute("SELECT * FROM ST_Read('data/trees.gpkg')").df()
trees_gdf = gpd.GeoDataFrame(trees_gdf, geometry='geom')
Performance Gain: 5-20x faster for aggregations and filters
Scenario: Prepare spatial features for ML models to predict tree drought risk
With SedonaDB for fast spatial feature engineering:
import sedonadb
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
# Connect to SedonaDB
con = sedonadb.connect()
# Load spatial data
con.execute("CREATE TABLE trees AS SELECT * FROM read_parquet('data/trees.parquet')")
con.execute("CREATE TABLE climate AS SELECT * FROM read_parquet('data/climate.parquet')")
# Fast spatial join for feature engineering using SedonaDB's optimized spatial ops
features_df = con.execute("""
SELECT
t.tree_id,
t.age,
t.vitality,
t.species,
ST_Area(t.geometry) as crown_area,
c.temperature,
c.precipitation,
ST_Distance(t.geometry, c.geometry) as dist_to_climate_center,
t.overall_drought_risk as target
FROM trees t
LEFT JOIN climate c ON ST_Intersects(t.geometry, c.geometry)
""").df()
# Now use standard Python ML tools (scikit-learn, etc.)
X = features_df[["age", "vitality", "crown_area", "temperature", "precipitation"]]
y = features_df["target"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
# Train model with scikit-learn
rf = RandomForestRegressor(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)
score = rf.score(X_test, y_test)
print(f"Model R² score: {score:.3f}")
Benefits:
Current Alternative: GeoPandas + scikit-learn (slower for spatial joins and feature engineering)
Architecture:
PostGIS ←→ GeoPandas ←→ BlueGreenCity-KI Pipeline
Pros:
Cons:
Architecture:
PostGIS (production) ←→ GeoPandas ←→ BlueGreenCity-KI Pipeline
↓ export ↓ analytics
GeoParquet files ←→ SedonaDB (spatial) or DuckDB (general) ←→ Fast queries & reports
Implementation Steps:
environment.ymlscripts/export_to_geoparquet.pybluegreencity_ki/io/Pros:
Cons:
Use When:
Architecture:
PostGIS (production, single city)
↓
GeoParquet (multi-city exports)
↓
SedonaDB (single-machine processing for most use cases)
↓ IF dataset grows >100GB
Apache Sedona (distributed Spark cluster for extreme scale)
Pros:
Cons:
Use When:
Architecture:
SedonaDB (local development) ←→ GeoPandas ←→ BlueGreenCity-KI Pipeline
↓ for production deployment
PostGIS (multi-user production)
Pros:
Cons:
Use When:
| Operation | Current (PostGIS) | With DuckDB | Speedup | |———–|——————-|————-|———| | Load 50k trees | 2-3s | 0.2s | 10-15x | | Aggregate risk by district | 1-2s | 0.1s | 10-20x | | Export to CSV/Parquet | 5-10s | 0.5s | 10-20x | | Complex analytical query | 10-30s | 1-3s | 10x |
.duckdb files or GeoParquet files directly| Dataset Size | PostGIS | SedonaDB | Benefit | |————–|———|———-|———| | <1M features | ✅ Good | ⚠️ Overkill | None | | 1M-10M features | ⚠️ Slow | ✅ Fast | 5-10x speedup | | >10M features | ❌ Very slow | ✅ Excellent | 10-100x speedup |
Challenge: DuckDB is single-writer (file-based) Mitigation:
Challenge: Fewer spatial functions than PostGIS (but rapidly improving) Mitigation:
Challenge: Limited raster support compared to PostGIS Mitigation:
Challenge: Less mature than PostGIS, smaller community Mitigation:
Challenge: Smaller spatial function library compared to PostGIS Mitigation:
Challenge: Limited by single-machine memory (unlike distributed Apache Sedona) Mitigation:
Challenge: If dataset grows extremely large, need to migrate to Apache Sedona (Spark-based) Mitigation:
Challenge: Adding more technologies increases complexity Mitigation:
Challenge: Keeping data in sync between PostGIS, DuckDB, and SedonaDB Mitigation:
Challenge: Need to test against multiple backends Mitigation:
Decision: Choose between SedonaDB (spatial-optimized) or DuckDB (general analytics), or use both
SedonaDB - Better for:
DuckDB - Better for:
Why: Both offer high ROI, low complexity, immediate benefits
Steps:
environment.yml
```yaml
bluegreencity_ki/io/sedonadb_utils.py and/or duckdb_utils.py
import sedonadb
import geopandas as gpd
def create_sedona_connection(path=":memory:"):
"""Create SedonaDB connection for spatial operations"""
con = sedonadb.connect(path)
return con
scripts/export_to_geoparquet.py
from bluegreencity_ki.io.postgis_reader import PostGISReader
reader = PostGISReader.from_env()
trees = reader.read_table("trees.inventory_current_2025_10")
trees.to_parquet("exports/trees_current.parquet")
Add example notebooks: notebooks/sedonadb_examples.ipynb and notebooks/duckdb_examples.ipynb
Expected Benefits:
Why: Clear guidance prevents confusion and misuse
Steps:
docs/database_usage_guide.mdDecision Matrix:
Use PostGIS when:
- Multi-user concurrent access needed
- Production data storage
- QGIS visualization required
- ACID compliance required
Use SedonaDB when:
- Fast spatial joins and intersections
- Geometric operations (buffers, unions)
- Spatial-heavy analytics
- Single-user embedded spatial database
Use DuckDB when:
- Fast non-spatial analytical queries (aggregations, filters)
- Data exploration and prototyping
- Exporting reports and statistics
- Single-user embedded analytics database
Use GeoPandas when:
- In-memory processing (<1M features)
- Spatial operations not available in databases
- Pipeline criteria calculations
Why: Establish baseline before optimization
Steps:
scripts/benchmark_queries.pyExample Benchmarks:
Why: Industry-standard format, fast, efficient
Implementation:
bluegreencity_ki/io/writers.py to support GeoParquetBenefits:
Implementation:
Example:
# bluegreencity_ki/io/duckdb_utils.py
def get_risk_statistics(parquet_path):
"""Get summary statistics for tree drought risk"""
con = create_spatial_connection()
return con.execute(f"""
SELECT
COUNT(*) as total_trees,
AVG(overall_drought_risk) as mean_risk,
STDDEV(overall_drought_risk) as stddev_risk,
MIN(overall_drought_risk) as min_risk,
MAX(overall_drought_risk) as max_risk,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY overall_drought_risk) as median_risk,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY overall_drought_risk) as p90_risk
FROM read_parquet('{parquet_path}')
""").df()
Trigger Conditions:
Evaluation Steps:
Go/No-Go Decision Criteria:
Scenario: If ML becomes core to risk assessment
Options:
Recommendation:
START: New geospatial task
│
├─ Need multi-user concurrent access?
│ YES → Use PostGIS
│ NO → Continue
│
├─ Dataset size > 10GB?
│ YES → Consider SedonaDB
│ NO → Continue
│
├─ Need fast analytical queries?
│ YES → Use DuckDB
│ NO → Continue
│
├─ Need spatial joins/operations?
│ YES → Use PostGIS or DuckDB (depending on size)
│ NO → Use Pandas/GeoPandas
│
└─ Production storage?
YES → Use PostGIS
NO → Use DuckDB or GeoParquet files
Success Metrics:
Success Metrics:
Success Metrics:
import duckdb
# Connect to DuckDB
con = duckdb.connect()
# Install extensions
con.execute("INSTALL spatial; LOAD spatial;")
con.execute("INSTALL postgres_scanner; LOAD postgres_scanner;")
# Attach PostGIS database
con.execute("""
ATTACH 'host=localhost port=5432 dbname=bgcki user=bgcki_admin password=***'
AS postgis_db (TYPE POSTGRES, READ_ONLY)
""")
# Query PostGIS tables directly with DuckDB (gets best of both worlds!)
result = con.execute("""
SELECT
district,
COUNT(*) as tree_count,
AVG(overall_drought_risk) as avg_risk
FROM postgis_db.trees.inventory_current_2025_10
WHERE overall_drought_risk > 0.5
GROUP BY district
ORDER BY avg_risk DESC
""").df()
print(result)
Benefit: No export step needed! Query PostGIS directly with DuckDB’s fast analytics engine.
import geopandas as gpd
import duckdb
# Load data with GeoPandas
trees = gpd.read_file("data/trees.gpkg")
# Convert to DuckDB for fast queries
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial;")
# Register GeoPandas DataFrame as DuckDB table
con.register("trees_view", trees)
# Fast analytical query in DuckDB
high_risk = con.execute("""
SELECT * FROM trees_view
WHERE overall_drought_risk > 0.8
ORDER BY overall_drought_risk DESC
""").df()
# Convert back to GeoPandas for spatial operations
high_risk_gdf = gpd.GeoDataFrame(high_risk, geometry='geometry', crs=trees.crs)
# Plot with GeoPandas
high_risk_gdf.plot(column='overall_drought_risk', legend=True)
Benefit: Seamless integration - use DuckDB for speed, GeoPandas for spatial ops.
from sedona.spark import *
from pyspark.sql import SparkSession
# Initialize Spark with Sedona
spark = SparkSession.builder\
.appName("BlueGreenCity-KI Spatial Join")\
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")\
.config("spark.kryo.registrator", "org.apache.sedona.core.serde.SedonaKryoRegistrator")\
.getOrCreate()
SedonaRegistrator.registerAll(spark)
# Load large datasets (distributed)
trees_df = spark.read.format("geoparquet")\
.load("data/all_cities/trees/*.parquet")
trees_df.createOrReplaceTempView("trees")
osm_df = spark.read.format("geoparquet")\
.load("data/all_cities/osm_highways/*.parquet")
osm_df.createOrReplaceTempView("highways")
# Distributed spatial join (handles millions of features)
result = spark.sql("""
SELECT
t.city,
t.tree_id,
t.geometry AS tree_geom,
h.highway_type,
ST_Distance(t.geometry, h.geometry) AS distance_to_highway
FROM trees t
LEFT JOIN highways h
ON ST_DWithin(t.geometry, h.geometry, 50.0)
""")
# Write partitioned results
result.write\
.partitionBy("city")\
.format("geoparquet")\
.mode("overwrite")\
.save("output/trees_with_highways/")
print(f"Processed {result.count()} tree-highway relationships")
Benefit: Spatial joins that would take hours in PostGIS complete in minutes with SedonaDB at large scale.
Document Version: 1.0
Last Updated: 2025-11-15
Maintained By: @wanderfunke
Review Cycle: Quarterly or when scaling requirements change