CapyDB Docs
GuidesImports & Migrations

Migrate from Neon

Move a Neon Postgres database into CapyDB.

Scan first

capydb migrate scan            # read-only; no login needed

The scan classifies the repo (driver, auth, call sites, every database hostname in your env files) and prints the migration plan with an effort grade. It flags the two Neon-specific code changes up front: the @neondatabase/serverless driver swap and any db.batch() / sql.transaction([...]) call sites that need rewriting to real transactions. If other repos share the same database, pass --portfolio <dir> so the cutover list includes every consumer.

Swap the driver

@neondatabase/serverless speaks Neon's HTTP/WebSocket protocol and cannot connect to plain Postgres. Swap to postgres-js:

capydb migrate deps            # removes the Neon driver, adds "postgres" to package.json

Then update the imports the scan listed:

import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'

// CapyDB's pooled endpoint (:6432) is transaction-mode PgBouncer:
// one connection per serverless instance, no prepared statements.
const client = postgres(process.env.DATABASE_URL!, { max: 1, prepare: false })
export const db = drizzle({ client })
  • db.batch([...]) (neon-http) becomes db.transaction(async (tx) => { ... }) - rebind every inner statement to tx (a db-bound query inside a max: 1 transaction deadlocks).
  • Raw neon(url)`...` tagged-template call sites: postgres() is call-compatible, but hoist it to a module-level singleton - it holds a socket, so per-call construction leaks connections.
  • Point drizzle.config.ts at DATABASE_DIRECT_URL (the :5432 endpoint) - DDL through a transaction pooler is unreliable.
  • Drop channel_binding=require from stored URLs.

Get the source connection string

In the Neon console: Project → Dashboard → Connect (the "Connection string" panel). Take the direct connection string, not the -pooler one - a logical copy cannot run through a transaction-mode pooler, so preflight and import reject -pooler hostnames outright with an error telling you to switch.

It looks like:

postgres://user:password@ep-xxxx-xxxx.eu-central-1.aws.neon.tech/dbname?sslmode=require

Neon-specific caveats

  • SNI: Neon routes connections by TLS SNI. CapyDB's importer uses standard TLS with SNI, so the plain hostname works. If you ever test the same URL with a very old client and get "endpoint could not be found", that is the SNI issue - irrelevant here, but it explains odd results from other tools.
  • Autosuspend: a scaled-to-zero Neon endpoint takes a moment to wake; the preflight's connection timeout (10s) is usually enough, but retry once if the first preflight fails with a timeout.
  • Branches: the URL identifies one branch's endpoint. Make sure it is the branch you actually want (usually main).
  • Postgres major: check the project's Postgres version in Neon settings and create the CapyDB target on the same or a newer supported major (16, 17, or 18). A newer source cannot be imported into an older target.

Preflight

capydb import preflight --source-url "postgres://...neon.tech/dbname?sslmode=require"

or dashboard → project → Import → paste URL → Run preflight. Fix anything that fails - size over plan limit, unsupported extensions (Neon projects often have pg_stat_statements enabled, which is on the allowlist; Neon-specific extensions are not commonly present in user databases).

Import

Stop or pause writers on the Neon side (the import is point-in-time; later writes are not carried over - see downtime honesty). Then:

capydb import --source-url "postgres://...neon.tech/dbname?sslmode=require" --recreate --wait

--recreate drops and recreates the target database first, so re-running an import is safe and idempotent in outcome.

After the import

  1. Verify row counts with SELECT count(*) per table - not pg_stat_user_tables.n_live_tup, which reads zero on a scaled-to-zero Neon source and false-alarms the comparison.
  2. Verify sequences: spot-check SELECT last_value FROM <seq>; for hot tables.
  3. \dx - confirm expected extensions exist on the CapyDB side; ANALYZE; once.
  4. Cut over env vars: capydb link --overwrite-env writes DATABASE_URL (pooled) and DATABASE_DIRECT_URL in one step. Redeploy every consumer of the database at once.
  5. Confirm nothing still talks to Neon before you decommission:
capydb migrate verify --source-url "postgres://...neon.tech/dbname?sslmode=require"

Zero client connections on the old source means every consumer moved; anything listed is a deployable you missed. Keep the Neon project read-only for a rollback window (a week or two), then delete it.