duck soup makes config-driven geodata ETL on DuckDB as easy as... well, duck soup!
It's a lightweight, lightning-fast replacement for building heavy workspaces in tools like FME (Safe Software Feature Manipulation Engine), or writing custom, error-prone Python scripts. Describe your pipeline as a simple YAML file — sources, a base, join/geoprocessing steps, attribute mapping, one or more output layers — or build it visually in the interactive web editor.
Under the hood, everything runs inside DuckDB using the powerful spatial extension: joins, geoprocessing (buffers, clips, overlays, dissolves, ...), and field mappings all compile down to a chain of SQL views, written straight to an output GeoPackage. A single YAML file can define several independent pipelines, each fanning out to multiple layers, all written into one shared GeoPackage.
- YAML-driven pipelines: Describe inputs, join/geoprocessing steps, schema mapping, and output layers in one neat configuration file.
- Multi-pipeline / multi-layer output: One file can define several independent pipelines, each writing one or more layers, all into a single shared GeoPackage with optional dataset-level metadata.
- Web Editor: A visual, browser-based pipeline builder with live YAML preview, syntax validation, data previewing, and interactive execution logs.
- Powered by DuckDB Spatial: Blistering speed using DuckDB's columnar execution engine and GDAL-backed
ST_Read/ST_Writeoperations. - Flexible Joins: Spatial joins (intersects/contains/within, keeping the first match or fanning out to all), attribute joins, and nearest-neighbor (distance-constrained) searches.
- Geoprocessing steps: Buffer, centroid, clip, erase, dissolve, intersect overlay, filter, and merge (union) — chainable like any join step, with
snapshotto fork the chain into named branches. - Derived sources: Build a filtered/buffered view of any source and reuse it as a join source, without a dedicated step.
- Rich Attribute Mapping: Translate, rename, compute coordinates/area/length, generate UUIDs/timestamps, or apply rule-based/CSV-based codelist lookups on the fly.
duck_soup/ # Python package directory
config.py # YAML schema (pydantic) + load/save
sources.py # one reader per format (mostly DuckDB ST_Read / GDAL)
derive.py # python UDFs (MGRS) registered into DuckDB
engine.py # builds a chain of SQL views, writes GeoPackage
cli.py # python -m duck_soup.cli run pipelines/test.yaml
web/
app.py # FastAPI backend
static/ # compiled editor frontend assets served by FastAPI
ui/ # TypeScript + Vite editor frontend source
pipelines/ # your dataset definitions (*.yaml)
data/ output/ # inputs / outputs
Each source is read once into a src_<id> view, with its geometry reprojected to a
single working CRS. The engine then walks the pipeline's steps in order, each
one creating the next step_N view on top of the last (step_0 is the base
source). A snapshot step names the chain's current state so a later step can fork
off a named branch instead of following the main chain, and a derived_source wraps
an existing source with a filter/buffer for reuse as a join source — so a pipeline
isn't limited to one strictly linear sequence of steps. DuckDB plans each layer's
view chain as one query, so joins, geoprocessing, and projections stream and stay
fast.
Once the chain is built, each output layer applies its own attribute mapping (and
optional filter) and is written straight to GeoPackage via
COPY … (FORMAT GDAL, DRIVER 'GPKG'). A pipeline can write several layers this way,
and a Config file can run several pipelines, all appended into the same GeoPackage.
lon/lat/mgrs/wkb/area/length are always derived from the same working-CRS geometry
used for the joins, so there's no extra reprojection.
Almost every format goes through DuckDB's ST_Read (which uses GDAL), so adding a
format is usually one branch in sources.py. ArcGIS REST is the exception: it's
paged to a temporary GeoJSON file first, then read like any other file.
Before setting up the project, make sure you have the following installed:
- Python: Python
3.11or newer. - Node.js & npm (optional): Only needed if you plan to modify or rebuild the frontend editor. Node.js
18+is recommended. - Internet Access: Required on the first run so DuckDB can automatically download and install its
spatialextension.
Tested with DuckDB 1.5.4 (spatial extension core build 28db190). pyproject.toml
pins duckdb to >=1.5.4,<1.6 because INSTALL spatial always fetches the extension
build matching the running DuckDB core version — pinning DuckDB is what keeps the
spatial extension version reproducible. Bump the pin (and this note) together when
upgrading.
Create a virtual environment, activate it, and install the package with dependencies:
python -m venv .venv
# Activate on Windows:
.venv\Scripts\activate
# Activate on macOS/Linux:
source .venv/bin/activate
pip install -e . # installs duck_soup + all required deps
# optional: pip install -e ".[mgrs]" adds MGRS grid conversion supportDuckDB downloads its spatial extension on first run (needs internet once, or
pre-install it offline). The mgrs package is optional — without it, func: mgrs
yields NULL instead of failing.
Run the self-contained test pipeline (no external data or services needed):
python -m duck_soup.cli check pipelines/test.yaml # validate
python -m duck_soup.cli run pipelines/test.yaml # writes output/test.gpkgRun commands in your virtual environment:
python -m duck_soup.cli check pipelines/test.yaml # validate only
python -m duck_soup.cli run pipelines/test.yaml # build the GeoPackageThe editor consists of a FastAPI backend and a TypeScript + Vite frontend.
If you just want to run the app using the pre-compiled static files, simply start the FastAPI backend:
uvicorn duck_soup.web.app:app --reloadThen open http://127.0.0.1:8000 in your browser.
If you want to modify or develop the editor's frontend, run both the backend and frontend dev server (with hot reloading):
-
Start the FastAPI backend (runs on port 8000):
uvicorn duck_soup.web.app:app --reload
-
Start the Vite dev server (runs on port 5173 and proxies
/apito port 8000):cd duck_soup/web/ui npm install npm run devThen open http://localhost:5173 in your browser.
If you have made frontend changes in duck_soup/web/ui and want to compile them to static files served by FastAPI:
cd duck_soup/web/ui
npm run build # compiles TypeScript and copies assets to ../static/The editor lets you add sources and derived sources, pick the base, build join and geoprocessing steps, define one or more output layers and their mapping, add more pipelines to the same file, and Validate / Save / Run — with a live YAML preview and run log.
A pipeline file is a Config: one shared output GeoPackage, optional dataset
metadata, and a list of independent pipelines. Each pipeline has its own
sources/base/steps/mapping, and can fan out to one or more output layers that all
get appended into that same GeoPackage:
name: ducks
description: "Ranking every duck in the neighbourhood pond by sass level"
output: output/Ducks.gpkg # one shared GeoPackage for every pipeline below
overwrite: true
metadata: # optional GeoPackage-level dataset metadata
abstract: "Duck sightings enriched with pond gossip and species drama"
gdpr: "No personal data (ducks were not available for consent)"
pipelines:
- name: ducks # first (here, only) pipeline in the file
working_crs: EPSG:25833 # CRS used for joins; defaults to base source CRS
sources:
- id: ducks # unique handle
format: gpkg # gpkg|geojson|gml|fgdb|shp|wfs|arcgis_rest|parquet|xlsx|csv
uri: data/Ducks.gpkg # path, .gdb folder, or service URL
layer: Ducks # layer / WFS typename / sheet name
crs: EPSG:4326
- id: ponds
format: geojson
uri: data/Ponds.geojson
layer: ponds
crs: EPSG:25833
- id: species_codes
format: csv
uri: data/species_codes.csv
geometry: false # tabular source
derived_sources: # optional: filtered/buffered view of a source, usable as `source:` below
- id: ponds_chill
from: ponds
where: "vibe = 'chill'" # only the drama-free ponds
base: ducks # features flow from here
steps: # ordered; each reshapes or adds columns to the row
- type: spatial_join
source: ponds
predicate: intersects # intersects|contains|within
on_multiple: first # first|largest_overlap (see "Spatial join match resolution" below)
fields: { s_pond_id: pond_id, s_pond_name: name, s_pond_type: type }
- type: attribute_join
source: species_codes
left: species_code # SQL expression / literal evaluated on the row
right: code # column on the joined source
fields: { s_species_name: common_name }
mapping: # ordered output columns; one of from/const/expr/func/codelist
- { to: name, from: "nickname" } # e.g. "Sir Quacksalot"
- { to: species, from: "s_species_name" }
- { to: pondId, from: "s_pond_id", cast: INTEGER }
- { to: longitude, func: lon } # ST_X of EPSG:4326 geometry
- { to: latitude, func: lat } # ST_Y of EPSG:4326 geometry
- { to: mgrs, func: mgrs } # for ducks who need a proper grid reference
- { to: spottedDate, func: today } # also: now, uuid, wkb, area, length
- { to: pond_label, expr: "upper(s_pond_name)" } # raw SQL on the row, e.g. "MURKY LAGOON"
layers: # one or more output layers from the same chain above
- layer: Ducks
crs: EPSG:25833
- layer: DucksChillPonds # a second layer, filtered from the same pipeline
crs: EPSG:25833
filter: "s_pond_type = 'no_drama'"A single-layer pipeline can write layer:/crs:/filter: directly instead of a
layers: list (see pipelines/test.yaml for a minimal working example) — and a
single-pipeline file can skip the pipelines: wrapper entirely and write
sources/base/steps/mapping/output directly at the top level. Both are
legacy shorthands, auto-upgraded to the Config shape above on load.
type |
what it does |
|---|---|
spatial_join |
join by predicate (intersects/contains/within); match: first (default, one row per base feature) or match: all (fan out one row per match) |
attribute_join |
1:1 left join: left (SQL expression/literal on the row) = right (column on the joined source) |
nearest_neighbor |
join the closest feature by distance; optional max_distance cap and distance_field output |
buffer |
ST_Buffer(geom, distance) |
centroid |
ST_Centroid(geom) |
clip |
keep only the geometry intersecting a matching feature from source (ST_Intersection) |
erase |
subtract the union of all matching source features from the geometry (ST_Difference) |
dissolve |
ST_Union_Agg(geom) grouped by a list of columns (omit for one feature total) |
intersect_overlay |
inner join + ST_Intersection, one output row per overlapping pair |
filter |
drop rows where where (SQL boolean) is false |
merge |
append another source's rows via UNION ALL BY NAME |
snapshot |
name the chain's current state (id:) so a later step can join back against it or fork a branch |
Every step also accepts an optional branch: — the name of an earlier snapshot to
run against instead of the main chain, letting a pipeline maintain several parallel
chains that each keep evolving on their own.
A derived_sources entry wraps an existing source (or another derived source) with
a where filter and/or a buffer, registered under its own id — usable as
source: in any step exactly like a real source. Handy for joining against only a
subset of a large reference layer, or a buffered version of it, without adding a
dedicated step to the main chain.
spatial_join with match: first (the default) keeps exactly one matching feature per
base row even when several join-source features satisfy the predicate. Which one is kept
is controlled by on_multiple:
on_multiple |
meaning |
|---|---|
first (default) |
deterministic — picks the join-source feature with the lowest original row order (its position in the source file/table), not an arbitrary query-plan order |
largest_overlap |
picks the feature with the largest ST_Intersection area with the base geometry; ties fall back to original row order |
match: all is unaffected by on_multiple — it fans out one output row per match instead
of picking a single one.
| kind | meaning |
|---|---|
from |
copy a column from the (joined) row |
const |
a literal value |
expr |
raw SQL expression evaluated against the row |
func |
lon, lat, mgrs, wkb, area, length, uuid, now, today |
codelist |
translate a column's value via rules or a CSV lookup table |
cast |
optional; wraps the result in TRY_CAST(… AS TYPE) |
For "if value LIKE mall then Mallard" style remapping, use codelist instead
of hand-writing a CASE WHEN in expr. Rules are evaluated top-to-bottom, first
match wins:
- to: species
codelist:
source: raw_species # column being translated
case_insensitive: true # default true
cases:
- { match: "mall", value: "Mallard (reigning pond champion)" } # exact match
- { like: "%gadwall%", value: "Gadwall (quietly judging everyone)" } # SQL LIKE pattern
- { regex: "^teal", value: "Green-winged Teal (chaotic good)" } # regex
default: "Mystery duck, do not approach" # used when nothing matches (omit for NULL)For large code tables (hundreds+ of codes), point at a CSV instead of listing
rules — this becomes a correlated lookup against read_csv, not a giant CASE:
- to: species_name
codelist:
source: species_code
file: codelists/duck_species.csv # two (or more) columns
file_match_col: code # key column in the CSV
file_value_col: common_name # output column in the CSV
default: "Unidentified duck"If the lookup is itself a real table with several columns you need (not just a
single translated value), model it as an attribute_join step instead — that's
a proper join, not a per-row correlated subquery, and is the better fit for
things like the species lookup table in the example above.
The editor's mapping rows support codelist directly: choosing it opens a panel
with a toggle between rules (match/like/regex → value, plus a default) and
file lookup (csv path + key/value columns).
- One
baseper pipeline. Eachpipelinesentry starts from a single source's features.mergecan append another source's rows mid-chain viaUNION ALL BY NAME, but those rows only pass through the steps after the merge, not the ones before it — so there's no way to run one identical step chain over two starting datasets at once. Write separatepipelinesentries instead (each can write to its own layer in the same shared output). match: firstis the default and is easy to reach for by accident. A base feature that overlaps more than one join-source feature — e.g. a duck paddling exactly on the boundary between two overlapping ponds — silently keeps only one match's fields unless you deliberately opt intomatch: all(see "Spatial join match resolution" above).- Synchronous run.
POST /api/runblocks on the whole pipeline and returns once it's done — no job queue, cancellation, or streamed logs for long-running jobs.check/runin the CLI are likewise blocking, single-shot commands. - Small
funcset on purpose (lon,lat,mgrs,wkb,area,length,uuid,now,today); add new ones inengine.py:_func_expr(SQL) orderive.py(python UDF).