LLM-Assisted Geoprocessing Pipelines

Turn a natural-language analysis request into a validated chain of geoprocessing steps, with every step's output checked before the next one consumes it.

“Which residential parcels are within 200 metres of a watercourse and outside the flood zone” is one sentence and four geoprocessing operations, each of which can succeed while producing something the next step cannot use. Planning that chain, and validating between its steps, is what separates an analysis you can defend from a sequence of plausible operations ending in a number.

This topic belongs to geospatial prompt engineering and tool routing and sits above GeoPandas and PostGIS tool routing, which decides where each planned step runs. Chains long enough to need checkpointing belong to multi-step spatial agent orchestration.

One sentence decomposed into four validated stepsSelecting residential parcels, buffering watercourses, intersecting the two and subtracting the flood zone, with a validation gate between each pair of steps.selectbufferintersectdifferenceresidential parcelswatercourses, 200 mthe two resultsminus the flood zonea validation gate between every pair: non-empty, valid, in the expected frame, plausibly sized
The gates are where the analysis becomes defensible. Each operation can succeed and produce an empty or degenerate result, and without a check the next step consumes it happily and the chain ends in a confident zero.

Foundational Principles

Decompose before executing. The whole chain is planned, reviewed and estimated before the first operation runs. A pipeline assembled step by step as the model reacts to each result cannot be costed, cannot be checkpointed, and cannot be explained afterwards.

Every intermediate result is validated. Non-empty, valid, in the expected frame, and of a plausible size. Those four checks catch nearly every silent failure, and they are cheap relative to the operations they guard.

An empty intermediate is a stop, not a value. A buffer that produced nothing, an intersection that found nothing — these mean the analysis has already failed, and continuing produces an answer about nothing that reads like an answer about something.

Step-by-Step Implementation Pipeline

1. Decompose the request into a typed plan

The plan is a list of typed steps with named inputs and outputs, produced once and reviewable as data.

import logging
from dataclasses import dataclass
from typing import Literal, Optional, Sequence

log = logging.getLogger("geoprocessing_plan")

Op = Literal["select", "buffer", "intersect", "difference", "dissolve", "aggregate"]


@dataclass(frozen=True)
class Step:
    name: str
    op: Op
    inputs: tuple[str, ...]              # names of earlier steps or source layers
    params: dict


@dataclass(frozen=True)
class Plan:
    steps: tuple[Step, ...]
    result: str                          # the step whose output is the answer


class PlanRejected(ValueError):
    """The plan cannot be executed as written."""

2. Check the plan before running any of it

Most bad plans are detectable statically: a step referencing an output that does not exist, an operation applied to the wrong geometry type, a chain whose result is not produced by any step.

GEOMETRY_REQUIREMENTS = {
    "buffer":     {"Point", "LineString", "Polygon", "MultiPolygon"},
    "intersect":  {"Polygon", "MultiPolygon"},
    "difference": {"Polygon", "MultiPolygon"},
    "dissolve":   {"Polygon", "MultiPolygon"},
}


def check_plan(plan: Plan, sources: dict[str, str]) -> Plan:
    """Static checks: references resolve, types match, the result exists."""
    produced: dict[str, str] = dict(sources)          # name -> geometry type
    for step in plan.steps:
        for ref in step.inputs:
            if ref not in produced:
                raise PlanRejected(f"step {step.name!r} references unknown input {ref!r}")
        allowed = GEOMETRY_REQUIREMENTS.get(step.op)
        if allowed:
            for ref in step.inputs:
                if produced[ref] not in allowed:
                    raise PlanRejected(
                        f"{step.op} cannot take {produced[ref]} from {ref!r}")
        produced[step.name] = _output_type(step.op, [produced[r] for r in step.inputs])
    if plan.result not in produced:
        raise PlanRejected(f"the plan's result {plan.result!r} is not produced by any step")
    return plan

Type-checking the chain statically catches the most common planning error — buffering a polygon and then intersecting with a line, or dissolving something that is not an area — before any expensive operation runs. The decomposition itself is developed in decomposing natural language into geoprocessing steps.

