Designing JSON schemas for PostGIS tool calls means writing the typed contract that stands between a language model’s intent and a live spatial database, so that operations like ST_DWithin and ST_Intersects receive only validated, bounded parameters and always compile to an index-aware query. This guide sits under Spatial Function-Calling Schemas and covers the execution stage where a validated tool call is rendered into parameterized SQL that a query planner can actually accelerate.
The stakes are concrete. A schema that maps loosely to PostGIS produces queries that either scan the whole table because they skipped the && bounding-box pre-filter, or crash because a geometry column was addressed with the wrong SRID. A schema that maps tightly produces a parameterized, GiST-index-friendly query every time, with an unsafe or unbounded call rejected before it ever reaches the connection.
When to Use This Approach
Use a per-operation JSON schema plus a server-side builder when the model must trigger a fixed vocabulary of spatial predicates against known tables. It is the right tool when correctness and index usage matter more than expressive breadth. Reach for free SQL generation only when the query shape is genuinely open-ended.
| Situation | Schema-bound tool call | Free SQL generation |
|---|---|---|
Fixed set of predicates (ST_DWithin, ST_Intersects) |
Preferred — typed, bounded | Overkill, unsafe |
Guaranteed && index pre-filter |
Enforced by builder | Depends on model discipline |
| Arbitrary ad-hoc analytics | Too rigid | Preferred, with guards |
| Auditable, replayable calls | Native | Requires SQL parsing |
For the open-ended path and its injection defenses, see Generating Valid PostGIS Queries from Natural Language.
Implementation
The schema below models an ST_DWithin proximity join. Every field is constrained: the table and geometry column are drawn from an allowlist, the SRID is an enum, and the distance is bounded and unit-named. The builder then renders a parameterized query that always emits the && bounding-box pre-filter before the ST_DWithin predicate, guaranteeing the GiST index is consulted. If any invariant fails, the call is rejected and a deterministic fallback query (empty result, logged) is returned instead of an unsafe execution.
import logging
from enum import IntEnum
from typing import Dict, Any, Tuple
from pydantic import BaseModel, Field, field_validator, ValidationError
from psycopg2 import sql
logger = logging.getLogger("postgis_toolcall")
# Allowlist derived from the live catalog at startup; never trust model-supplied names.
REGISTERED_LAYERS: Dict[str, str] = {
"parcels": "geom",
"hydrants": "geom",
"flood_zones": "geom",
}
class LayerSRID(IntEnum):
WGS84 = 4326
UTM33N = 32633
class DWithinToolCall(BaseModel):
left_layer: str
right_layer: str
srid: LayerSRID
distance_meters: float = Field(ge=0.0, le=25_000.0)
max_rows: int = Field(ge=1, le=1000)
@field_validator("left_layer", "right_layer")
@classmethod
def known_layer(cls, v: str) -> str:
if v not in REGISTERED_LAYERS:
raise ValueError(f"unknown layer: {v}")
return v
@field_validator("distance_meters")
@classmethod
def finite(cls, v: float) -> float:
if v != v or v in (float("inf"), float("-inf")):
raise ValueError("distance_meters must be finite")
return v
def build_dwithin_query(call: DWithinToolCall) -> sql.Composed:
"""Render a parameterized, index-aware ST_DWithin join.
The && bbox pre-filter precedes ST_DWithin so the GiST index is used;
geometries are cast to a metric CRS for a true metre distance.
"""
lgeom = REGISTERED_LAYERS[call.left_layer]
rgeom = REGISTERED_LAYERS[call.right_layer]
return sql.SQL(
"""
SELECT a.id AS left_id, b.id AS right_id
FROM {lt} a
JOIN {rt} b
ON a.{lg} && ST_Expand(b.{rg}, %(dist)s)
AND ST_DWithin(
a.{lg}::geography, b.{rg}::geography, %(dist)s
)
WHERE ST_SRID(a.{lg}) = %(srid)s
AND ST_SRID(b.{rg}) = %(srid)s
LIMIT %(lim)s
"""
).format(
lt=sql.Identifier(call.left_layer),
rt=sql.Identifier(call.right_layer),
lg=sql.Identifier(lgeom),
rg=sql.Identifier(rgeom),
)
def run_dwithin_toolcall(raw_args: Dict[str, Any], conn) -> Dict[str, Any]:
try:
call = DWithinToolCall.model_validate(raw_args)
except ValidationError as exc:
logger.warning("rejected ST_DWithin call: %s", exc.errors())
return {"status": "rejected", "reason": "SCHEMA_REJECT", "rows": []}
query = build_dwithin_query(call)
params = {
"dist": call.distance_meters,
"srid": int(call.srid),
"lim": call.max_rows,
}
try:
with conn.cursor() as cur:
cur.execute(query, params)
rows = cur.fetchall()
return {"status": "ok", "rows": rows}
except Exception as exc:
conn.rollback()
logger.error("ST_DWithin execution failed: %s", exc)
# Deterministic fallback: empty, logged, never a partial write.
return {"status": "fallback", "reason": "EXEC_ERROR", "rows": []}
The && ST_Expand(...) clause is the load-bearing detail: it gives the planner a bounding-box join condition that the GiST index can satisfy before the exact ST_DWithin distance test runs on the surviving candidates. The ::geography cast makes distance_meters a true metric distance regardless of the stored projection. Because layer names come from REGISTERED_LAYERS and reach SQL only through sql.Identifier, the model can never inject a table name or column. This composes cleanly into a larger plan; see Multi-Step Spatial Agent Orchestration for chaining several such calls.
Validation & Testing
- Reject unsafe calls. Assert that
run_dwithin_toolcall({"left_layer": "'; DROP TABLE parcels; --", ...})returnsstatus == "rejected"and never builds a query, proving the allowlist blocks injection at the schema boundary. - Prove the index pre-filter is present. Render
build_dwithin_queryand assert the generated SQL string contains both&&andST_DWithin, with the&&occurring first — a regression guard against silently dropping the bounding-box pre-filter. - Bounds enforcement. Assert
distance_meters=1e12anddistance_meters=float("inf")both raiseValidationError, confirming the query planner is never handed an unbounded radius. - Plan check in CI. Against a seeded fixture database, run
EXPLAINon the built query and assert the plan references anIndex Scan(orIndex Cond) on the geometry column rather than aSeq Scan.
Gotchas & Edge Cases
Casting to geography changes what the index matches. ST_DWithin(a::geography, b::geography, d) uses spherical distance, but the && operator compares planar bounding boxes in the stored SRID. Keep the && term on the geometry columns (via ST_Expand) so the index still applies; only the exact predicate uses geography.
SRID enum wider than the data. If the schema allows SRID 4326 but a row is stored as 32633, the ST_SRID guard filters it out silently and results look empty. Derive LayerSRID from the actual ST_SRID values in each layer at startup, not from a hand-typed list.
ST_Expand in degrees vs metres. When the geometry column is stored in a geographic CRS (degrees), ST_Expand(geom, 25000) expands by 25000 degrees, which is meaningless. Either store the pre-filter geometry in a projected CRS or expand using a degree-equivalent computed from latitude before building the query.
Unbounded LIMIT. Omitting max_rows lets a broad join stream millions of pairs. Keep max_rows required and bounded so a permissive proximity call cannot become an accidental cross join.