Generating Realistic Data That Respects Your Schema
Most of these docs talk about property-based testing — the part of SqlProof where you write an invariant and the engine throws random data at it. This page is about the data engine itself, which is useful on its own:
- Seeding a local dev database with realistic relational data.
- Generating fixtures that respect FK / CHECK / UNIQUE / NOT NULL constraints without rejection sampling or hand-curated fixture files.
- Replaying schema-respecting datasets through migrations to verify they don’t break under data shapes you haven’t seen yet.
- Wiring an external parent table (e.g. Supabase
auth.users) into generation so child rows draw FK values from real, existing parents.
Everything below uses the public dataset_strategy API that the
property runners are built on. You can use it directly, no pytest
required.
The basic loop
Section titled “The basic loop”The dataset’s structure:
- FK-respecting. Every
orders.customer_idpoints to a realcustomers.idfrom the same dataset. Everyline_items.order_idpoints to a realorders.id. Tables are inserted in topological order so the parents exist when children reference them. - Type-respecting.
NUMERIC(10,2)columns get scale-2 decimals.varchar(50)gets strings under 50 characters.uuidgets parseable UUID strings. - NULL-respecting.
NOT NULLcolumns are never null; nullable columns can be null. - CHECK-respecting. See below.
To actually load the dataset into a database:
CHECK constraints are honored automatically
Section titled “CHECK constraints are honored automatically”This is the part where SqlProof is doing real work for you. Given:
SqlProof’s CHECK refiner reads each constraint and adjusts the generator so values land in-domain before INSERT, instead of generating something invalid and asking Postgres to reject it. The following constraint shapes are recognized:
| Shape | Example | What the refiner does |
|---|---|---|
| Range | price >= 0, quantity > 0, score < 100 | Narrows the numeric strategy’s bounds |
| IN-set | status IN ('draft', 'live', 'retired') | Replaces with sampled_from(...) |
| ANY(ARRAY) | priority = ANY(ARRAY[1, 2, 3, 5, 8]) | Same as IN-set |
| Length | length(sku) <= 12, char_length(name) >= 3 | Narrows string-length bounds |
Constraints the refiner doesn’t yet recognize (regex match, compound
boolean expressions, function calls) fall back to a permissive
strategy with a .filter(...) predicate when the expression has a
parseable arithmetic comparator, or to the unrefined strategy
otherwise. Generated rows are still valid against type and FK
constraints, but Postgres may reject them on the unrecognized
CHECK — which itself is a useful signal: it tells you which
constraints are exotic enough that you might want to express them
differently or override the column directly.
Column overrides: fixed, strategy, or derived
Section titled “Column overrides: fixed, strategy, or derived”Three flavors via the columns= parameter to dataset_strategy:
Fixed value
Section titled “Fixed value”Hypothesis strategy
Section titled “Hypothesis strategy”Derived (callable)
Section titled “Derived (callable)”The override receives a ColumnContext with the row being built so
far, and returns the derived value:
This is invaluable when CHECK constraints involve cross-column
relationships (CHECK (total = quantity * unit_price)). The CHECK
refiner can’t infer such relationships from the expression text, but
a one-line derived override solves it.
Shrinkable cardinalities
Section titled “Shrinkable cardinalities”sizes={"orders": 50} always generates exactly 50. For property
tests, shrinkable sizes find smaller failing examples:
When a property fails on customers=15, orders=87, Hypothesis can
shrink to customers=2, orders=3 if that still reproduces the
bug — usually it can. Smaller counterexamples are dramatically
easier to debug.
External parent tables (Supabase, multi-tenant systems)
Section titled “External parent tables (Supabase, multi-tenant systems)”Sometimes a FK target is owned by an external system you can’t
generate into — Supabase’s auth.users, a tenant directory, a CRM
mirror. SqlProof’s ExternalTableSpec lets the generator draw FK
values from a live external parent without trying to insert into it:
For Supabase specifically, the sqlproof.contrib.supabase module
ships two helpers that make this turnkey:
testing Supabase apps guide.
Composite UNIQUE constraints
Section titled “Composite UNIQUE constraints”UNIQUE (project_id, user_id) on a project_members table is honored
during generation — duplicates are rejected and replaced before INSERT.
You don’t have to do anything; if the schema has it, the generator
respects it.
Generated project_members rows will never contain two rows with the
same (project_id, user_id) pair, even though they’re separately
sampled per row.
Use case: seeding a local dev database
Section titled “Use case: seeding a local dev database”Same machinery, no pytest involved. Useful when starting a fresh
environment or after a db reset:
Use case: migration safety against generated data
Section titled “Use case: migration safety against generated data”Run a candidate migration against many generated datasets to verify it doesn’t lose information or change query results:
The generator produces a different valid dataset on every iteration;
the index is created and the view is re-queried each time. If a
PG-specific quirk causes the index to change query plan in a way that
also changes results (a real risk for queries with LIMIT or window
functions and no explicit ORDER BY), the property fails on a minimal
counterexample.
What the generator doesn’t (yet) do
Section titled “What the generator doesn’t (yet) do”Honest limitations, all tracked openly:
- Range types, composite types, custom domains. #4 on the issue tracker. Currently fall back to a text strategy.
- Exclusion constraints, partial unique indexes, generated columns. #3. Generated columns in particular need a fix soon — the generator currently tries to populate them and Postgres rejects the row.
- CHECK constraints involving regex or complex boolean expressions.
Falls through to the unrefined strategy (with a
.filter(...)if a numeric comparator is buried in there). Override the column manually if this matters. - Cross-row relational invariants that aren’t expressed as schema constraints — e.g. “every order must have at least one line item.” Use a derived size strategy or a column override that ensures the relationship.
For everything else: if it’s expressed as a Postgres-recognized schema constraint, the generator should honor it. If it doesn’t, that’s a bug — please open an issue.