Optimizing a query without changing its behavior
Problem
Section titled “Problem”agent_workload_summary(org_id) returns one row per agent in the org with their open count, pending count, and SLA breach count. It uses one correlated subquery per metric — readable, but slow as tickets grows. A senior engineer rewrites it as a single JOIN with FILTER aggregations. The query plan is much better. After deployment, a new agent who joins the team but hasn’t been assigned any tickets simply doesn’t appear on the workload dashboard — they’re invisible.
v1: the slow version
Section titled “v1: the slow version”The correlated subqueries return 0 when the agent has no matching tickets, but the outer query always returns a row for every agent — because the only FROM clause is org_members.
v2: the optimization candidate
Section titled “v2: the optimization candidate”JOIN tickets is an INNER JOIN. Agents without tickets disappear. The reviewer reads “join agents to their tickets, group, aggregate” and never asks “what about agents without tickets?”.
The example test (passing)
Section titled “The example test (passing)”Doesn’t compare v1 to v2 at all. Doesn’t try an agent with zero tickets.
The SqlProof property
Section titled “The SqlProof property”The skip guard handles the realistic case: until v2 is shipped via its migration, the equivalence test has nothing to compare against. Once the migration runs, the test starts gating CI.
The counterexample
Section titled “The counterexample”Illustrative — Hypothesis would print the actual draw and assertion:
Two agents (agent1 and agent3) have no tickets. v1 includes them with zero counts; v2 drops them entirely.
The fix
Section titled “The fix”Change JOIN to LEFT JOIN:
The LEFT JOIN preserves the org_members row even when no tickets match; count(*) FILTER (...) then returns 0 over the one-row group (because the filter evaluates to NULL/false on the all-NULL ticket fields), matching v1’s contract.
Lifecycle: when to write and when to delete
Section titled “Lifecycle: when to write and when to delete”This is the recipe’s most distinctive teaching beat. Equivalence properties are scaffolding, not forever-tests.
- Local, during the refactor PR. Engineer writes v2 and this property in the same commit, iterates until Hypothesis can’t find a divergence.
- CI on the PR (required check). Hypothesis runs more examples than the engineer ran locally; the persisted counterexample database under
.sqlproof/failures/travels with the PR. - CI on
mainduring the deprecation window. v1 and v2 both ship; callers migrate from v1 to v2 over ~one week; this property runs on every commit during the window, catching anyone who tweaks v2 in an unrelated PR and silently breaks equivalence. - Deleted with v1. Once callers are off v1 and v1 is dropped, the property goes too. Keeping v1 alive just to host the property creates perpetual dead code.
The exception: deprecation views, dual-writes, compatibility shims across a versioned API boundary. There the property really is forever.
The other recipes in this section guard permanent invariants. This one guards a transient one — and that’s the point.