Skip to content
Discord Get Started

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.

  • 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
Supabase featureWhat 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) policiesSupabase’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 editorDB9 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.

  • 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.
AreaSupabaseDB9
Connection stringpostgresql://postgres.[ref]:pass@pooler.supabase.com:6543/postgrespostgresql://<tenant_id>.<role>@pg.db9.io:5433/postgres
Port5432 (direct) or 6543 (pooled)5433
Usernamepostgres or postgres.[ref]<tenant_id>.<role> (e.g., a1b2c3d4e5f6.admin)
Connection poolingBuilt-in Supavisor (transaction mode)No built-in pooler — use application-side pooling
Row-Level SecuritySupported and heavily usedSupported, but Supabase’s policies need rewriting (see step 2)
LISTEN/NOTIFYSupported (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
Extensions40+ (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 partitioningSupportedPARTITION BY RANGE on a timestamp or timestamptz key only
ReplicationLogical replication supportedLogical decoding from replication slots; no CREATE SUBSCRIPTION

Review the Compatibility Matrix for the full list.

  • Access to your Supabase project (direct database connection string from Settings > Database)
  • pg_dump installed locally (comes with PostgreSQL client tools)
  • DB9 CLI installed: curl -fsSL https://db9.ai/install | sh
  • A DB9 account: db9 create --name my-app to create your target database
  1. Export from Supabase

    Use the direct connection string from your Supabase dashboard (Settings > Database > Connection string > URI, not the pooled connection). pg_dump does 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.sql

    The -N flags exclude Supabase’s internal schemas. You want your application’s public schema (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.sql

    Flags explained:

    • --no-owner — omits ALTER ... OWNER TO statements referencing Supabase-specific roles
    • --no-privileges — omits GRANT/REVOKE for Supabase’s role hierarchy
    • -N auth -N storage ... — excludes Supabase platform schemas
  2. 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 its anon and authenticated roles, so they do not carry over:

    • A policy TO anon fails at import with 42704 role "anon" does not exist.
    • A policy that calls auth.uid() imports cleanly. It then fails with 3F000 schema "auth" does not exist whenever a role it applies to queries the table.
    • DB9 does have an authenticated role, the one signed-in Browser SDK requests and functions run as. A Supabase policy TO authenticated therefore imports cleanly and then breaks those queries. Every signed-in user shares that one role.

    Remove all policy definitions:

    Terminal
    # Find RLS statements
    grep -n "ROW LEVEL SECURITY\|CREATE POLICY\|ALTER POLICY" export.sql

    Remove lines like:

    SQL
    -- Remove these
    ALTER 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 authenticated role, so a current_user check cannot tell your users apart. Compare against the JWT subject instead, which DB9 exposes as the auth.uid setting — this is the direct replacement for Supabase’s auth.uid():

    SQL
    -- Supabase: USING (auth.uid() = user_id)
    CREATE POLICY "Users can view own posts" ON public.posts
    FOR SELECT
    USING (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_id column.

    Keep the ::text cast: Supabase’s user_id columns are usually uuid, current_setting returns text, and without the cast every query the policy applies to fails with operator does not exist: text = uuid (42883). See RLS with the Browser SDK.

    Supabase-specific extensions

    Terminal
    grep "CREATE EXTENSION" export.sql

    Keep extensions that DB9 supports: uuid-ossp (uuid_generate_v4() only) and vector (mapped to DB9’s built-in vector). CREATE EXTENSION hstore is accepted too, but hstore values are not supported, so a table with an hstore column fails at CREATE TABLE with 0A000 type "hstore" is not supported — convert those columns to JSONB (see hstore) and drop the CREATE EXTENSION hstore line: while it is registered, pg_dump cannot dump your DB9 database. Remove others — common Supabase extensions not in DB9 include pg_graphql, pgsodium, pg_net, pg_stat_statements, PostGIS, pg_trgm.

    Supabase installs most extensions in its extensions schema, and -N extensions does not keep them out of the dump: pg_dump still writes CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; and names their objects with that schema, as in DEFAULT extensions.uuid_generate_v4(), extensions.vector(1536), extensions.vector_cosine_ops and OPERATOR(extensions.<=>). DB9 installs uuid-ossp and vector only in public (0A000 extension "uuid-ossp" must be installed in schema "public"), and rejects their functions, type, operator classes and operators under the extensions. prefix — extensions.uuid_generate_v4() fails with 42883 and extensions.vector(1536) with 42704 — 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 a vector extension installed in public, which the dump writes as public.vector(1536), also rejected with 42704; 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.sql

    Leave CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions; as it is: DB9 accepts pgcrypto only in extensions, and rejects WITH SCHEMA public with 0A000.

    CREATE EXTENSION pgcrypto can 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() and digest() are DB9 built-ins and keep working, but crypt(), gen_salt(), hmac(), encrypt()/decrypt(), pgp_sym_encrypt() and gen_random_bytes() all fail with 42883 function ... does not exist. If your schema hashes passwords with crypt()/gen_salt(), move that into your application layer before migrating.

    Auth schema references

    If your SQL references auth.uid() or auth.jwt() (common in RLS policies and triggers), remove those references:

    Terminal
    grep -n "auth\.uid\|auth\.jwt\|auth\.role" export.sql

    Triggers that reference Supabase internals

    Terminal
    grep -n "supabase_functions\|extensions\.\|realtime\." export.sql

    Remove triggers that call into Supabase’s internal schemas.

    Other unsupported features

    Terminal
    # Table partitioning
    grep -i "PARTITION" export.sql
    # Logical replication
    grep -i "PUBLICATION\|SUBSCRIPTION" export.sql
    # Advisory locks
    grep -i "advisory_lock" export.sql

    A partitioned table does not load as pg_dump writes it, even where DB9 supports its partitioning — see the partitioning note in Migrate from PostgreSQL.

  3. Create the DB9 Database

    Terminal
    db9 create --name my-app --show-connection-string

    Returns immediately with the connection string and credentials.

  4. Import into DB9

    Import over pgwire with psql in two passes: schema.sql from step 1 while the tables are still empty, then the data streamed straight from Supabase. Run the step 2 checks against schema.sql, remove what they flag and apply the step 2 sed rewrite before importing it.

    Terminal
    # Import the schema, with the statements flagged in step 2 removed
    psql "$(db9 db connect my-app --output quiet)" -f schema.sql
    # Then stream the data from Supabase into DB9
    pg_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 COPY in CSV and TEXT formats over pgwire. psql keeps going after an error, so scan its output for ERROR: lines.

  5. Update Your Application

    Connection string

    Replace the Supabase connection string:

    Diff
    DATABASE_URL=postgresql://postgres.[ref]:[password]@db.[ref].supabase.co:5432/postgres
    DATABASE_URL=postgresql://a1b2c3d4e5f6.admin@pg.db9.io:5433/postgres?sslmode=require

    Replace the Supabase client

    If you use @supabase/supabase-js for 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 middleware
    const { rows } = await pool.query(
    'SELECT * FROM posts WHERE user_id = $1',
    [userId]
    );

    Every query that previously relied on RLS needs an explicit WHERE clause 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 $$
    BEGIN
    PERFORM pg_notify('order_events', NEW.id::text);
    RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    CREATE TRIGGER order_change AFTER INSERT OR UPDATE ON orders
    FOR EACH ROW EXECUTE FUNCTION notify_order_change();

    Subscribers must hold an open pgwire connection to receive; a LISTEN issued 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 wal2json plugin, and your service polls the slot. Slot creation can time out or fail with XX000 / 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_changes takes 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 a CREATE TABLE ... AS or SELECT ... INTO loads, which arrive as I changes for the new table, _rowid column included, although that table is in no publication. format-version 2 is required. Write the slot functions in the FROM clause as shown: SELECT pg_create_logical_replication_slot(...) fails with 42883. Over the HTTP SQL API, do not start the statement with a -- comment: the API then returns no rows, and pg_logical_slot_get_changes has still consumed the changes.

    Either way, browser clients need an application-side fan-out:

    • Application WebSockets — a backend service holds the LISTEN connection 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.

  6. Validate

    Check schema

    Terminal
    db9 db dump my-app --ddl-only

    Compare 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 test

    Common 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 with 3F000. Replace with current_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 WHERE clauses.
    • gen_random_uuid() without extension — DB9 supports this as a built-in function. CREATE EXTENSION pgcrypto in your schema is accepted as a metadata shim and can stay, but it registers no functions: calls to crypt(), gen_salt(), hmac(), encrypt() or pgp_sym_encrypt() fail at runtime with 42883, not at restore time.
    • SERIALIZABLE isolation — DB9 does not implement SERIALIZABLE. Requesting it on the wire protocol raises a WARNING and silently downgrades the transaction to REPEATABLE READ, so it will not fail loudly. Audit transactions that relied on serializability and add explicit SELECT ... FOR UPDATE locks or unique constraints. At the downgraded level, the transaction that loses a SELECT ... FOR UPDATE race fails at COMMIT with 40001 rather than at the statement, so make sure your retry logic covers COMMIT as well; a unique-constraint conflict fails the INSERT with 23505, as in PostgreSQL. See Transactions.

Keep your Supabase project running during migration. If you need to revert:

  1. Switch DATABASE_URL back to the Supabase connection string.
  2. If you need to export data created in DB9:
Terminal
db9 db dump my-app -o db9-export.sql
psql "postgresql://postgres.[ref]:[password]@db.[ref].supabase.co:5432/postgres?sslmode=require" \
-f db9-export.sql

The 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.

  • 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 SUBSCRIPTION is 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 EXTENSION statements.
  • Import with psql, not db9 db sql -f — db9 db sql -f cannot run a pg_dump file, 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.