Skip to content

Procedures

Some database work is neither a document write nor an analytical read: recomputing a rollup over a freshly-ingested batch, calling a stored function, refreshing a materialized view. You can always reach for the raw client — but then you own correctness, with no parameter validation, no tenancy, no portability. The procedures port is the governed middle ground: a spec-named, parametrized command or compute step. It is the write-and-compute twin of the governed, parametrized reads you run through analytics, sharing the same machinery and differing only in intent.

Where it sits

A handler touches the database three ways, and procedures fills the gap between the other two:

Approach What it does Governed?
Raw client any SQL, inline no — you own validation, tenancy, portability
Procedures a named command / compute step yes — validated params, tenancy, backend-portable
Analytics named reads, pages of rows yes

Procedures promotes raw SQL to a named, validated, tenant-aware, portable operation. Unlike analytics it mutates or computes rather than returning pages — so it is command-only, and acquiring it inside a read-only operation fails closed. The motivating case is a batch pipeline: ingest a large batch, then run one set-based statement over it, instead of per-row triggers that overload the database.

The shape in code

One spec is one procedure: a typed params model in, and an optional typed result. It names nothing about SQL or the backend — that lives in the wiring.

class Sale(BaseModel):
    region: str
    amount: int


class RegionTotal(BaseModel):
    region: str
    total: int


class RegionQuery(BaseModel):
    pass


class RecomputeWindow(BaseModel):
    since: str = "2026-01-01"


# The batch lands via analytics append; the read side serves recomputed per-region totals.
SALES_SPEC = AnalyticsSpec[RegionTotal, Sale](
    name="sales",
    read=RegionTotal,
    queries={"by_region": AnalyticsQueryDefinition(params=RegionQuery)},
    ingest=Sale,
)

# The procedure is the whole handler-facing surface: typed params, no result model means
# side-effect only (returns an affected-row count). Command-only — it mutates/computes.
RECOMPUTE_SPEC = ProcedureSpec[RecomputeWindow, None](
    name="recompute_region_totals",
    params=RecomputeWindow,
)

A handler resolves the port off the context and runs it with typed params. The recompute below replaces a swarm of per-row triggers with a single governed call:

async def ingest_sales(ctx: ExecutionContext, sales: list[Sale]) -> None:
    # Append-only ingest of the batch (stands in for a large bulk load).
    await ctx.analytics.ingest(SALES_SPEC).append(sales)


async def recompute(ctx: ExecutionContext, since: str = "2026-01-01") -> int:
    # ONE governed set-based statement over the whole batch — no per-row triggers.
    result = await ctx.procedure.command(RECOMPUTE_SPEC).run(
        RecomputeWindow(since=since)
    )
    return result.affected_count or 0


async def region_totals(ctx: ExecutionContext) -> list[RegionTotal]:
    page = await ctx.analytics.query(SALES_SPEC).run("by_region", RegionQuery())
    return list(page.hits)

What comes back

The result you declare on the spec drives the shape of the ExecResult. The cardinality is deliberately narrow — a scalar, a single row, or an affected-row count. Pages of rows are analytics' job, not this port's.

result on the spec ExecResult carries Use for
None affected_count a side effect — recompute, REFRESH, CALL
a scalar type (int, …) value a function returning one value
a Pydantic model value a function or SELECT returning one row

Mapping it to Postgres

The handler-facing spec stays backend-agnostic; the statement lives in the integration config. On Postgres, one PostgresProcedureConfig maps a route to a statement with %(name)s placeholders:

PostgresProcedureConfig(
    # A set-based statement; with result=None the rowcount is the affected count, so this is DML
    # (not `SELECT a_function(...)`, which returns one row — a count-returning function uses a
    # scalar result instead).
    sql=(
        "INSERT INTO region_totals (region, total) "
        "SELECT region, sum(amount) FROM sales WHERE since >= %(since)s GROUP BY region "
        "ON CONFLICT (region) DO UPDATE SET total = excluded.total"
    ),
    in_transaction=True,            # False for REFRESH MATERIALIZED VIEW CONCURRENTLY
    statement_timeout=None,         # SET LOCAL statement_timeout for long compute
    query_schema=None,              # per-tenant schema (namespace tier)
)

Set in_transaction=False for statements that cannot run inside a transaction (REFRESH MATERIALIZED VIEW CONCURRENTLY and some maintenance); they take the autocommit path. statement_timeout and query_schema both need a transaction, so they are rejected at wiring when combined with in_transaction=False.

Tenancy

Analytics and document writes can enforce tenant isolation because the framework composes the predicate. A procedure runs author-supplied opaque SQL, so the framework can only enforce isolation where the tenant boundary lives outside that SQL:

Tier Mechanism Posture
namespace per-tenant schema via query_schema enforced — the SQL runs in the tenant's own schema
dedicated a routed client per tenant enforced — strongest isolation
tagged %(tenant)s bound into the SQL offered, then validated (below)

At the tagged tier the framework binds the current tenant id as the %(tenant)s parameter, but it cannot prove the SQL uses it to scope. So a tenant_aware route is allowed, but checked.

A tenant-aware route fails closed at wiring

Wiring a tenant_aware procedure whose SQL never references %(tenant)s raises at startup, not at runtime — a route that declares tenant-scoping but forgets to bind the tenant is a configuration error, never a silent leak. This proves the parameter is bound; for genuinely enforced isolation, prefer the namespace or dedicated tier. See multi-tenancy.

A procedure that is inherently cross-tenant — refreshing a shared materialized view, a cross-tenant rollup — is simply not tenant_aware, the same as any other non-tenant port.

The batch-recompute case

The flow that motivates the port: a batch lands, then one set-based statement recomputes over it. The compute is expressed once, in the registered statement — here modelled in-process so the recipe runs without a warehouse:

def _recompute_region_totals(
    params: RecomputeWindow,
    state: MockState,
) -> ExecResult[None]:
    """Model the set-based recompute the production SQL performs: read the ingested batch,
    aggregate per region in one pass, materialize the read model. Returns the rows written.
    """

    sales = state.analytics_ingest_log.get("sales", [])

    totals: dict[str, int] = {}
    for row in sales:
        totals[row["region"]] = totals.get(row["region"], 0) + int(row["amount"])

    state.analytics_query_hits["sales"] = {
        "by_region": [{"region": r, "total": t} for r, t in sorted(totals.items())]
    }

    return ExecResult(affected_count=len(totals))

In production that handler is a single Postgres statement; the calling code does not change. Field encryption applies to a procedure's params when a keyring is wired — the same field-level encryption the document and analytics paths use.