Five Property Patterns
If you’ve written database tests before, you’ve probably noticed that most SQL bugs follow a few recurring shapes: an aggregate that drifts on weird inputs, an RLS policy that allows the wrong row, a migration that produces a different answer than the query it replaced. Each of those shapes has a property-based formulation that’s much shorter than the example-based equivalent and catches more bugs.
Here are five patterns, ordered roughly by how often they pay off.
1. Aggregation invariants
Section titled “1. Aggregation invariants”The shape: “The aggregate produced by the database equals what Python computes from the same input rows.”
This is property-based testing’s most immediate win, because almost every reporting query is an aggregation, and aggregations have edge cases (NULLs, empty groups, floating-point precision, decimal scale, window-frame boundaries) that hand-typed fixtures don’t cover.
If customer_revenue rounds differently than Python (e.g. silently
truncating instead of rounding half-to-even), this test will fail on
the smallest input set Hypothesis can shrink to — typically two rows
with adjacent values that straddle the rounding boundary.
2. RLS policy regressions
Section titled “2. RLS policy regressions”The shape: “User A cannot see any row that doesn’t belong to user A — for any combination of memberships, roles, and shared resources.”
RLS is notorious for bugs that pass simple unit tests but fail under specific role/sharing combinations. A property test makes that combinatorial space tractable.
If a future migration introduces a WITH CHECK clause that’s slightly
wrong, a policy USING (...) that misses the share-link case, or a
SECURITY DEFINER function that bypasses RLS unintentionally — this
test will surface a minimal counterexample showing the exact rule
sequence that exposed the leak.
3. Migration safety: old-vs-new behavioral parity
Section titled “3. Migration safety: old-vs-new behavioral parity”The shape: “The new query / function / view returns the same answer as the old one, for every valid input dataset.”
This is the single most useful pattern when refactoring SQL —
extracting a CTE, switching from LEFT JOIN ... IS NULL to NOT EXISTS, replacing a window function with a self-join. The property
holds before and after the refactor; the test you write while
designing the refactor lives forever as a regression net.
This test fails the moment you discover that ORDER BY rev DESC LIMIT 5 isn’t equivalent when ties exist — the window-function variant is
deterministic in tie-breaking, the LIMIT variant isn’t. Hypothesis
finds the dataset with three customers tied for fifth place faster
than you’d hand-craft it.
4. Idempotency / commutativity
Section titled “4. Idempotency / commutativity”The shape: “Applying this UPSERT twice produces the same state as applying it once. The order of two non-conflicting INSERTs doesn’t change the final state.”
Property tests are unusually good at idempotency because the assertion is structurally identical to the property: run the operation N times and compare states.
A surprising number of “idempotent” UPSERTs aren’t, because the
updated_at trigger fires on every UPDATE even when nothing changed.
This test catches it.
5. Round-trip serialization
Section titled “5. Round-trip serialization”The shape: “Serializing a row to JSONB and reading it back yields the same row.”
Useful when you have JSONB columns, custom types, or a serialization function — bugs hide in subtle precision differences (floats, decimals with trailing zeros, timezone-aware vs -naive datetimes).
Use this whenever you write a new JSONB-using migration. The first property failure usually points to a number that lost precision or a timestamp that lost timezone information.
When example-based tests are still better
Section titled “When example-based tests are still better”Some assertions don’t gain much from generation:
- Schema shape: “this column exists, with this type.” pgTAP and
one-off
information_schemachecks are the right tool. (Though SqlProof’s introspection results expose this too — you can iterateproof.schema_info.tables.) - Wire format snapshots: “the JSON output of this RPC matches this
exact frozen string.” Use
syrupyor pytest snapshots. The shape is fixed; you don’t need 100 random datasets. - Specific known bug regressions: “issue #437 was about user 42 with empty cart on sale day; assert it doesn’t recur.” Hypothesis might find that exact case, but a targeted example is faster and more readable.
A healthy test suite has both. Property tests catch the bugs you didn’t anticipate; example tests document the bugs you’ve already seen and don’t want back.
Worked examples in the Inbox sample
Section titled “Worked examples in the Inbox sample”Every pattern above has a fully-worked, runnable recipe in the Inbox sample — a multi-tenant Supabase support inbox where each recipe pairs a buggy production-shape RPC/policy/trigger with the SqlProof property that catches it:
- Aggregation invariant → Outer joins and WHERE
- RLS policy regression → Correlated RLS subqueries, Internal-message RLS, Mass assignment, Missing DELETE policy, Tenant-scoped vector search
- Migration safety / equivalence → Equivalent query optimization
- Idempotency → Idempotent status triggers
- Round-trip serialization → Stable vector pagination (paginated-set equality is the same shape)
Plus one stateful (sequence-dependent) pattern not represented above: Stateful ticket lifecycle.