PostGIS Lineage Schema Design

Part of: Storage, Indexing & Query Optimization

Relational databases remain the default system of record for most geospatial agencies, and PostGIS is the natural home for spatial provenance when the derivation history must live alongside the data it describes. The challenge is that lineage is a graph problem wearing a table-shaped costume: datasets are derived from other datasets through process steps, each step consumes sources and emits products, and the whole structure forms a directed acyclic graph (DAG) that a naive schema will flatten into unqueryable join soup. A deliberate schema — with dedicated tables for datasets, process steps, and sources, geometry columns for spatial extents, jsonb for process parameters, and foreign keys that encode the DAG edges — turns that costume into a genuine, auditable model.

This guide sits under the Storage, Indexing & Query Optimization overview and fills a specific gap: how to lay out the physical PostGIS schema so that provenance is immutable, spatially indexed, and compliant by construction. It complements the graph-native approach covered in Graph Databases for Lineage Graphs; if you have not yet decided which engine fits your workload, the trade-offs are weighed in PostGIS vs Neo4j for Spatial Lineage. Here we assume PostGIS has won and focus entirely on getting the tables, indexes, and triggers right.

PostGIS lineage schema entity model and index placement source source_id (PK) origin_uri extent geometry srid, sha256 process_step step_id (PK) algorithm, version parameters jsonb run_at dataset dataset_id (PK) produced_by (FK) extent geometry srid, sha256 step_input edge: step ← source/dataset consumes produces GiST on extent BRIN on run_at

Prerequisites

Step-by-step

1. Create the core tables

The schema has three entity tables. source records external inputs the agency does not itself produce, process_step records a single transformation with its parameters, and dataset records a product. Every product points at the step that produced it, and every step points at the inputs it consumed through an association table — together these foreign keys are the edges of the derivation DAG. Spatial extents are stored as native geometry(Polygon, 4326) columns rather than raw coordinates so the planner can use spatial operators such as ST_Intersects.

CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE source (
    source_id    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    origin_uri   text NOT NULL,
    media_type   text NOT NULL,
    extent       geometry(Polygon, 4326),
    sha256       char(64) NOT NULL,
    ingested_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE process_step (
    step_id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    algorithm    text NOT NULL,
    version      text NOT NULL,
    parameters   jsonb NOT NULL DEFAULT '{}'::jsonb,
    actor        text NOT NULL,
    run_at       timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE dataset (
    dataset_id   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    label        text NOT NULL,
    produced_by  uuid REFERENCES process_step(step_id),
    extent       geometry(Polygon, 4326),
    srid         integer NOT NULL,
    sha256       char(64) NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now()
);

-- Association table: the edges consumed by each step (source OR dataset).
CREATE TABLE step_input (
    step_id       uuid NOT NULL REFERENCES process_step(step_id),
    source_id     uuid REFERENCES source(source_id),
    input_dataset uuid REFERENCES dataset(dataset_id),
    role          text NOT NULL,
    CONSTRAINT one_input_kind CHECK (
        (source_id IS NOT NULL) <> (input_dataset IS NOT NULL)
    ),
    PRIMARY KEY (step_id, source_id, input_dataset)
);

The CHECK constraint enforces that each edge references exactly one kind of input, preventing ambiguous rows where both columns are populated or both are null.

2. Add spatial and temporal indexes

Two access patterns dominate lineage queries: “what touched this region” and “what ran in this time window”. Serve the first with a GiST index on every geometry column, and the second with a BRIN index on the naturally-ordered timestamp columns. BRIN is the right tool for append-only ingestion timestamps because the physical row order correlates with time, giving you a tiny index that still prunes effectively. A partial GiST index skips rows with no extent, which is common for non-spatial reference sources.

CREATE INDEX dataset_extent_gix ON dataset USING gist (extent)
    WHERE extent IS NOT NULL;
CREATE INDEX source_extent_gix  ON source  USING gist (extent)
    WHERE extent IS NOT NULL;

CREATE INDEX step_run_at_brin   ON process_step USING brin (run_at)
    WITH (pages_per_range = 32);
CREATE INDEX dataset_created_brin ON dataset USING brin (created_at);

-- Accelerate parameter lookups on the JSONB column.
CREATE INDEX step_params_gin ON process_step USING gin (parameters jsonb_path_ops);

-- The DAG-walk join columns.
CREATE INDEX dataset_produced_by_idx ON dataset (produced_by);
CREATE INDEX step_input_step_idx     ON step_input (step_id);

Index selection and maintenance for these access paths is treated in depth in the companion guide on tuning GiST and BRIN indexes for lineage.

3. Enforce immutability with a trigger

Provenance facts must not change after they are written. Rather than trusting application code, enforce append-only semantics in the database itself with a BEFORE UPDATE OR DELETE trigger that raises an exception. Attach it to the entity tables so that any attempt to rewrite history — accidental or malicious — fails loudly and leaves a Postgres error in the log.

CREATE OR REPLACE FUNCTION reject_mutation() RETURNS trigger AS $$
BEGIN
    RAISE EXCEPTION 'Table % is append-only; % rejected on %',
        TG_TABLE_NAME, TG_OP, now()
        USING ERRCODE = 'restrict_violation';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER dataset_immutable
    BEFORE UPDATE OR DELETE ON dataset
    FOR EACH ROW EXECUTE FUNCTION reject_mutation();

CREATE TRIGGER process_step_immutable
    BEFORE UPDATE OR DELETE ON process_step
    FOR EACH ROW EXECUTE FUNCTION reject_mutation();

If a dataset genuinely supersedes another, write a new row and link it — never mutate the old one. The full append-only pattern, extended with a tamper-evident hash chain, is built step by step in designing a PostGIS lineage audit table.

4. Ingest records from Python

The ingestion client hashes the payload, reprojects the extent to the storage CRS, and writes the source, step, product, and edges inside a single transaction so a partial failure never leaves an orphaned product. Using psycopg 3 with a parameterized query keeps the geometry as well-known text (WKT) that PostGIS parses with ST_GeomFromText.

from __future__ import annotations

import hashlib
import json
from typing import Any

import psycopg


def ingest_derivation(
    conn: psycopg.Connection,
    *,
    label: str,
    algorithm: str,
    version: str,
    parameters: dict[str, Any],
    actor: str,
    extent_wkt: str,
    payload: bytes,
    source_ids: list[str],
) -> str:
    """Insert one process step and its product atomically; return the dataset UUID."""
    digest = hashlib.sha256(payload).hexdigest()
    with conn.transaction():
        step_id = conn.execute(
            """
            INSERT INTO process_step (algorithm, version, parameters, actor)
            VALUES (%s, %s, %s::jsonb, %s)
            RETURNING step_id
            """,
            (algorithm, version, json.dumps(parameters), actor),
        ).fetchone()[0]

        for src in source_ids:
            conn.execute(
                """
                INSERT INTO step_input (step_id, source_id, role)
                VALUES (%s, %s, 'primary')
                """,
                (step_id, src),
            )

        dataset_id = conn.execute(
            """
            INSERT INTO dataset (label, produced_by, extent, srid, sha256)
            VALUES (%s, %s, ST_GeomFromText(%s, 4326), 4326, %s)
            RETURNING dataset_id
            """,
            (label, step_id, extent_wkt, digest),
        ).fetchone()[0]
    return str(dataset_id)

Because the immutability trigger blocks UPDATE, the client must get each row right on the first insert — validate against the document schema in Structuring JSON/XML Lineage Documents before calling ingest_derivation.

The Edge Table Is the Design

Why derivation needs an edge table, not a parent column A parent_id column supports one input per product; a mosaic of four hundred tiles needs an association table with one row per input. TEMPTING — a parent_id column dataset(id, …, parent_id) one input per product, forever A mosaic of 400 tiles has 400 parents. The column can hold one. The rest are lost. CORRECT — an association table source / dataset the things step_input one row per input, with a role process_step the transformation The `role` column is what lets you distinguish a primary input from a reference layer later.

The role column is the part most schemas omit and later wish they had. Every input to a step is not equally causal: a parcel layer being reprojected is the primary input, while a datum grid or a lookup table is a reference that shaped the result without being derived from. Recording that distinction at write time costs a text column; reconstructing it afterwards is guesswork, and without it impact analysis treats a shared reference table as an ancestor of everything, producing the same over-connection problem that afflicts badly modelled graphs.

Keep the association table narrow and let it be long. Millions of edge rows with three columns index and join efficiently; the temptation to denormalise attributes onto the edge — copying the input’s extent or digest for convenience — produces a table that must be updated when the input changes, which contradicts the append-only property the rest of the schema is built to guarantee.

Configuration reference

Parameter Type Valid values Default
extent SRID integer any registered EPSG code; project standard is 4326 4326
parameters jsonb any valid JSON object '{}'::jsonb
sha256 char(64) 64 lowercase hex characters none (required)
pages_per_range (BRIN) integer 1128; lower = finer pruning, larger index 128
GiST fillfactor integer 10100 90
role (step_input) text primary, auxiliary, reference primary
immutability trigger boolean enabled / disabled per table enabled

Choosing the Storage CRS

The schema above types every geometry column as geometry(Polygon, 4326), and that choice deserves justification rather than inheritance, because the arguments against it are real.

WGS 84 is the right default for lineage extents specifically. Extents are used for coarse spatial filtering — “what touched this region” — not for measurement, so the distortion that makes a geographic CRS unsuitable for area and distance calculations does not matter here. In exchange you get a single CRS across the whole estate, which means extents from datasets in a dozen different working projections are directly comparable without transformation at query time.

The counter-argument applies when lineage extents are used for anything metric. If a query needs to find records within five kilometres of a point, a geographic CRS forces either a spherical distance function or an on-the-fly transformation, both of which defeat the GiST index unless carefully written. Where that access pattern dominates, store a second geometry column in a suitable projected CRS and index both, rather than switching the canonical one.

What must not happen is a mixed estate. A geometry column typed without an SRID constraint will happily accept extents in whatever CRS the writer had, and ST_Intersects between two geometries with different SRIDs raises in some cases and returns nonsense in others. Constraining the type is what makes the reprojection happen at ingestion, where it is visible and can be logged, rather than at query time where it silently does not.

Common failure modes & mitigations

Failure mode Symptom Mitigation
Silent CRS drift Extents stored in mixed SRIDs; ST_Intersects returns empty or wrong results Type geometry columns as geometry(Polygon, 4326) and reproject with ST_Transform at ingestion; reject rows whose ST_SRID differs
Orphaned rows Products with a produced_by step that has no step_input edges Wrap step, edges, and product in one transaction; add a deferred constraint or nightly check that flags stepless products
Index bloat GiST index grows far beyond table size; scans slow after bulk loads Run REINDEX CONCURRENTLY in maintenance windows; monitor with pg_stat_user_indexes; lower fillfactor for write-heavy tables
Trigger bypass Rows edited via TRUNCATE or superuser session Restrict TRUNCATE grants; keep the audit hash chain so tampering is detectable even if a trigger is disabled
JSONB schema rot Parameters keys drift between pipeline versions Validate parameters against a versioned JSON Schema before insert; index only stable keys

Compliance & governance alignment

Control / framework Requirement Schema element that satisfies it
ISO 19115 lineage (LI_Lineage) Record process step, source, and description process_step.algorithm / version, source, step_input edges
W3C PROV-O Entities, activities, agents with derivation edges dataset (Entity), process_step (Activity), actor (Agent), produced_by / step_input (wasDerivedFrom)
FISMA AU-9 (protection of audit info) Audit records protected from modification BEFORE UPDATE OR DELETE immutability trigger + hash column
INSPIRE metadata Traceable spatial extent and quality geometry(Polygon, 4326) extents, GiST-indexed for discovery
GDPR Article 30 (records of processing) Who processed what, when process_step.actor, run_at, parameterized transformation record

For the full mapping of these regimes to lineage fields, see the regulatory overview at Regulatory Compliance Standards Mapping and the ISO 19115 lineage implementation guide.

Why the Trigger, and Not Just Permissions

Three layers of append-only protection Permissions, a database trigger, and a hash chain each stop or detect a different class of modification. 1 · Permissions — REVOKE UPDATE, DELETE from application roles stops: ordinary application paths · misses: anyone who can grant themselves more 2 · Trigger — BEFORE UPDATE OR DELETE … RAISE EXCEPTION stops: everyone who has not deliberately disabled it · misses: a superuser who does 3 · Hash chain published off-system stops: nothing · DETECTS: everything the first two missed, including the superuser Each layer covers the previous one's blind spot. None of them is sufficient alone.

The reason to have all three is that they fail to different adversaries. Permissions are administrative and can be changed by whoever administers them; the trigger is inside the database and survives a permission change but not a superuser who drops it; the chain stops nothing at all but makes any successful modification detectable afterwards. An implementation with only permissions is protecting against mistakes, which is worth doing and is not the claim an audit-record-protection control makes.

Note the ordering of cost, too. Revoking permissions is free, the trigger is a dozen lines, and the chain is the only one requiring ongoing operational work — publishing heads somewhere outside the database’s own control. That is why the chain tends to be skipped, and why skipping it is the difference between a system that resists accidents and one that produces evidence.

Where to go next

With the core schema in place, three follow-on tasks harden it for production: a tamper-evident audit table with a hash chain, spatial partitioning of high-volume lineage tables by ingestion month, and bitemporal versioning of lineage rows so historical states remain queryable. Each builds directly on the tables defined here. If you later find that recursive DAG walks dominate your workload, revisit the engine choice in PostGIS vs Neo4j for Spatial Lineage and the graph-native patterns in Graph Databases for Lineage Graphs.

Frequently Asked Questions

Should lineage tables live in the same database as the spatial data?

Usually yes, for one decisive reason: it lets a step’s write and its lineage record commit in the same transaction. Separate databases mean a step can succeed while its record fails, and the reconciliation that follows is exactly the ambiguity lineage exists to remove. Separate schemas within one database give you the access-control separation without giving up atomicity.

How do we handle very high insert volumes?

Partition by ingestion time and let the BRIN index do its job. Append-only workloads are the ideal case for time partitioning: writes go to one partition, old partitions become read-only and can be detached to cold storage as a unit, and queries with a date predicate prune everything else. The specifics are in Spatial Partitioning for Lineage Tables.

Is jsonb the right type for parameters?

Yes, with a caveat: index only the keys you actually filter on, using a GIN index with jsonb_path_ops or expression indexes on specific paths. Indexing the whole document is expensive and rarely used, since queries almost always filter on step, actor, time or extent and then read parameters rather than searching them.

How deep can recursive CTEs go before they degrade?

Further than most lineage graphs are deep. Degradation comes from fan-out rather than depth — a chain twenty steps long with one parent each is trivial, while five levels with high branching multiplies rows quickly. Add a depth column and bound the recursion explicitly rather than relying on the graph’s shape staying friendly.

What breaks when a dataset is deleted?

Nothing, if the schema is right. Foreign keys from edges to datasets should be ON DELETE RESTRICT, so a dataset that is referenced cannot be removed — which is the correct behaviour, because deleting it would orphan the lineage. Retire datasets by writing a retirement event, not by deleting rows.

Do we need a separate audit table on top of this?

Only for the hash chain, which needs its own ordering and cannot easily live on the entity tables. The entity tables are already append-only, so a general-purpose audit table duplicating their contents adds storage and no information. The tamper-evident chain is a different structure with a different job, built in Designing a PostGIS Lineage Audit Table.