Skip to main content
supaschema was built while developing a production multi-tenant SaaS on Supabase. The schema had:
  • roughly 30 schemas;
  • about 8,300 schema objects;
  • hundreds of Row Level Security policies.
Every schema edit meant waiting for a shadow-database diff before migrations and generated types could catch up. All numbers below are reproducible. Object names are kept generic; the measurements are from the real tree.

Speed: the whole tree, no database, in under two seconds

Extracting and planning the entire declarative tree (8,271 modeled objects) runs on the parser alone — no Docker, no shadow database, no introspection:
extract-from 1012ms · extract-to 876ms · plan 5ms
A full diff is two extractions plus a plan — ~1.9 seconds over the entire production tree. The equivalent Supabase CLI diff replays all 8,300 objects into a fresh Docker shadow database on every run, which is minutes at this scale (see the benchmarks). The 8,271 figure is what supaschema models; the tree also produced 91 expected fail-closed diagnostics, concentrated in the bootstrap layer (managed-schema declarations for extensions/vault/roles, which a real adoption excludes via schemas.exclude) plus normalize-fidelity warnings. None are engine errors.

Head-to-head on real schema (bounded slice)

A 282-object slice of the real tree (three schemas: identity, calculators, messaging — 73 RLS policies among them) was diffed against itself plus a small additive change, supaschema versus the default Supabase CLI engine, one iteration, both applied to a throwaway Postgres and re-applied:
supaschemaSupabase CLI (default)
Diff latency361 ms35,293 ms (~98× slower)
Migration applies once → target catalogyesyes
Migration applies twice (crash-safe re-run)yesno
Reaches the target catalog after re-runyesno
The Supabase engine emitted an unguarded CREATE TABLE public.…(…) (no IF NOT EXISTS) and an unguarded CREATE INDEX, so the second apply fails with relation "…" already exists. supaschema’s output is guarded by construction (IF NOT EXISTS, catalog-checked DO blocks), so a crashed or retried deploy simply runs the file again. Reproduce against any declarative tree:
node benchmarks/tools/build-project-fixture.mjs --tree <your supabase/schemas> --out benchmarks/fixtures/project
SUPASCHEMA_COMPARE_FIXTURES=project \
SUPASCHEMA_COMPARE_TOOLS=supaschema-file,supabase-default \
SUPASCHEMA_COMPARE_ITERATIONS=1 \
SUPASCHEMA_COMPARE_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres \
node benchmarks/compare.js

Security: the miss that speed hides

Speed is the visible pain. RLS correctness is the bigger risk. On a multi-tenant platform, a policy’s USING predicate is the tenant boundary. Changing USING (true) to USING (tenant_id = current_tenant()) can close an isolation hole. Every Supabase CLI diff engine measured here silently drops that policy-body change because it diffs policies by name, not by body. See the accuracy results. supaschema scores F1 1.000; the compared engines score 0.982-0.999 on the same missed-policy fixture. A slow diff costs time. An unreplayable diff costs a deploy. A silently dropped policy change can ship a tenant-isolation bug.

Why both speed and security

The two are the same wedge from different ends. The parser-based, no-database design is what makes supaschema fast; that same AST-based identity is what lets it compare policy bodies structurally instead of by name, which is what catches the isolation regression. You do not trade one for the other.
Last modified on June 16, 2026