DocsGuides
Migrate from Neon
Move a Neon or any Postgres database into BataDB with bata import.
bata import moves a Postgres database into a new BataDB project with one
command. The source can be a Neon project, an RDS instance, a local database, or
anything else that speaks Postgres.
It uses the standard pg_dump and psql client tools, so there is no
proprietary format. The dump streams straight into the target without touching
your disk. Before it reports success, it compares row counts and sequence values
on both sides.
Moving from another host? Move your Postgres to BataDB is the general guide: where every host keeps its direct URL, real output from each check, the cutover and handy commands. This page is the Neon deep dive.
BataDB runs PostgreSQL 16 and 17. A source on PostgreSQL 18 (Neon offers it) lands on a PostgreSQL 17 project. Most schemas move cleanly across that gap. The version caveat explains what does not.
Prerequisites
The
bataCLI, signed in withbata loginor an API key:Terminalnpm install -g @batadata/cli bata login # or: export BATA_API_KEY=<your-api-key>A card on file, on the Free plan. A Free team runs no compute until a card is on file, so the import cannot create its target without one. Get a link with
bata billing card add. The free allowance stays free.Room for the data, on the Free plan. Free includes 1 GB of storage and, by default, stops at its allowance instead of billing overage. When your database is larger, turn on pay as you go first with
bata billing stop-at-limit off. See Pricing and billing.Local
pg_dumpandpsqlat least as new as the source server.pg_dumpcan dump its own major version or older, never a newer one.psqlmust also be at least as new aspg_dump, because it replays the streampg_dumpwrites. For a PostgreSQL 18 source, install the 18 client tools:Terminal# macOS (Homebrew) brew install postgresql@18 export PATH="$(brew --prefix postgresql@18)/bin:$PATH" # Ubuntu or Debian (from the PostgreSQL apt repository if your release # does not ship version 18) sudo apt install postgresql-client-18Check what you have:
Terminalpg_dump --version psql --versionYou do not have to get this right by hand.
bata importchecks both versions before it creates anything, and names the version to install when one is too old.
The command
bata import --source <postgres-uri> [--name <name> | --project <id>] [--pg <major>] [--allow-pooled-source] [--yes] [--json]| Flag | Meaning |
|---|---|
--source <uri> | Required. The source connection string. From Neon, use the direct (unpooled) connection string: its host has no -pooler. A pooled string (a -pooler host, port 6543 or 6432, or pgbouncer=true) is refused with POOLED_SOURCE, because pg_dump's session settings would leak through a transaction pooler to your app's other connections. The same rule applies to any Postgres host. |
--name <name> | Create a new project with this name. Defaults to the source database's name. |
--project <id> | Import into an existing project instead. |
--pg <major> | PostgreSQL major for a new project: 16 or 17. Defaults to the source's major, capped at 17 and at least 16. Not allowed with --project. |
--allow-pooled-source | Accept a source that looks pooled. Use it only for a direct server that happens to look like one, or a pooler in session mode. |
--yes, -y | Import into an existing project that already holds objects. |
--json | Print one machine-readable result, for agents and CI. |
--name and --project cannot be combined: one creates a project, the other
reuses one.
Examples
# New project named after the source database ("neondb")
bata import --source "postgresql://<user>:<password>@ep-example-123456.us-east-2.aws.neon.tech/neondb?sslmode=require"
# New project with an explicit name
bata import --source "$NEON_DATABASE_URL" --name production-clone
# Into an existing project that already has objects, unattended
bata import --source "$NEON_DATABASE_URL" --project <project-id> --yes --jsonWhat it does
bata import prints one line per phase. With --json it prints only the final
result.
- Check the tools. It confirms
pg_dumpandpsqlare on your PATH. - Inspect the source. It reads the database name, server version, size, table count and installed extensions. A bad URI or an unreachable server fails here, with the connection error.
- Check tool versions.
pg_dumpmust be at least the source's major.psqlmust be at least the source's major and at leastpg_dump's. - Resolve the target. With
--project, it looks the project up. Otherwise it creates a serverless project on the smallest compute size (1 CU). Then it waits up to 180 seconds for the compute to be ready. - Guard an existing target. With
--project, it counts the objects already there: tables, views, materialized views, sequences, foreign tables, schemas, types and functions. If there are any, it stops unless you passed--yes, because the restore could collide with them. - Check extensions. It compares the extensions your source uses against the
ones the target can provide (
pg_available_extensions). If one is missing, it stops withEXTENSION_UNSUPPORTEDbefore any data moves. - Migrate. It runs
pg_dump --no-owner --no-privileges --encoding=UTF8on the source and pipes it intopsql -v ON_ERROR_STOP=1on the target. On the first restore error it stops and prints the errors. The UTF-8 encoding keeps a non-UTF-8 source from being garbled in transit. - Verify. It counts every table's rows on both sides, and compares every sequence's value. Any difference is printed as a table, and the command exits non-zero.
- Done. It prints the project id, the PostgreSQL version, and the connection string.
With --json, the password in the result's connection string reads REDACTED,
so a saved log never holds it. Get the full string with
bata db url --project <project-id>.
The restore does not use --single-transaction. One transaction that runs for
hours fails as a whole when a single connection drops. Statement by statement,
with ON_ERROR_STOP=1, it stops at the first real error and shows it to you.
Source newer than target
BataDB runs PostgreSQL 16 and 17. A source one major newer, such as PostgreSQL 18 into 17, restores cleanly for standard schemas. There is one automatic fix and one limit.
- Fixed for you. Newer
pg_dumpversions writeSET transaction_timeout = 0;at the top of the dump, which PostgreSQL 16 rejects.bata importremoves that line from the dump's opening settings, and only there. Nothing in your schema or data is changed. - Not hidden. A data type, function, syntax or storage option that does not
exist on the target's major fails during the restore.
bata importprints thepsqlerrors and exits non-zero. It never skips the failing objects or claims success. Change that part of the schema to something the target supports, then run the import again.
Tables, indexes, constraints, sequences, views and common extensions usually move with no changes.
Checking the result yourself
The import already compares row counts and sequences. To spot-check a table:
# On the source
psql "$NEON_DATABASE_URL" -Atc "SELECT count(*) FROM your_table"
# On the target
bata db query "SELECT count(*) FROM your_table" --project <project-id>Verification covers every table and sequence on the source. A source table or
sequence that is missing on the target, or whose count or value differs, fails
the import with VERIFY_MISMATCH.
On a --project --yes import, the target may hold tables or sequences the source
does not have. Those are left untouched and are not part of the comparison. The
import says so: a warning in normal output, and target_only_tables and
target_only_sequences in the --json result. A newly created project should
never have any. If it does, the import fails with VERIFY_FAILED.
If a verification query cannot run at all, the import also fails with
VERIFY_FAILED. It never reports success without a completed comparison.
Objects the platform adds
Every BataDB project has a small public.health_check table and its sequence.
The import ignores them on the target: they never trip the --yes guard and
never show up as target-only objects.
If your source has its own public.health_check table, the import warns you
first. Into a newly created project, it drops the target's copy so yours restores
and is verified like any other table. Into an existing --project, the restore
will likely fail with relation ... already exists; rename the source table
first.
Two internal schemas, neon and neon_migration, are skipped on both sides. The
target maintains its own copies. If your own data lives in a schema with one of
those names, rename it before you import.
Extensions
Run this on a BataDB project to see every extension it offers:
bata db query "SELECT name, default_version FROM pg_available_extensions ORDER BY name" --project <project-id>You do not have to compare lists by hand. The import's extension check stops before any data moves if your source uses something the target cannot provide.
Common extensions such as pgcrypto, pg_trgm, uuid-ossp, citext and
vector are available.
Moving a production app
The command moves the data. These are the steps around it for a real cutover.
Install the client tools. See Prerequisites.
Stop writes on the source. Use a maintenance window, or pause the app's writers.
pg_dumpreads a consistent snapshot, but anything written after it starts does not come across.Import. Run
bata import --source "$NEON_DATABASE_URL" --name <app>. Verification runs on its own. Spot-check one table that matters to you anyway.Check roles. The dump is restored with
--no-owner --no-privileges. Every object is owned by the role in your BataDB connection string. Source roles and grants are not recreated. An app with oneDATABASE_URLneeds nothing more. An app with several roles re-creates its grants after the import.Point the app at BataDB. Use the pooled connection string for app traffic and serverless functions, the direct one for migrations, or
@batadata/serverlessfor edge runtimes. See Connecting. Deploy, run your smoke tests, then stop the Neon writer.Sequences carry over. They are verified, so inserts continue from the right value. You do not need
setval.Run migrations on the direct connection string. Most migration tools, such as
prisma migrate deploy, take a session-level advisory lock. The pooled connection string runs in transaction mode: each transaction can land on a different server connection, and session state is reset after every transaction. A session lock taken there protects nothing. With Prisma, declare adirectUrl:Prisma schemadatasource db { provider = "postgresql" url = env("DATABASE_URL") // pooled: the app directUrl = env("DIRECT_URL") // direct: migrations }directUrlhas no fallback, so set the variable in every environment, including local and CI. The app's own queries are fine on the pooled string.If a migration ever hangs waiting for an advisory lock, find the holder over the direct connection string and end it:
SQLSELECT l.pid, a.state, age(now(), a.state_change) AS idle_for FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE l.locktype = 'advisory' AND l.granted; -- then, for an idle holder: SELECT pg_terminate_backend(<pid>);Keep the Neon project read-only for a while. It is your rollback.
One Neon project can hold several databases. bata import moves one database per
run: the one named in the URI. Run it once per database, each into its own BataDB
project.
Manual fallback: pg_dump | psql
Use bata import when you can. It does everything below, plus the version
checks, the extension check, verification and cleanup. Use the manual pipe only
when you need to customize the dump, for example to pick schemas or exclude
tables.
# 1. Create the target and get its direct connection string
PROJECT=$(bata create my-app --json | jq -r .id)
TARGET=$(bata db url --project "$PROJECT")
# 2. Stream the dump into the target. The awk filter removes the
# newer-only SET transaction_timeout line, and only from the opening
# settings. --encoding=UTF8 keeps a non-UTF-8 source intact.
# The two excluded schemas are internal ones the target already has.
pg_dump "$NEON_DATABASE_URL" --no-owner --no-privileges --encoding=UTF8 \
--exclude-schema=neon --exclude-schema=neon_migration \
| awk 'p{print;next}
/^[[:space:]]*$/||/^--/||/^\\(un)?restrict/||/^SET .*;[[:space:]]*$/||/^SELECT pg_catalog\.set_config\(.*\);[[:space:]]*$/{
if($0 ~ /^SET[[:space:]]+transaction_timeout/)next; print; next }
{p=1;print}' \
| psql "$TARGET" -v ON_ERROR_STOP=1
# 3. Spot-check
psql "$TARGET" -Atc "SELECT count(*) FROM your_table"Why awk and not grep -v 'SET transaction_timeout'? A grep removes that line
anywhere in the stream, including inside a function body or a COPY data row,
which would silently change your data. The awk filter does what bata import
does. It only looks at the opening block of blank lines, comments, \restrict,
SET and set_config lines. It stops filtering at the first real statement.
If the restore stops on a type or syntax the target's major does not have, see Source newer than target.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
pg_dump and psql are required but were not found on PATH | Client tools not installed | Install them (see Prerequisites) |
Your pg_dump is version 17, but the source server is PostgreSQL 18 | Client tools older than the source | Install the PostgreSQL 18 client tools |
Your psql is version 17, but pg_dump is version 18 | psql older than pg_dump | Put matching psql and pg_dump on your PATH |
Could not read the source database: ... | Wrong URI, network, or SSL | Check the host and credentials. Neon requires ?sslmode=require |
An error ending in (PAYMENT_METHOD_REQUIRED) | Free team with no card on file | Open the link in the message, or get one with bata billing card add |
Target compute did not become ready within 180s | The new compute is still starting | Wait a moment and run the import again |
Target project already has N user object(s) | --project points at a project with existing objects | Add --yes if you mean to import on top of them |
The source uses extension(s) the target can't provide: ... | The source uses an extension BataDB does not offer | Drop it on the source (DROP EXTENSION ...) before importing, or ask for it at kirby@zvndev.com |
Restore failed | Usually a feature newer than the target's major | Read the printed psql errors and adjust the schema |
Verification failed or Verification could not run | Counts or sequence values differ, or a check query failed | Compare the tables by hand, then retry (below) |
When the restore or verification fails
If bata import created the project and then the restore or verification fails,
it keeps the project. A partial restore can take a long time to produce and helps
you diagnose the failure. The output names the project id. With --json, the
result carries target.project_id and target.created: true.
The clean retry is to delete the partial project and run the import again:
bata projects delete <project-id> --yes
bata import --source "$SOURCE" --name <name>The CLI also suggests retrying into the same project with
--project <project-id> --yes. That only works when the failed run created no
objects. Otherwise the restore stops at the first object that already exists.
A failed extension check is different. It happens before any data moves, so a project created for that run is deleted for you.
See also
- Move your Postgres to BataDB: the general guide, for any host.
- Migrating from Prisma: moving a Prisma app's data and code.
- Turbine ORM: using Turbine with your imported database.
bata import --help: the flag list for your installed CLI.