Migrate from PostgreSQL
This guide covers migrating from any PostgreSQL installation — self-hosted, AWS RDS, Google Cloud SQL, Azure Database for PostgreSQL, or other managed services — to DB9. The process uses standard pg_dump for export and psql for import.
For platform-specific guides, see Migrate from Neon or Migrate from Supabase.
Before You Start: Compatibility Check
Section titled “Before You Start: Compatibility Check”DB9 supports most PostgreSQL workloads, but some features are not available. Run these checks against your existing database before migrating.
Quick compatibility scan
Section titled “Quick compatibility scan”Connect to your source database and check for unsupported features:
-- Table partitioning (DB9: PARTITION BY RANGE on a timestamp/timestamptz key only)SELECT count(*) AS partitioned_tablesFROM pg_partitioned_table;
-- Table inheritance (not supported)SELECT count(*) AS inherited_tablesFROM pg_inherits;
-- Row-level security policies (supported; create these roles in DB9 first, see step 2)SELECT DISTINCT r AS policy_roleFROM pg_policies, unnest(roles) AS rWHERE r <> 'public';
-- Foreign data wrappers (not supported)SELECT count(*) AS fdw_serversFROM pg_foreign_server;
-- Logical replication (DB9 offers logical decoding from replication slots, but cannot subscribe)SELECT count(*) AS publicationsFROM pg_publication;
-- Advisory locks in use (supported, but semantics differ)SELECT count(*) AS advisory_locksFROM pg_locks WHERE locktype = 'advisory';If any of these return non-zero counts, review whether your application depends on them. Most checks above target unsupported features that need refactoring before migration; advisory lock usage needs a separate semantic review because DB9 advisory locks are node-local.
Extension check
Section titled “Extension check”SELECT extname FROM pg_extension WHERE extname NOT IN ( 'http', 'uuid-ossp', 'hstore', 'fs9', 'pg_cron', 'parquet', 'zhparser', 'vector', 'embedding', 'pgcrypto', 'plpgsql') ORDER BY extname;Anything this returns has no counterpart in DB9. CREATE EXTENSION accepts exactly the eleven names in the list above and raises 42704 extension is not available for every other name — PostGIS, pg_trgm, ltree, citext and the rest of the contrib ecosystem included. Eight of the eleven are functional: http, uuid-ossp (uuid_generate_v4() only), fs9, pg_cron, parquet, zhparser, vector and embedding.
The remaining three, hstore, pgcrypto and plpgsql, are metadata-only shims — hstore values, for one, are not supported. They are in the list because dumps containing them restore cleanly — not because they bring anything with them.
PL/pgSQL check
Section titled “PL/pgSQL check”DB9 supports basic PL/pgSQL: variable declarations, IF/ELSIF, CASE, FOR loops (including EXIT WHEN and CONTINUE WHEN), PERFORM, RAISE, RETURN, dynamic EXECUTE with INTO and USING, exception handling (BEGIN...EXCEPTION) and nested BEGIN...END blocks — in a CREATE FUNCTION body, a DO block, and a procedure run with CALL. It does not support:
WHILEandFOREACHloops- Cursor operations
See Advanced SQL — PL/pgSQL for the full host comparison.
-- Find functions that may use unsupported PL/pgSQL featuresSELECT proname, prosrcFROM pg_procWHERE prolang = (SELECT oid FROM pg_language WHERE lanname = 'plpgsql') AND prosrc ~* '\m(WHILE|FOREACH|CURSOR|REFCURSOR)\M';Review any matches and rewrite them before migrating.
Trigger functions need one more check. CREATE TRIGGER ... BEFORE refuses a function that uses a
counted FOR loop, a bare LOOP, a CASE statement, EXECUTE, an exception handler or a nested
block (0A000, or 42601 for WHILE and FOREACH), even though CREATE FUNCTION accepted it —
so a restored dump fails at the CREATE TRIGGER statement. A FOR r IN SELECT … LOOP query loop
is the exception and is allowed; it needs a declared record variable, which anything coming from
PostgreSQL already has, since PostgreSQL rejects an undeclared one at CREATE FUNCTION. A CASE
expression on the right of an assignment is fine, and
AFTER triggers accept FOR loops, CASE statements and EXECUTE. See
Advanced SQL — Triggers. To find candidates on the source database:
-- Find functions attached as BEFORE triggers that use constructs DB9 refuses thereSELECT DISTINCT p.pronameFROM pg_trigger tJOIN pg_proc p ON p.oid = t.tgfoidWHERE NOT t.tgisinternal AND (t.tgtype & 2) <> 0 -- BEFORE triggers AND (p.prosrc ~* '\m(LOOP|CASE|EXECUTE)\M' -- over-matches: CASE also hits CASE expressions and -- LOOP also hits FOR r IN SELECT ... LOOP, both accepted OR p.prosrc ~* '\mEXCEPTION\s+WHEN\M' OR p.prosrc ~* '\mBEGIN\M.*\mBEGIN\M');What Changes
Section titled “What Changes”| Area | Standard PostgreSQL | DB9 |
|---|---|---|
| Connection string | postgresql://user:pass@host:5432/dbname | postgresql://<tenant_id>.<role>@pg.db9.io:5433/postgres |
| Port | 5432 (default) | 5433 |
| Database name | Custom | Always postgres |
| Username | Standard roles | <tenant_id>.<role> format (e.g., a1b2c3d4e5f6.admin) |
| Transaction isolation | SERIALIZABLE fully enforced | READ COMMITTED and REPEATABLE READ enforced, but some write conflicts fail at COMMIT with 40001 instead of at the statement; SERIALIZABLE is downgraded to REPEATABLE READ with a warning on the wire protocol, and rejected with an error over the HTTP SQL API |
| Connection pooling | External (PgBouncer, pgpool) | Application-side pooling |
| Replication | Logical and streaming | Logical decoding from replication slots; no CREATE SUBSCRIPTION and no physical replication |
| LISTEN/NOTIFY | Supported | Supported over pgwire (delivered on commit); the stateless HTTP SQL API can NOTIFY, but a LISTEN there is accepted and never receives anything |
| Extensions | Community ecosystem | Fixed built-in set only: http, uuid-ossp, fs9, pg_cron, parquet, zhparser, vector, embedding — plus hstore, pgcrypto and plpgsql as metadata-only shims |
| Indexes | All types fully functional | B-tree, GIN, HNSW, and Hash (single-column, non-unique, integer/OID); GiST/SP-GiST/BRIN rejected |
See the Compatibility Matrix for the complete list.
What Stays the Same
Section titled “What Stays the Same”- SQL — DML (SELECT, INSERT, UPDATE, DELETE, UPSERT), DDL (CREATE TABLE, ALTER, DROP), JOINs, CTEs, window functions, subqueries, and RETURNING work without changes, apart from the gaps listed in the Compatibility Matrix and the
pg_dumpstatements that step 2 removes or rewrites. - Data types — TEXT, INTEGER, BIGINT, BOOLEAN, TIMESTAMPTZ, UUID, JSONB, arrays, FLOAT8, NUMERIC, BYTEA, and vectors. A DB9
NUMERICvalue holds at most 28–29 significant digits and rejects'NaN'and'Infinity': a row holding a larger or non-finite value fails to import (INSERTorCOPY) with22P02, and decimal places beyond that capacity are rounded off without an error — see Data types. - Wire protocol — pgwire v3 (Simple Query, Extended Query, COPY, prepared statements). Any PostgreSQL driver works.
- ORMs — Prisma, Drizzle, SQLAlchemy, TypeORM, Sequelize, Knex, and GORM are tested at 98-100% compatibility.
Prerequisites
Section titled “Prerequisites”- Access to your source PostgreSQL database
pg_dumpinstalled locally (should match or be close to your source PostgreSQL version)- DB9 CLI installed:
curl -fsSL https://db9.ai/install | sh - A DB9 account:
db9 create --name my-appto create your target database
-
Export from PostgreSQL
Schema and data (plain SQL)
Terminal pg_dump --no-owner --no-privileges --no-comments \"postgresql://user:pass@your-host:5432/your_database" \> export.sqlSchema only
Terminal pg_dump --schema-only --no-owner --no-privileges \"postgresql://user:pass@your-host:5432/your_database" \> schema.sqlSpecific tables
Terminal pg_dump --no-owner --no-privileges -t users -t orders -t products \"postgresql://user:pass@your-host:5432/your_database" \> tables.sqlUse plain SQL format (default): the cleanup checks in step 2 read and edit the file as text, which a custom (
-Fc) or directory (-Fd) archive is not.Flags explained:
--no-owner— omitsALTER ... OWNER TOstatements that reference source-specific roles--no-privileges— omitsGRANT/REVOKEstatements--no-comments— omitsCOMMENT ONstatements
Locale and encoding settings in the pg_dump output (like
SET client_encoding) are accepted and safely ignored by DB9, which operates in UTF-8 only.Managed PostgreSQL notes
Provider Connection notes AWS RDS Use the endpoint hostname and master user credentials. Ensure the security group allows outbound connections from your machine. Google Cloud SQL Use Cloud SQL Auth Proxy or allowlist your IP. Direct connection: host:5432/dbname.Azure Database Use the {user}@{server}username format Azure requires.DigitalOcean Use the connection string from the database dashboard. Requires sslmode=require. -
Clean the Export
Review the export for features DB9 does not support:
Terminal # Unsupported extensionsgrep "CREATE EXTENSION" export.sql# Table partitioninggrep -i "PARTITION BY\|PARTITION OF" export.sql# Table inheritancegrep -i "INHERITS" export.sql# Foreign data wrappersgrep -i "CREATE SERVER\|CREATE FOREIGN TABLE" export.sql# Replicationgrep -i "CREATE PUBLICATION\|CREATE SUBSCRIPTION" export.sql# Rulesgrep -i "CREATE RULE" export.sqlRemove or comment out any matches. For extensions, keep only the names DB9 accepts:
http,uuid-ossp,fs9,pg_cron,parquet,zhparser,vectorandembedding, pluspgcryptoandplpgsql, which DB9 accepts as metadata shims. DropCREATE EXTENSION hstoretoo: DB9 accepts it, but does not supporthstorevalues, and while it is registeredpg_dumpcannot dump your DB9 database.Partitioned tables need rewriting rather than removing. DB9 accepts
PARTITION BY RANGEon atimestamportimestamptzkey only —LISTandHASHfail with42601, and any other key type with0A000 RANGE partitioning currently requires a timestamp or timestamptz key. Even a table that qualifies does not restore as dumped:pg_dumpattaches each partition withALTER TABLE ONLY ... ATTACH PARTITION, which DB9 does not parse (42601), so the partition is left as a stand-alone table whose rows never show through the parent. Replace eachCREATE TABLE+ATTACH PARTITIONpair withCREATE TABLE <partition> PARTITION OF <parent> FOR VALUES FROM (...) TO (...), and declare the parent’s keys in its ownCREATE TABLE: once a partitioned table has partitions,CREATE INDEXon it fails with0A000, and so does anALTER TABLEthat adds, drops, renames or alters its columns, keys or constraints (OWNER TOstill works). Each partition gets its own copy of those keys, so drop the per-partition key statementspg_dumpwrites as well. A partitioned table cannot have an identity column — creating its first partition fails with0A000 partition identity-column inheritance is not yet supported— so declare that columnBIGSERIALin the rewrite. After the import, only plainINSERTandSELECTgo through the parent:UPDATE,DELETE,TRUNCATEandCOPY ... FROMon it fail with0A000, so run them against a partition.INSERT ... ON CONFLICTfails with0A000on the parent and on its partitions alike.Common cleanup patterns
Terminal # Remove all CREATE EXTENSION except supported onessed -E -i.bak -e '/CREATE EXTENSION/!b' \-e '/uuid-ossp|vector|embedding|http|fs9|pg_cron|parquet|zhparser|pgcrypto|plpgsql/!d' \export.sqlOr manually review and remove the flagged lines.
Keep
vector, but rewrite how the dump names it.pg_dumpwrites the type, its operator classes and its operators schema-qualified —public.vector(1536),public.vector_cosine_ops,OPERATOR(public.<=>)— and DB9 accepts them only unqualified: the table’sCREATE TABLEfails with42704 type "public.vector" does not exist, a view that usesOPERATOR(public.<=>)fails withinternal error, and whenpsqlreaches that table’sCOPYit reads the rows as SQL — depending on thepsqlversion, later tables can lose their rows too. Strip the qualifier:Terminal # Write the vector type, its operator classes and its operators unqualifiedsed -E -i.bak \-e 's/(^|[^[:alnum:]_])public\.vector([^[:alnum:]_]|_(l2|ip|cosine)_ops|$)/\1vector\2/g' \-e 's/OPERATOR\(public\.(<->|<#>|<=>)\)/\1/g' \export.sqlTables such as
public.vectorsorpublic.vector_storekeep their qualified names. pgvector functions the dump writes qualified, such aspublic.l2_distance(...)in a view or a default, fail with42883the same way; remove theirpublic.by hand.Row-level security
Keep your row-level security statements.
ENABLE/FORCE ROW LEVEL SECURITYandCREATE POLICYimport from apg_dumpfile, and DB9 enforces the policies for roles that are not the table owner. See Row-Level Security. One thing needs attention:- Roles.
pg_dumpdoes not export roles, and a policy that names one (CREATE POLICY ... TO app_user) fails with42704 role "app_user" does not exist. Create each role in DB9 after step 3 and before step 4. The compatibility scan above lists them. For example:psql "$(db9 db connect my-app --output quiet)" -c "CREATE ROLE app_user LOGIN PASSWORD '<password>'".--no-privilegesalso leaves out the grants, so grant those roles their table and sequence privileges again after the import.
- Roles.
-
Create the DB9 Database
Terminal db9 create --name my-app --show-connection-stringDatabase creation is synchronous and completes in under a second.
-
Import into DB9
Import with
psqlover pgwire in two passes: the schema while the tables are still empty, then the data streamed straight from the source. Apply the step 2 cleanup toschema.sqlfirst — the same commands work on it.Terminal # 1. Import schemapsql "$(db9 db connect my-app --output quiet)" -f schema.sql# 2. Stream data directly from source to DB9pg_dump --data-only --no-owner \"postgresql://user:pass@your-host:5432/your_database" \| psql "$(db9 db connect my-app --output quiet)"This pipes
COPYstatements through pgwire without intermediate files. DB9 supports COPY in TEXT and CSV formats.psqlkeeps going after an error, so scan its output forERROR:lines.Import errors
If import fails partway through:
- Unsupported DDL — check the error message for the specific statement, remove it from the SQL file, and re-run.
- Data type mismatch — DB9 does not support XML, CIDR/MACADDR, or most range types (INET is supported). Cast or remove these columns.
timetz/time with time zonecolumns import, offsets included — except a column withDEFAULT CURRENT_TIMEorDEFAULT now(), which fails with42804; drop the default before exporting. Casts, interval arithmetic,AT TIME ZONEandEXTRACTdo not work on them — see the compatibility matrix before relying on them in queries. Bit-string columns import cleanly:bit(n)andbit varying(n)— both of the spellingspg_dumpemits — parse, and the declared length is enforced exactly as in PostgreSQL (22026for abit(n)mismatch,22001for an over-lengthvarbit(n)), so no conversion is needed. The one difference is thatbit(1)and barebitbecomeBOOLEAN; they round-trip'1'/'0'correctly but report asbooleanin the catalog. Composite columns are the ones to handle before you export: a composite column accepts a text literal without validating it and can never be read back field-wise — flatten it into scalar columns. See the compatibility matrix. - Encoding errors — DB9 is UTF-8 only. Non-UTF-8 data will fail with “invalid byte sequence for encoding UTF8”. Convert the source data to UTF-8 before export.
-
Update Your Application
Connection string
Diff DATABASE_URL=postgresql://user:password@your-host:5432/your_databaseDATABASE_URL=postgresql://a1b2c3d4e5f6.admin@pg.db9.io:5433/postgres?sslmode=requireKey differences:
- Username:
<tenant_id>.<role>format - Port: 5433
- Database: Always
postgres - TLS: Supported, and used by default — set
sslmode=requireexplicitly (the server does not enforce a minimum)
Connection pooling
If you use an external connection pooler (PgBouncer, pgpool), remove it and configure pooling in your application:
TypeScript // node-postgresconst pool = new pg.Pool({connectionString: process.env.DATABASE_URL,max: 10,idleTimeoutMillis: 30000,});Python # SQLAlchemyengine = create_engine(DATABASE_URL,pool_size=10,pool_pre_ping=True,)For ORM-specific connection setup, see the integration guides: Prisma, Drizzle, SQLAlchemy, GORM.
LISTEN/NOTIFY
LISTENandNOTIFYwork as they do in PostgreSQL: notifications are queued inside the transaction and delivered to listening sessions on commit.SQL -- session ALISTEN order_events;-- session BNOTIFY order_events, 'order-1234';LISTENrequires a session that stays open, so a subscription must be held over a direct pgwire connection (db9 db connect <db>, or any Postgres driver). The stateless HTTP SQL API —db9 db sqland the SDK’ssql()method — can issueNOTIFY, but cannot hold a subscription to receive.Sequences and SERIAL columns
SERIAL,BIGSERIALand identity columns import as they stand, and the data stream in step 4 already restores each sequence position —pg_dumpends its data section withsetvalcalls. To check a sequence against its table:Terminal db9 db sql my-app -q "SELECT last_value FROM users_id_seq"db9 db sql my-app -q "SELECT max(id) FROM users"To move a sequence,
setvaltakes its value straight from a subquery, so one statement does it:Terminal psql "$(db9 db connect my-app --output quiet)" \-c "SELECT setval('users_id_seq', (SELECT COALESCE(max(id), 1) FROM users))" - Username:
-
Validate
Check schema
Terminal db9 db dump my-app --ddl-onlyCompare with your original schema.
Check row counts
Terminal db9 db sql my-app -q "SELECT count(*) FROM users"db9 db sql my-app -q "SELECT count(*) FROM orders"Compare against the source database.
Run your test suite
Terminal DATABASE_URL="$(db9 db connect my-app --output quiet)" npm testCommon differences to watch for
- SERIALIZABLE isolation — DB9 does not implement SERIALIZABLE. On the wire protocol it is accepted with a
WARNINGand silently downgraded to REPEATABLE READ, so code that relies on serializable guarantees (write-skew prevention) keeps running without failing. Audit those transactions 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. - Index access methods —
btree,gin,hnsw, andhashcan be created;GiST,SP-GiST, andBRINare rejected atCREATE INDEXtime withaccess method "..." is not supported(0A000), so a dump that contains them will fail to restore those statements too. GIN is functional — full-text search and JSONB containment use index scans for a literal or a driver-bound probe. For full-text, a*_tsquery()call must name its configuration as a literal (websearch_to_tsquery('english', $1)is indexed); omitting or binding the configuration, or computing the probe with an expression (jsonb_build_object(...),ARRAY[lower(...)], a subquery), silently falls back to a sequential scan. Hash indexes are planned and used for equality lookups, within narrower bounds than PostgreSQL’s: integer and OID columns only (hash indexes currently support only integer and OID columns), noUNIQUEand no multicolumn (both0A000). A hash index on a text column is the common case that does not carry over — usebtreethere. - Advisory locks —
pg_advisory_lock()and related functions are available, but coordination is node-local (not cross-process/global). For strict row-level coordination semantics, useSELECT ... FOR UPDATE.
- SERIALIZABLE isolation — DB9 does not implement SERIALIZABLE. On the wire protocol it is accepted with a
Rollback Plan
Section titled “Rollback Plan”Your source database is unchanged by the migration. To revert:
- Switch
DATABASE_URLback to the original PostgreSQL connection string. - If you need to export data created in DB9:
# Small databasesdb9 db dump my-app -o db9-export.sqlpsql "postgresql://user:pass@your-host:5432/your_database" -f db9-export.sql
# Any size — pg_dump 17 or newer, over pgwire (see the notes below)pg_dump --no-owner --no-privileges -N extensions -N cron \ "$(db9 db connect my-app --output quiet)" -f db9-export.sqlpsql "postgresql://user:pass@your-host:5432/your_database" -f db9-export.sql
# One table at a time — COPY, over pgwirepsql "$(db9 db connect my-app --output quiet)" \ -c "COPY users TO STDOUT WITH (FORMAT csv, HEADER)" > users.csvpsql "postgresql://user:pass@your-host:5432/your_database" \ -c "COPY users FROM STDIN WITH (FORMAT csv, HEADER)" < users.csvThe 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 as above, or export individual tables with COPY. What to know about pg_dump against DB9:
- Use version 17 or newer. DB9 reports server version 17.0, and an older
pg_dumpstops withserver version mismatch. - Keep the flags.
-N extensionsis what lets the dump finish: without it, a registeredfs9orembeddingextension stops it withunrecognized provolatile value for function .... Together with-N cronit also keeps DB9’s ownextensionsandcronschemas out of the dump.--no-owner --no-privilegesleaves out DB9’sadminrole and its grants, which do not exist on your server. - What it cannot dump. A database that has a partitioned table (
cannot export persisted RANGE partition ...), or one with thehstoreextension registered (column "typanalyze" does not exist— runDROP EXTENSION hstorefirst). A parallel dump (-j) fails as well, becausepg_export_snapshot()does not exist; dump serially. - Create
vectortables on the target first. Avectorcolumn is dumped as a barevector, with no schema and no dimension, so itsCREATE TABLEfails on a pgvector server. Worse, once that table’sCOPYfails,psqlreads its rows as SQL and loses theCOPYof the tables the dump writes after it too. Before importing, runCREATE EXTENSION vectoron the target and create each table that has avectorcolumn yourself, with its dimension. The import still prints errors for those tables —type "vector" does not existfor the dump’sCREATE TABLE, andalready existsormultiple primary keysfor the sequences and keys you created — but it loads their rows. Recreate their HNSW indexes afterwards. - Importing into PostgreSQL 16 or older prints one harmless error,
unrecognized configuration parameter "transaction_timeout".
Caveats
Section titled “Caveats”- No zero-downtime migration — DB9 cannot subscribe to logical replication (
CREATE SUBSCRIPTIONis not supported), so it cannot follow the source database while you cut over. Plan a maintenance window for the cutover, or accept a brief period of dual-writes. - UTF-8 only — DB9 does not support other encodings. Ensure your data is UTF-8 before export.
- Plain SQL format — export with
pg_dump’s default plain-text format so the step 2 checks can read and edit it. - 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 — see step 4. - No custom extensions — the built-in set is fixed and compiled into the server, so there is nothing to install and no version skew between databases, but also no way to add to it. If your application depends on PostGIS, ltree, pg_trgm, or other community extensions, those features will not be available and
CREATE EXTENSIONraises42704. Watchpgcryptoin particular: the statement is accepted but supplies no functions, socrypt()/gen_salt()fail at call time rather than at import — see the extension check above. - No custom index access methods — only
btree,gin,hnsw, andhashcan be created, andhashis limited to single-column, non-unique indexes on integer or OID columns.
Next Pages
Section titled “Next Pages”- Compatibility Matrix — full list of supported and unsupported features
- Connect — connection string format and authentication
- Migrate from Neon — Neon-specific migration
- Migrate from Supabase — Supabase-specific migration
- Production Checklist — deployment readiness
- Limits and Quotas — operational limits