3. Estimate the whole chain, not each step

A chain’s cost is dominated by the step with the largest intermediate, which is frequently not the step that looks expensive. Estimating the whole plan lets the agent trade a cheaper decomposition before anything runs.

def estimate_chain(plan: Plan, sizes: dict[str, int]) -> tuple[int, str]:
    """Track the largest intermediate; that is what the chain will cost."""
    current = dict(sizes)
    peak, peak_step = 0, ""
    for step in plan.steps:
        inputs = [current.get(r, 0) for r in step.inputs]
        if step.op == "buffer":
            out = inputs[0]
        elif step.op in {"intersect", "difference"}:
            out = int(max(inputs) * 1.2)              # overlays can add vertices and parts
        elif step.op == "dissolve":
            out = max(1, inputs[0] // 10)
        else:
            out = inputs[0] if inputs else 0
        current[step.name] = out
        if out > peak:
            peak, peak_step = out, step.name
    return peak, peak_step

4. Validate between every pair of steps

The four checks are the same each time, and running them uniformly is what makes the chain’s failure modes predictable.

@dataclass(frozen=True)
class Check:
    ok: bool
    reason: str


def validate_intermediate(result, expected_epsg: int, input_count: int,
                          step_name: str) -> Check:
    """Four checks that catch nearly every silent intermediate failure."""
    if result is None or len(result) == 0:
        return Check(False, f"{step_name} produced no features")
    invalid = sum(1 for g in result.geometry if g is not None and not g.is_valid)
    if invalid:
        return Check(False, f"{step_name} produced {invalid} invalid geometries")
    epsg = getattr(result.crs, "to_epsg", lambda: None)()
    if epsg != expected_epsg:
        return Check(False, f"{step_name} output is in EPSG:{epsg}, expected {expected_epsg}")
    if input_count and len(result) > input_count * 50:
        return Check(False,
                     f"{step_name} produced {len(result)} features from {input_count} "
                     "inputs — check for an unintended cross join")
    return Check(True, "")

The size sanity check is the one that catches the most expensive mistake. An overlay against the wrong layer produces a cross product, and the result is a valid, non-empty, correctly framed dataset of two million slivers that the next step will happily consume. The full set of checks is developed in validating intermediate geoprocessing outputs.

An empty intermediate propagating to a confident zeroA buffer that produced nothing feeds an intersection that produces nothing, which feeds a count that returns zero — a well-formed answer to a question the chain never actually asked.840 parcels0 buffers0 featurescount: 0every step succeeded; the answer is a confident zero about nothinggate stops herereason: the watercourse layer was empty here
Zero is the most dangerous valid answer. It is well-formed, it is what the arithmetic produced, and it is indistinguishable from a genuine finding unless something checked the step that first produced nothing.

5. Stop on a failed check, with the step named

A failed intermediate is a stop, and the message needs the step name and the reason — that is what turns a dead chain into a correctable plan.

def run_chain(plan: Plan, execute, expected_epsg: int) -> dict:
    """Execute with a gate after every step. Stops at the first failure."""
    results: dict[str, object] = {}
    for step in plan.steps:
        inputs = [results.get(r) for r in step.inputs]
        try:
            out = execute(step, inputs)
        except Exception as exc:
            return {"ok": False, "failed_at": step.name, "reason": f"execution failed: {exc}"}
        check = validate_intermediate(out, expected_epsg,
                                      sum(len(i) for i in inputs if i is not None),
                                      step.name)
        if not check.ok:
            log.info("chain stopped at %s: %s", step.name, check.reason)
            return {"ok": False, "failed_at": step.name, "reason": check.reason}
        results[step.name] = out
    return {"ok": True, "result": results[plan.result], "steps": list(results)}

6. Keep the plan and the result together

An analysis result without its plan cannot be defended or repeated. Returning both, with the parameters that produced each step, is what makes a number a finding rather than an assertion.

def to_finding(plan: Plan, result, notes: Sequence[str]) -> dict:
    return {
        "value": summarise(result),
        "method": [{"step": s.name, "op": s.op, "params": s.params} for s in plan.steps],
        "notes": list(notes),
    }

7. Prefer a plan the database can run in one pass

A chain of four operations executed as four round trips through a process is slower and more fragile than the same chain expressed as one query where the database can do it. Detecting that case is worth the effort for common shapes.

FUSIBLE = ({"select", "buffer", "intersect"}, {"select", "intersect", "difference"})


def can_fuse(plan: Plan) -> bool:
    """Can this chain be expressed as a single query?"""
    ops = {s.op for s in plan.steps}
    return any(ops <= fusible for fusible in FUSIBLE) and len(plan.steps) <= 4

Fusing is an optimisation and not a requirement, and it should never change the result. Where a fused query and a stepwise chain disagree, the stepwise chain is the reference — it is the one whose intermediates were checked. The specific pattern of buffer, overlay and dissolve chains is covered in planning buffer, overlay and dissolve chains from a prompt.

8. Record what each step actually produced

Row counts per step are the cheapest diagnostic in the whole topic. A chain that ended in zero is explained instantly by the step where the count first became zero, and that number costs nothing to record.

def step_counts(results: dict[str, object]) -> dict[str, int]:
    return {name: (len(value) if value is not None else 0)
            for name, value in results.items()}

9. Resolve source layers against the catalog at plan time

A plan referencing a layer that does not exist should fail while it is still a plan, not three steps into execution. Resolving names against the live catalog during the static check is what makes that happen, and it also catches the more dangerous case of a name that resolves to something other than what was meant.

def resolve_sources(plan: Plan, catalog) -> dict[str, str]:
    """Map every source name to a real layer and its geometry type, or reject."""
    sources: dict[str, str] = {}
    referenced = {ref for step in plan.steps for ref in step.inputs}
    step_names = {step.name for step in plan.steps}
    for ref in sorted(referenced - step_names):
        record = catalog.get(ref)
        if record is None:
            raise PlanRejected(
                f"source layer {ref!r} does not exist; available layers are "
                f"{', '.join(sorted(catalog)[:8])}")
        if record.get("deprecated"):
            log.info("plan uses deprecated layer %r (successor: %s)",
                     ref, record.get("successor"))
        sources[ref] = record["geometry_type"]
    return sources

Listing the available layers in the rejection is what makes it recoverable in one turn. A model told that a layer does not exist will invent another plausible name; told which names exist, it picks one.

10. Keep the plan reproducible across data versions

A finding produced against last month’s data cannot be compared with one produced today unless both record which version they ran against. Stamping the source versions onto the plan makes a re-run either identical or explicitly different.

def stamp_versions(plan: Plan, catalog) -> dict:
    """Record the version of every source the plan touches."""
    referenced = {ref for step in plan.steps for ref in step.inputs}
    step_names = {step.name for step in plan.steps}
    return {ref: catalog[ref].get("version", "unversioned")
            for ref in sorted(referenced - step_names) if ref in catalog}

The unversioned default is deliberately visible. A source with no version is one whose findings cannot be reproduced, and seeing that string in a stored result is more useful than an absent field nobody notices — it is a prompt to fix the source rather than the analysis.

Operating This Stage Over Time

Plans acquire steps. A chain that started as three operations grows a filter, then a reprojection, then a cleanup, and each addition is individually justified. The cost is not just latency: every additional step is another place an intermediate can be empty, and a seven-step chain fails more often than a three-step one for reasons that have nothing to do with the data. Reviewing chain length distribution occasionally, and asking whether the longest ones could be expressed differently, is worth more than optimising any single step.

Source layers change beneath a plan. A layer renamed or re-partitioned upstream turns a working plan into a static check failure, which is the good case, or into a plan that runs against a different layer with the same name, which is not. Validating source names against the live catalog at plan time — not at execution time — turns the second case into the first.

The size sanity threshold needs attention as data grows. A multiplier that flagged cross joins against a corpus of thousands will flag legitimate results against a corpus of millions, and once it produces false positives it gets raised until it no longer fires. Derive it from the operation rather than from a constant: an intersection legitimately produces more features than its inputs, a dissolve produces fewer, and a buffer produces the same number.

Finally, keep the reference implementation stepwise even after fusing is introduced. The fused path is faster and unvalidated between steps, and the day the two disagree you will want the one whose intermediates were checked to be the one you trust.

Failure Modes & Root Causes

The confident zero. Every step succeeds, the answer is zero, and no step produced anything after the second. Root cause: no gate on empty intermediates. Mitigation: stop at the first empty result, naming the step.

The cross join. An overlay against the wrong layer produces millions of slivers, and the chain continues. Root cause: no size sanity check. Mitigation: bound the output size relative to the inputs, per operation.

The frame slip. One step returns geometry in a different frame and the next overlays it against the original, producing an empty or nonsensical result. Root cause: frames checked at ingestion but not between steps. Mitigation: check the frame on every intermediate.

The unrepeatable finding. A number is produced and nobody can reconstruct how. Root cause: the plan discarded after execution. Mitigation: return the plan with the result, always.

Production Validation Protocols

  1. Static plan check. Assert every plan passes reference resolution and geometry-type checking before execution; a plan that fails should never reach a database.
  2. Empty-intermediate test. Assert a chain whose second step produces nothing stops there, with that step named.
  3. Cross-join guard test. Assert an overlay producing far more features than its inputs is stopped, using a fixture that triggers it.
  4. Frame-consistency test. Assert an intermediate in an unexpected frame stops the chain rather than being reprojected silently.
  5. Fusion equivalence test. Where fusing is enabled, assert the fused and stepwise paths produce identical results on a fixture set.
  6. Step-count publication. Publish per-step row counts with every finding; they are the diagnostic that explains a surprising answer.
The four checks applied at every gateNon-empty, valid geometry, expected frame and plausible size — four cheap checks that between them catch nearly every silent intermediate failure.non-emptyvalidright frameplausible sizecatches theconfident zerocatches repairsthe next step needscatches a slipbetween stepscatches across joinFour comparisons per step, and between them nearly every silent failure
Cheap checks, expensive failures. Each of these is a length, a boolean or an integer comparison, and each one guards against a failure that otherwise reaches an answer intact.

Frequently Asked Questions

Should the model see intermediate results?

Counts and validity, not geometry. A model told that the buffer step produced 1,240 features can reason about whether that is plausible and can explain the finding; a model handed the geometry spends context on data it cannot use and occasionally starts reasoning about individual features. The exception is a short chain over a handful of features, where the geometry is small and genuinely informative.

How long should a chain be allowed to get?

Four or five steps covers most real analyses, and a plan longer than that is usually two analyses or one that should be expressed differently. Longer chains are not wrong, but each step multiplies the ways the whole can fail and lengthens the explanation the answer needs. A cap with an override, plus a log of overrides, keeps the pressure in the right direction.

What should happen when a step succeeds but produces something implausible?

Stop, and say what was implausible. A dissolve that produced more features than it consumed, an intersection larger than either input, a buffer that changed the feature count — these are all valid outputs and all signals that the plan does not mean what it appears to. Continuing produces an answer nobody can defend, and the check costs one comparison.

Can the plan be repaired automatically when a check fails?

Sometimes, and it should be offered rather than applied. A frame mismatch has an obvious repair, and applying it silently means the chain now contains a step nobody planned. Returning the failure with a suggested amendment lets the agent re-plan explicitly, which keeps the plan and the execution in agreement — which is the property that makes the finding defensible.

How does this differ from orchestration?

Scope and durability. A pipeline is one analysis planned up front and executed within a turn; orchestration handles chains that span turns, checkpoint state and survive failures partway through. The boundary is roughly whether the work fits in an interactive budget — see multi-step spatial agent orchestration for what happens when it does not.