Migrate from Supabase
Supabase is a full application platform — PostgreSQL database, authentication, file storage, realtime subscriptions, edge functions, and an auto-generated REST API. DB9 replaces only the database layer. Everything else needs a separate solution or stays with Supabase.
This guide covers exporting your schema and data, importing into DB9, and replacing Supabase-specific database features in your application.
What DB9 Replaces and What It Does Not
Section titled “What DB9 Replaces and What It Does Not”DB9 replaces
Section titled “DB9 replaces”- PostgreSQL database — SQL queries, tables, indexes, functions, triggers
- Connection pooling endpoint — use application-side pooling instead
- pg_cron — DB9 has a built-in pg_cron extension
- pgvector — DB9 has built-in vector search with HNSW indexes and native embeddings
DB9 does not replace
Section titled “DB9 does not replace”| Supabase feature | What to use instead |
|---|---|
| Auth (GoTrue) | Third-party auth (Auth0, Clerk, Firebase Auth) or custom JWT |
| Storage (S3-backed) | AWS S3, GCS, or DB9’s fs9 extension for file-as-SQL workflows |
| Realtime (CDC + LISTEN/NOTIFY) | LISTEN/NOTIFY with your own triggers, or a logical decoding slot that your service polls, fanned out by an application WebSocket layer or a message queue |
| Edge Functions (Deno) | Cloudflare Workers, Vercel Functions, AWS Lambda |
| PostgREST (auto-generated API) | Build API routes in your framework (Next.js, Express, FastAPI) |
| Row-Level Security (RLS) policies | Supabase’s policies do not carry over (see step 2). Rewrite them as DB9 row-level security policies, or enforce access control in your application layer |
| Dashboard and SQL editor | DB9 CLI (db9 db sql) and standard tools (psql, pgAdmin) |
If you only need to migrate the database and plan to rebuild or replace the other services, continue with this guide.
What Changes and What Stays the Same
Section titled “What Changes and What Stays the Same”Stays the same
Section titled “Stays the same”- SQL queries — DML, DDL, joins, CTEs, window functions, and subqueries work without changes.
- PostgreSQL drivers — node-postgres, psycopg, pgx, JDBC, and other pgwire drivers work with DB9.
- ORM compatibility — Prisma, Drizzle, SQLAlchemy, TypeORM, Sequelize, Knex, and GORM are tested.
- Common data types — TEXT, INTEGER, BIGINT, BOOLEAN, TIMESTAMPTZ, UUID, JSONB, arrays, vectors.
Changes
Section titled “Changes”| Area | Supabase | DB9 |
|---|---|---|
| Connection string | postgresql://postgres.[ref]:pass@pooler.supabase.com:6543/postgres | postgresql://<tenant_id>.<role>@pg.db9.io:5433/postgres |
| Port | 5432 (direct) or 6543 (pooled) | 5433 |
| Username | postgres or postgres.[ref] | <tenant_id>.<role> (e.g., a1b2c3d4e5f6.admin) |
| Connection pooling | Built-in Supavisor (transaction mode) | No built-in pooler — use application-side pooling |
| Row-Level Security | Supported and heavily used | Supported, but Supabase’s policies need rewriting (see step 2) |
| LISTEN/NOTIFY | Supported (powers Realtime) | Supported over pgwire (delivered on commit); the stateless HTTP SQL API can NOTIFY, but a LISTEN there is accepted and never receives anything |
| Extensions | 40+ (PostGIS, pg_graphql, pgsodium, etc.) | Fixed built-in set: http, vector, fs9, pg_cron, embedding, uuid-ossp, parquet, zhparser — plus hstore, pgcrypto and plpgsql as metadata-only shims (see step 2) |
| Table partitioning | Supported | PARTITION BY RANGE on a timestamp or timestamptz key only |
| Replication | Logical replication supported | Logical decoding from replication slots; no CREATE SUBSCRIPTION |
Review the Compatibility Matrix for the full list.
Prerequisites
Section titled “Prerequisites”- Access to your Supabase project (direct database connection string from Settings > Database)
pg_dumpinstalled locally (comes with PostgreSQL client tools)- DB9 CLI installed:
curl -fsSL https://db9.ai/install | sh - A DB9 account:
db9 create --name my-appto create your target database
-
Export from Supabase
Use the direct connection string from your Supabase dashboard (Settings > Database > Connection string > URI, not the pooled connection).
pg_dumpdoes not work over pooled connections.Schema and data
Terminal pg_dump --no-owner --no-privileges --no-comments \-N auth -N storage -N realtime -N extensions -N supabase_functions -N supabase_migrations \"postgresql://postgres.[your-ref]:[password]@db.[your-ref].supabase.co:5432/postgres?sslmode=require" \> export.sqlThe
-Nflags exclude Supabase’s internal schemas. You want your application’spublicschema (and any custom schemas), not Supabase’s platform tables.Schema only
Terminal pg_dump --schema-only --no-owner --no-privileges \-N auth -N storage -N realtime -N extensions -N supabase_functions -N supabase_migrations \"postgresql://postgres.[your-ref]:[password]@db.[your-ref].supabase.co:5432/postgres?sslmode=require" \> schema.sqlFlags explained:
--no-owner— omitsALTER ... OWNER TOstatements referencing Supabase-specific roles--no-privileges— omitsGRANT/REVOKEfor Supabase’s role hierarchy-N auth -N storage ...— excludes Supabase platform schemas
-
Clean the Export
Supabase exports commonly include features DB9 does not support. Review and remove:
Row-Level Security (most common)
Supabase projects heavily use RLS. DB9 supports row-level security, but Supabase’s policies call
auth.uid()/auth.jwt()and name itsanonandauthenticatedroles, so they do not carry over:- A policy
TO anonfails at import with42704 role "anon" does not exist. - A policy that calls
auth.uid()imports cleanly. It then fails with3F000 schema "auth" does not existwhenever a role it applies to queries the table. - DB9 does have an
authenticatedrole, the one signed-in Browser SDK requests and functions run as. A Supabase policyTO authenticatedtherefore imports cleanly and then breaks those queries. Every signed-in user shares that one role.
Remove all policy definitions:
Terminal # Find RLS statementsgrep -n "ROW LEVEL SECURITY\|CREATE POLICY\|ALTER POLICY" export.sqlRemove lines like:
SQL -- Remove theseALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can view own posts" ON public.posts FOR SELECT USING (auth.uid() = user_id);Then recreate them as DB9 policies, or enforce the rules in your application layer (see Step 5).
Browser SDK requests that carry a user JWT, and the functions you deploy, all run as the single
authenticatedrole, so acurrent_usercheck cannot tell your users apart. Compare against the JWT subject instead, which DB9 exposes as theauth.uidsetting — this is the direct replacement for Supabase’sauth.uid():SQL -- Supabase: USING (auth.uid() = user_id)CREATE POLICY "Users can view own posts" ON public.postsFOR SELECTUSING (current_setting('auth.uid', true) = user_id::text);The subjects match only if you point BYO JWT at the same identity provider; otherwise map your provider’s IDs onto the
user_idcolumn.Keep the
::textcast: Supabase’suser_idcolumns are usuallyuuid,current_settingreturnstext, and without the cast every query the policy applies to fails withoperator does not exist: text = uuid(42883). See RLS with the Browser SDK.Supabase-specific extensions
Terminal grep "CREATE EXTENSION" export.sqlKeep extensions that DB9 supports:
uuid-ossp(uuid_generate_v4()only) andvector(mapped to DB9’s built-in vector).CREATE EXTENSION hstoreis accepted too, buthstorevalues are not supported, so a table with anhstorecolumn fails atCREATE TABLEwith0A000 type "hstore" is not supported— convert those columns toJSONB(see hstore) and drop theCREATE EXTENSION hstoreline: while it is registered,pg_dumpcannot dump your DB9 database. Remove others — common Supabase extensions not in DB9 includepg_graphql,pgsodium,pg_net,pg_stat_statements,PostGIS,pg_trgm.Supabase installs most extensions in its
extensionsschema, and-N extensionsdoes not keep them out of the dump:pg_dumpstill writesCREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;and names their objects with that schema, as inDEFAULT extensions.uuid_generate_v4(),extensions.vector(1536),extensions.vector_cosine_opsandOPERATOR(extensions.<=>). DB9 installsuuid-osspandvectoronly inpublic(0A000 extension "uuid-ossp" must be installed in schema "public"), and rejects their functions, type, operator classes and operators under theextensions.prefix —extensions.uuid_generate_v4()fails with42883andextensions.vector(1536)with42704— so those tables are not created, and the foreign keys that reference them fail after them. Rewrite these names before importing. The third expression handles avectorextension installed inpublic, which the dump writes aspublic.vector(1536), also rejected with42704; the last one rewrites the vector operators of either schema:Terminal sed -E -i.bak \-e 's/^(CREATE EXTENSION IF NOT EXISTS ("uuid-ossp"|vector)) WITH SCHEMA extensions;$/\1 WITH SCHEMA public;/' \-e 's/(^|[^[:alnum:]_])extensions\.(uuid_generate_v4|vector)/\1\2/g' \-e 's/(^|[^[:alnum:]_])public\.vector([^[:alnum:]_]|_(l2|ip|cosine)_ops|$)/\1vector\2/g' \-e 's/OPERATOR\((extensions|public)\.(<->|<#>|<=>)\)/\2/g' \export.sqlLeave
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;as it is: DB9 acceptspgcryptoonly inextensions, and rejectsWITH SCHEMA publicwith0A000.CREATE EXTENSION pgcryptocan stay: DB9 accepts it as a metadata shim so Supabase bootstrap scripts restore unchanged. It does not, however, bring pgcrypto’s functions with it.gen_random_uuid()anddigest()are DB9 built-ins and keep working, butcrypt(),gen_salt(),hmac(),encrypt()/decrypt(),pgp_sym_encrypt()andgen_random_bytes()all fail with42883 function ... does not exist. If your schema hashes passwords withcrypt()/gen_salt(), move that into your application layer before migrating.Auth schema references
If your SQL references
auth.uid()orauth.jwt()(common in RLS policies and triggers), remove those references:Terminal grep -n "auth\.uid\|auth\.jwt\|auth\.role" export.sqlTriggers that reference Supabase internals
Terminal grep -n "supabase_functions\|extensions\.\|realtime\." export.sqlRemove triggers that call into Supabase’s internal schemas.
Other unsupported features
Terminal # Table partitioninggrep -i "PARTITION" export.sql# Logical replicationgrep -i "PUBLICATION\|SUBSCRIPTION" export.sql# Advisory locksgrep -i "advisory_lock" export.sqlA partitioned table does not load as
pg_dumpwrites it, even where DB9 supports its partitioning — see the partitioning note in Migrate from PostgreSQL. - A policy
-
Create the DB9 Database
Terminal db9 create --name my-app --show-connection-stringReturns immediately with the connection string and credentials.
-
Import into DB9
Import over pgwire with
psqlin two passes:schema.sqlfrom step 1 while the tables are still empty, then the data streamed straight from Supabase. Run the step 2 checks againstschema.sql, remove what they flag and apply the step 2sedrewrite before importing it.Terminal # Import the schema, with the statements flagged in step 2 removedpsql "$(db9 db connect my-app --output quiet)" -f schema.sql# Then stream the data from Supabase into DB9pg_dump --data-only --no-owner \-N auth -N storage -N realtime -N extensions -N supabase_functions -N supabase_migrations \"postgresql://postgres.[your-ref]:[password]@db.[your-ref].supabase.co:5432/postgres?sslmode=require" \| psql "$(db9 db connect my-app --output quiet)"DB9 supports
COPYin CSV and TEXT formats over pgwire.psqlkeeps going after an error, so scan its output forERROR:lines. -
Update Your Application
Connection string
Replace the Supabase connection string:
Diff DATABASE_URL=postgresql://postgres.[ref]:[password]@db.[ref].supabase.co:5432/postgresDATABASE_URL=postgresql://a1b2c3d4e5f6.admin@pg.db9.io:5433/postgres?sslmode=requireReplace the Supabase client
If you use
@supabase/supabase-jsfor database queries, switch to a standard PostgreSQL driver:Diff import { createClient } from '@supabase/supabase-js';const supabase = createClient(url, anonKey);const { data } = await supabase.from('posts').select('*').eq('published', true);import pg from 'pg';const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });const { rows: data } = await pool.query('SELECT * FROM posts WHERE published = true');Or use an ORM: Prisma, Drizzle, SQLAlchemy.
Replace Row-Level Security
Supabase’s RLS policies do not run on DB9 as written (see step 2). Either enforce access control in your API or middleware, as below, or rewrite them as DB9 policies (see Row-Level Security):
TypeScript // Before (Supabase RLS):// CREATE POLICY "own posts" ON posts FOR SELECT USING (auth.uid() = user_id);// const { data } = await supabase.from('posts').select('*');// After (application-level):const userId = req.auth.userId; // From your auth middlewareconst { rows } = await pool.query('SELECT * FROM posts WHERE user_id = $1',[userId]);Every query that previously relied on RLS needs an explicit
WHEREclause or middleware check.Replace Realtime
Supabase Realtime combines LISTEN/NOTIFY with change-data-capture over logical replication. DB9 has no Realtime service, so table changes are not broadcast to clients automatically. You can emit the events yourself from a trigger:
SQL CREATE FUNCTION notify_order_change() RETURNS TRIGGER AS $$BEGINPERFORM pg_notify('order_events', NEW.id::text);RETURN NEW;END;$$ LANGUAGE plpgsql;CREATE TRIGGER order_change AFTER INSERT OR UPDATE ON ordersFOR EACH ROW EXECUTE FUNCTION notify_order_change();Subscribers must hold an open pgwire connection to receive; a
LISTENissued over the stateless HTTP SQL API is accepted and never receives anything.Where logical replication is provisioned, you can capture changes without triggers by reading a logical decoding slot. DB9 decodes published-table changes through the
wal2jsonplugin, and your service polls the slot. Slot creation can time out or fail withXX000/55000; set a statement timeout and confirm the slot exists before relying on this path. See the CDC limitations.SQL CREATE PUBLICATION order_pub FOR TABLE orders;SELECT * FROM pg_create_logical_replication_slot('order_slot', 'wal2json');SELECT data FROM pg_logical_slot_get_changes('order_slot', NULL, NULL, 'format-version', '2');The last statement returns the pending changes and consumes them, one JSON row per change, such as
{"action":"I","columns":[{"name":"id","type":"integer","value":1}, ...],"schema":"public","table":"orders"};pg_logical_slot_peek_changestakes the same arguments and leaves the changes in place. Changes show up seconds to tens of seconds after commit, and only for tables that were in the publication when the row was written — apart from the rows aCREATE TABLE ... ASorSELECT ... INTOloads, which arrive asIchanges for the new table,_rowidcolumn included, although that table is in no publication.format-version2is required. Write the slot functions in theFROMclause as shown:SELECT pg_create_logical_replication_slot(...)fails with42883. Over the HTTP SQL API, do not start the statement with a--comment: the API then returns no rows, andpg_logical_slot_get_changeshas still consumed the changes.Either way, browser clients need an application-side fan-out:
- Application WebSockets — a backend service holds the
LISTENconnection and pushes to clients - External message queue — publish change events to Redis Pub/Sub, AWS SQS, or similar
- Polling — query the database on an interval for changes
Replace Storage
For file operations, consider:
- AWS S3 / GCS — direct replacement for Supabase Storage
- DB9 fs9 extension — store and query files directly in SQL (see fs9 guide)
Replace Edge Functions
Supabase Edge Functions (Deno) have no DB9 equivalent. Use your preferred serverless platform (Cloudflare Workers, Vercel Functions, AWS Lambda) and connect to DB9 with a standard PostgreSQL driver.
- Application WebSockets — a backend service holds the
-
Validate
Check schema
Terminal db9 db dump my-app --ddl-onlyCompare with your original schema to confirm tables, indexes, and constraints were created.
Check row counts
Terminal db9 db sql my-app -q "SELECT count(*) FROM posts"db9 db sql my-app -q "SELECT count(*) FROM users"Compare against Supabase.
Run your test suite
Terminal DATABASE_URL="$(db9 db connect my-app --output quiet)" npm testCommon test failures after migration
auth.uid()calls — DB9 does not know your Supabase users, so these do not return their IDs, and inside a policy they fail with3F000. Replace withcurrent_setting('auth.uid', true)(the BYO JWT subject) or with application-provided user IDs.- RLS-dependent queries — queries that relied on RLS returning filtered results now return all rows unless you add explicit
WHEREclauses. gen_random_uuid()without extension — DB9 supports this as a built-in function.CREATE EXTENSION pgcryptoin your schema is accepted as a metadata shim and can stay, but it registers no functions: calls tocrypt(),gen_salt(),hmac(),encrypt()orpgp_sym_encrypt()fail at runtime with42883, not at restore time.- SERIALIZABLE isolation — DB9 does not implement SERIALIZABLE. Requesting it on the wire protocol raises a
WARNINGand silently downgrades the transaction to REPEATABLE READ, so it will not fail loudly. Audit transactions that relied on serializability and add explicitSELECT ... FOR UPDATElocks or unique constraints. At the downgraded level, the transaction that loses aSELECT ... FOR UPDATErace fails atCOMMITwith40001rather than at the statement, so make sure your retry logic coversCOMMITas well; a unique-constraint conflict fails theINSERTwith23505, as in PostgreSQL. See Transactions.
Rollback Plan
Section titled “Rollback Plan”Keep your Supabase project running during migration. If you need to revert:
- Switch
DATABASE_URLback to the Supabase connection string. - If you need to export data created in DB9:
db9 db dump my-app -o db9-export.sqlpsql "postgresql://postgres.[ref]:[password]@db.[ref].supabase.co:5432/postgres?sslmode=require" \ -f db9-export.sqlThe db9 db dump command outputs plain SQL. Its limit of 50,000 rows or 16 MB applies to the whole database, not to each table: a larger database is refused with Error 413 and no file is written. For larger databases, use pg_dump 17 or newer instead — see the PostgreSQL guide’s rollback plan for the command, the cases it cannot dump, and the extra step for vector columns — or use psql to stream individual tables with COPY.
Caveats
Section titled “Caveats”- Database only — DB9 does not replace Supabase Auth, Storage, Realtime, Edge Functions, or the PostgREST API. Plan replacements for each service you use.
- No zero-downtime migration — DB9 cannot subscribe to logical replication (
CREATE SUBSCRIPTIONis not supported), so it cannot follow your Supabase database during the cutover. Plan a maintenance window for the cutover. - Supabase RLS policies must be rewritten — they call
auth.uid()and name Supabase’s roles. Move the rules into application code or recreate them as DB9 policies. This is typically the largest refactoring effort. Audit every table that had RLS enabled. - Extension gaps — PostGIS, pg_graphql, pgsodium, pg_net, and other Supabase extensions are not available. Check your
CREATE EXTENSIONstatements. - Import with
psql, notdb9 db sql -f—db9 db sql -fcannot run apg_dumpfile, with or without--direct, and the HTTP SQL API it uses by default also rejects requests over about 2 MB and gives up after about 15 seconds. Import over pgwire, schema first. - No built-in REST API — Supabase auto-generates a REST API via PostgREST. With DB9, build your own API layer or use an ORM.
Next Pages
Section titled “Next Pages”- Compatibility Matrix — full list of supported and unsupported PostgreSQL features
- Connect — connection string format and authentication
- Migrate from Neon — Neon-specific migration guide
- Migrate from PostgreSQL — general PostgreSQL migration path
- fs9 Extension — file operations in SQL (alternative to Supabase Storage)
- Production Checklist — deployment readiness