Pushing Spatial Predicates Down with PostGIS FDW

A federated join either pushes its filter into each domain or pulls each domain’s data across the network — and the difference is not a matter of degree. Pushdown turns a cross-domain join into two indexed local scans and a small final join; its absence turns the same query into a full table transfer that scales with the size of both datasets. The maddening part is that both produce correct results, so the failure is invisible until the query is slow enough to notice. This guide configures postgres_fdw so spatial predicates push down reliably, proves it from the plan, and shows the specific mistakes that silently disable it. It implements the pushdown discipline in Federated Query and Cross-Domain Spatial Joins within Federated Ownership & Routing Architecture.

Prerequisites

Requirement Value / Assumption Notes
Tools PostgreSQL ≥ 15 with postgres_fdw, PostGIS ≥ 3.3 both ends Extension versions must match
CRS convention Both sides stored in EPSG:4326; joins declared in that CRS A mismatch returns zero rows silently
Indexes GiST on every geometry column joined across the boundary Without it there is nothing to push down to
Access roles A read-only remote role scoped to the published product FDW credentials are domain-boundary credentials
Statistics ANALYZE run on foreign tables The planner cannot cost what it has not sampled
Environment REMOTE_HOST, LOCAL_DSN Exported before running

Step-by-Step Implementation

1. Create the server and make PostGIS operators shippable

The single most important setting is extensions: without it the planner assumes PostGIS operators are unknown to the remote side and refuses to ship any predicate that uses them.

sql
-- fdw_setup.sql — a foreign server whose PostGIS predicates actually push down.
CREATE EXTENSION IF NOT EXISTS postgres_fdw;

CREATE SERVER hydrology_domain
  FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (
    host 'hydrology-db.internal',
    port '5432',
    dbname 'hydrology',
    -- THE load-bearing option. Without it the planner treats every PostGIS
    -- operator as unshippable and pulls the whole table before filtering.
    extensions 'postgis',
    fetch_size '50000',              -- larger batches; the default 100 is far too small
    async_capable 'true'             -- lets two domains scan concurrently
  );

CREATE USER MAPPING FOR CURRENT_USER
  SERVER hydrology_domain
  OPTIONS (user 'federated_reader', password_required 'true');

IMPORT FOREIGN SCHEMA published
  LIMIT TO (flood_extents)
  FROM SERVER hydrology_domain
  INTO remote_hydrology;

The four settings that decide whether an FDW join is usableFour configuration decisions in the order they take effect. The extensions option makes PostGIS operators shippable at all. A GiST index on both sides gives the shipped predicate something to use. A current ANALYZE gives the planner a realistic row estimate rather than the default placeholder. A fetch size well above the default of one hundred stops a large result crossing in thousands of round trips. Each of the first three drops onto a rail naming the specific pathology its omission produces.extensions=‘postgis’operators shippableGiST both sidessomething to useANALYZE currentrealistic estimatefetch_size 50000fewer round tripsthenthenthenomit → whole table crossesomit → remote seq scanomit → nested loopCorrect, and unusableright answer, hours late

Verify that PostGIS is declared shippable, which is not visible anywhere except the server options:

bash
psql "$LOCAL_DSN" -c "SELECT srvname, srvoptions FROM pg_foreign_server;" \
  | grep -q 'extensions=postgis' && echo "PostGIS operators are shippable"

2. Analyze the foreign table so the planner can cost it

An un-analyzed foreign table defaults to a tiny row estimate, which makes the planner choose a nested loop that fetches the remote table repeatedly.

sql
-- Sampling a foreign table costs a scan; do it after import and on a schedule.
ANALYZE remote_hydrology.flood_extents;

-- Confirm the estimate is realistic rather than the default placeholder.
SELECT relname, reltuples::bigint AS estimated_rows
FROM pg_class
WHERE relname = 'flood_extents';

Verify the estimate is within an order of magnitude of the truth; a wildly low estimate is the classic cause of a plan that looks fine and runs for an hour:

bash
psql "$LOCAL_DSN" -c "SELECT count(*) FROM remote_hydrology.flood_extents;"

3. Write the join so the predicate is shippable

Pushdown is defeated by anything the remote side cannot evaluate — a local function, a mismatched type, a reprojection applied to the remote column.

Which expressions ship to the remote domain and which do notFive expression forms judged on whether postgres_fdw will ship them to the remote side. The bounding-box operator ships and is index-backed, which is the combination that matters. An exact intersects predicate ships when the extensions option is declared but is not index-backed, so it is better evaluated locally on survivors. A literal geometry constant ships as a constant. A function wrapping the remote geometry column does not ship, which silently defeats the whole arrangement. A local function call does not ship either and forces a fetch.ShipsIndex-backedUse itgeom && constantyesyesalwaysST_Intersects(geom, const)yesnolocally, on survivorsgeometry literalyesn/aas a constantST_Transform(geom, …)nononever on the columnlocal_fn(geom)nonoforces a fetch

sql
-- federated_join.sql — a shippable predicate, both sides filtered remotely.
-- Parameters: :extent (WKT, EPSG:4326)
WITH bounds AS (
    -- The literal is evaluated locally and shipped as a constant. Correct.
    SELECT ST_GeomFromText(:'extent', 4326) AS g
)
SELECT p.parcel_id, h.severity,
       ST_Area(ST_Intersection(p.geom, h.geom)::geography) AS affected_m2
FROM local_cadastral.parcels p
JOIN remote_hydrology.flood_extents h
  -- && is the index-backed bounding-box operator and ships cleanly.
  ON p.geom && h.geom
 AND ST_Intersects(p.geom, h.geom)
CROSS JOIN bounds b
WHERE p.geom && b.g                -- pushes to the local index
  AND h.geom && b.g;               -- ships to the remote side as a constant predicate

