CapyDB/ docs
GuidesImports & Migrations

Migrate from Neon

Migrate from Neon to CapyDB: scan the repo, swap the @neondatabase/serverless driver with the codemod, then import the schema and data or the data alone.

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 codemod neon          # dry run: what would change, and what needs you
capydb migrate codemod neon --write  # apply

The codemod swaps the imports and client construction below, rewrites package.json and drizzle.config, and turns inline db.batch([...]) calls into transactions. Or do it by hand - capydb migrate deps only swaps the dependency. The result:

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). The codemod does this for batches written inline on the client and lists the ones it cannot prove safe (statements built elsewhere and passed in, spreads).
  • 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.

Pasted the -pooler string anyway? --from-neon (or --from neon) switches it to the direct endpoint, adds sslmode=require when missing, and prints what it changed.

Preflight

capydb import preflight --from-neon --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 --from-neon --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. Whatever was in the target is backed up before the drop, labeled pre-import-<job id> in the project's backup list.

Schema already applied? Copy only the data

If you ran your migrations against the CapyDB project first (the usual Drizzle flow), copy just the rows:

capydb import --data-only --from-neon --source-url "postgres://...neon.tech/dbname?sslmode=require" --dry-run
capydb import --data-only --from-neon --source-url "postgres://...neon.tech/dbname?sslmode=require"

It loads parents before children by the project's foreign keys, in one transaction, into empty tables, and moves the sequences to where Neon's were. It runs from your machine - see the reference.

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.