Pipeline Examples
This page contains complete, ready-to-use pipeline examples demonstrating common patterns, built entirely from real blocks in the base, data, gdal, and fiadb collections shipped with Spade (see the Block Catalog for the full list). Each example includes a description of the processing workflow, the full YAML pipeline file, and an explanation of the data flow.
Example 1: Simple two-block linear pipeline🔗
Description: Fetch a raster and reproject it to a different coordinate reference system. This is the simplest possible pipeline -- two blocks connected in sequence.
name: simple-reproject
version: "1.0"
description: Fetch a raster and reproject it to EPSG:4326
blocks:
- id: "@source"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/scenes/2025-06-01.tif"
format: "GeoTIFF"
- id: "@reproject"
name: gdal.warp
inputs:
- "@source"
args:
target_crs: "EPSG:4326"
resolution: 0
resampling: "bilinear"
output_format: "GTiff"Data flow:
data.read --> gdal.warpdata.readruns first. It has no inputs (source block), so it starts immediately. It fetches the raster from storage, producing afileoutput.gdal.warpruns afterdata.readcompletes. It receives the raster via a bare reference and reprojects it to EPSG:4326.
The bare reference works here because data.read produces one file output and gdal.warp expects one file input -- the type match is unambiguous.
Example 2: Parallel branches that merge🔗
Description: Fetch a reference raster and a vector layer independently and in parallel, then burn the vector geometries into the raster's grid.
name: parallel-rasterize
version: "1.0"
description: >
Fetch a reference raster and a vector layer in parallel, then
rasterize the vector onto the raster's grid.
blocks:
# Step 1a: Fetch the reference raster (parallel branch 1)
- id: "@dem"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/dem.tif"
format: "GeoTIFF"
# Step 1b: Fetch the vector layer (parallel branch 2)
- id: "@parcels"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/parcels.geojson"
format: "GeoJSON"
# Step 2: Burn the parcels into the DEM's grid
- id: "@burned"
name: gdal.rasterize
inputs:
- block: "@parcels"
output: file
as: vectors
- block: "@dem"
output: file
as: reference
args:
burn_value: 1
attribute: ""
all_touched: falseData flow:
data.read (@dem) --+
+--> gdal.rasterize
data.read (@parcels) --+@demand@parcelsare both source blocks with no dependencies, so Spade runs them in parallel.gdal.rasterizewaits until both complete, then receives both outputs.
Both @dem and @parcels produce a file-typed output (data.read's only output is always file, regardless of format), and gdal.rasterize's two non-scalar inputs (vectors, reference) are also both type file. Spade's type matcher only looks at the coarse type field, not format -- so from its perspective these are two indistinguishable file-to-file connections. Bare references would trip a real ambiguity error here (the first one processed would find both vectors and reference as compatible candidates). This is why the pipeline above uses explicit references with as for both inputs, pinning @parcels to vectors and @dem to reference directly. See Input References for the full mechanics, including what happens if you provide block+output without as in a case like this.
Example 3: Map/reduce tile processing pipeline🔗
Description: Fetch a collection of already-tiled rasters and a shared study-area boundary, enumerate the tiles for parallel fan-out, reproject and clip each tile in parallel, then mosaic the results back into a single output image. This demonstrates the full map/reduce pattern.
name: tile-processing
version: "1.0"
description: >
Fetch a collection of already-tiled rasters, reproject and clip
each tile to a shared boundary, then mosaic the results.
blocks:
# Fetch the pre-tiled raster collection (each file already exists
# as its own separate object in storage)
- id: "@tiles"
name: data.read_collection
inputs: []
args:
uri: "s3://example-bucket/scenes/2025-06/tiles/*.tif"
format: "GeoTIFF"
max_items: 200
# Fetch the shared study-area boundary (broadcast input)
- id: "@boundary"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/boundaries/study-area.geojson"
format: "GeoJSON"
# Map: enumerate the tile collection
- id: "@enumerate"
name: gdal.map_raster_tiles
inputs:
- "@tiles"
args: {}
# Parallel: reproject each tile
- id: "@reproject"
name: gdal.warp
inputs:
- "@enumerate"
args:
target_crs: "EPSG:4326"
resolution: 0
resampling: "bilinear"
output_format: "GTiff"
# Parallel: clip each tile to the broadcast boundary
- id: "@clip"
name: gdal.clip_raster_by_vector
inputs:
- block: "@reproject"
output: raster
as: source
- block: "@boundary"
output: file
as: boundary
args:
crop_to_cutline: true
all_touched: false
# Reduce: mosaic all clipped tiles
- id: "@mosaic"
name: gdal.reduce_mosaic
inputs:
- "@clip"
args:
resampling: "nearest"Data flow:
data.read_collection --> gdal.map_raster_tiles --+--> gdal.warp (tile 0) --> gdal.clip_raster_by_vector (tile 0) --+
| |
data.read (@boundary) ---------broadcast----------+--> gdal.warp (tile 1) --> gdal.clip_raster_by_vector (tile 1) --+--> gdal.reduce_mosaic
| |
+--> gdal.warp (tile N) --> gdal.clip_raster_by_vector (tile N) --+data.read_collectionanddata.readrun in parallel (both are source blocks with no dependencies).gdal.map_raster_tilesruns after the tile collection is fetched. It is a map block (kind: mapin its manifest) that enumerates the tiles the collection already contains -- it does not split anything itself -- and produces an expansion manifest listing each one.gdal.warpenters map context because its input comes from the map block. Spade creates one invocation per tile, all running in parallel.gdal.clip_raster_by_vectoralso runs in map context. Each invocation receives two inputs:- Its own reprojected tile (mapped input from
gdal.warp) - The study-area boundary (broadcast input from
data.read, shared across all invocations)
- Its own reprojected tile (mapped input from
gdal.reduce_mosaicis a reduce block (kind: reducein its manifest). It waits for all clip invocations to complete, receives a collection of all clipped tiles, and produces a single mosaic output.
Example 4: Explicit input references🔗
Description: Join two independently-produced tables on a shared key column, then derive a new column from the joined result. Because base.join's two inputs (left and right) are both type file, bare references would be ambiguous. This example uses explicit references to wire the correct outputs to the correct inputs.
name: explicit-references
version: "1.0"
description: >
Join two tables and compute a derived column, demonstrating why
explicit references with `as` are needed when both inputs share a type.
blocks:
# Fetch the area-estimates table
- id: "@estimates"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/area-estimates.parquet"
format: "Parquet"
# Fetch the county covariates table
- id: "@covariates"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/county-covariates.parquet"
format: "Parquet"
# Join the two tables on the shared key column.
# base.join declares two inputs of the same type ("left" and
# "right", both `file`), so neither a bare reference nor a plain
# block+output reference (without `as`) can safely disambiguate
# them -- see the note below.
- id: "@joined"
name: base.join
inputs:
- block: "@estimates"
output: file
as: left
- block: "@covariates"
output: file
as: right
args:
on: county_fips
how: inner
# Derive a per-capita column from the joined result
- id: "@per-capita"
name: base.mutate
inputs:
- "@joined"
args:
expressions: '[{"name":"per_capita","expr":"estimate / population"}]'Data flow:
file output ---> left input
data.read (@estimates) --> base.join --> base.mutate
file output ---> right input
data.read (@covariates)@estimatesand@covariateseach fetch a table, running in parallel.@joinedneeds both tables as input. Because both outputs and both inputs share the same type (file), type matching can't disambiguate them even after naming the output --asis required to pin the wiring directly:output: filefrom@estimateswithas: leftwires it straight to theleftinput.output: filefrom@covariateswithas: rightwires it straight to therightinput.
@per-capitareceives the joined table via a bare reference (unambiguous, sincebase.joinproduces one output) and computes a derived column.
If you attempted to use bare references for step 2:
# THIS FAILS VALIDATION
inputs:
- "@estimates"
- "@covariates"spade check reports a real ambiguity error here, because the first bare reference it processes already has two type-compatible candidate inputs (left and right):
Pipeline validation failed with 1 error(s):
- block <joined-id> (base.join): ambiguous type match: output <estimates-id>.file (type "file") matches multiple inputs: [left right]If you instead used block+output without as for both references, spade check would not report an error at all -- it would silently wire whichever reference comes first in the list to left and the other to right, regardless of which table you meant to go where. See Input References for why this happens and why as is the only way to guarantee the correct wiring.