Verify from the plan that the remote SQL carries the filter. This is the check that matters and the one most often skipped:

bash
psql "$LOCAL_DSN" -c "EXPLAIN (ANALYZE, VERBOSE) $(cat federated_join.sql)" \
  | grep -A3 'Foreign Scan'
# The "Remote SQL" line MUST contain the geometry predicate. If it reads
# "SELECT ... FROM published.flood_extents" with no WHERE, nothing is pushed down.

4. Compare the two plans to quantify what pushdown buys

bash
# Deliberately defeat pushdown by wrapping the remote column in a function, and
# compare. The difference is the argument for everything above.
psql "$LOCAL_DSN" <<'SQL'
\timing on
-- Shippable: the filter runs in the hydrology domain.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM remote_hydrology.flood_extents h
WHERE h.geom && ST_GeomFromText('POLYGON((-1 50, 1 50, 1 52, -1 52, -1 50))', 4326);

-- Not shippable: ST_Transform on the remote column forces a full fetch.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM remote_hydrology.flood_extents h
WHERE ST_Transform(h.geom, 3857) && ST_Transform(
  ST_GeomFromText('POLYGON((-1 50, 1 50, 1 52, -1 52, -1 50))', 4326), 3857);
SQL

What the extensions option is worth: rows crossing the boundary, with and without itFour measurements in thousands of rows crossing the domain boundary for one bounded join, against the 250 thousand candidate ceiling. With the extensions option declared, the remote filter runs in the hydrology domain and 12 thousand rows cross. Without it every PostGIS operator is treated as unshippable, the filter moves above the foreign scan, and the whole 4.1 million-row table crosses to be discarded locally. The middle bars show the same query with a stale row estimate and with a small fetch size, both of which degrade it without defeating pushdown.extensions=‘postgis’12k rowsfiltered remotelystale ANALYZE12k rowsnested loop, slowfetch_size=10012k rowsthousands of round tripsno extensions option4100k rowswhole table crossescandidate ceiling

Configuration Reference

Option Scope Value Effect
extensions Server postgis Without it, no PostGIS predicate ships
fetch_size Server 50000 The default of 100 makes large results crawl
async_capable Server true Two foreign scans run concurrently
use_remote_estimate Table true Planner asks the remote side for costs
updatable Table false A federated read path must not write
GiST index Both sides Required Pushdown with no index is a remote sequential scan
ANALYZE cadence Maintenance Weekly, and after bulk change Stale statistics produce nested loops

Common Failure Modes & Fixes

The plan shows a Foreign Scan with no WHERE in the remote SQL. Root cause: extensions 'postgis' missing from the server options. Fix: add it and re-plan; this single option is the difference between a filtered scan and a full table transfer.

Pushdown works for && and not for ST_Intersects. Root cause: expected and correct. The bounding-box operator is index-backed and cheap; the exact predicate is neither, and the planner is right to evaluate it locally on the survivors. Fix: nothing — write both, as the join above does, so the cheap one ships and the exact one filters what returns.

A query that pushed down last month no longer does. Root cause: someone added a reprojection or a function around the remote geometry column. Fix: reproject the constant into the remote column’s CRS instead; a literal transformed locally ships as a constant, a column transformed remotely does not.

The join returns zero rows and no error. Root cause: the two sides are in different SRIDs, so their coordinate ranges do not overlap. Fix: assert both sides’ SRID before joining; PostGIS raises on mismatched SRIDs for some operators and silently returns nothing for others.

The plan is good and the query is still slow. Root cause: fetch_size at the default of 100, so a large result crosses the network in thousands of round trips. Fix: raise it; 50,000 is reasonable for geometry-bearing rows.

FAQ

Why does extensions 'postgis' matter so much?

Because postgres_fdw will only ship an expression to the remote side if it can prove the remote side understands it, and by default it assumes the remote server knows only built-in operators. Every PostGIS operator — &&, ST_Intersects, ST_DWithin — is provided by an extension, so without the declaration the planner classifies them all as unshippable and moves them into a local filter above the Foreign Scan. The result is correct and pathological: the entire remote table is fetched and then discarded. One option changes a full transfer into an indexed remote scan.

Should the exact predicate push down too?

No, and wanting it to is usually a sign the join is structured wrongly. ST_Intersects on complex geometry is expensive and not index-backed; pushing it remote means paying that cost on every candidate row in the remote domain rather than on the small set that survived the bounding-box filter. The efficient shape is to ship the cheap indexed operator, return the candidates, and evaluate the exact predicate locally on a set that is typically orders of magnitude smaller. If the candidate set is not much smaller than the table, the bounding-box filter is not selective enough and the fix is a tighter extent.

Is FDW the right tool for a federated mesh at all?

For PostGIS-to-PostGIS joins between a small number of domains, yes — it is simple, the pushdown is well understood, and it needs no additional infrastructure. It stops being right when the join spans more than a few domains, when the sides are in different storage technologies, or when the query engine needs to parallelise across many workers. At that point a dedicated federation engine reading each domain’s published snapshot is the better shape, which is the subject of the Trino guide alongside this one. The two coexist comfortably: FDW for tight, frequent, two-domain joins; an engine for broad analytical work.

How do FDW credentials interact with the zero-trust model?

The user mapping is a domain-boundary credential and should be treated as one: scoped to a read-only role, limited to the schema the producing domain publishes, and rotated on the same schedule as any other cross-domain credential. Importing a whole remote database rather than the published schema is the common mistake — it gives the consuming domain visibility into internals the boundary was meant to hide, and it means a producer’s internal refactor can break a consumer. Import the published schema, LIMIT TO the specific products, and let the contract rather than the connection define what is visible.