This guide moves a Postgres database into a new, unsharded PlanetScale Neki database with pg_dump and pg_restore, then checks the result with row counts and content checksums. The import is offline: source writes stay stopped from the start of the dump until cutover.
What you will build
A new Neki database that holds an exact copy of a source Postgres database (tables, an enum, a view, foreign keys, identity and serial columns), validated against checksums taken from the source before the dump.
source Postgres --pg_dump (custom format)--> source.dump --pg_restore--> Neki (one shard, unsharded)
| |
baseline counts + checksums ------------------ compared ---------------+
AI Prompt
Using the PlanetScale CLI (pscale 0.344.0) and the Postgres client tools, import an existing Postgres database into a new Neki database.
Requirements:
- Neki is in Platform Preview. Automated imports are not available, so use the manual path: stop source writes, run pg_dump with --format=custom --no-owner --no-privileges, then run pg_restore with --no-owner --no-privileges --exit-on-error into a new, empty, unsharded Neki database.
- Create the Neki database with: pscale database create <name> --engine neki --region us-east --cluster-size PS-10-AWS-ARM-NEKI --replicas 2 --wait. Neki has no single-node configuration, so --replicas 2 is the minimum.
- Create an admin role with: pscale role create <db> main <role> --inherited-roles postgres --format json. The role takes several seconds to become usable, so retry the first psql connection.
- Neki requires TLS. Use sslmode=require.
- The target must be empty. Restoring into a database that already contains the objects fails on the first CREATE statement.
- Record row counts and an md5 checksum over each table's ordered contents on the source before the dump, and compare them against Neki after the restore.
- Compare sequences by inserting a new row and reading the id it receives. Do not compare last_value, because Neki routers reserve ranges of sequence values.
- When counting foreign keys, exclude constraints in the __neki schema.
- Run the verification step below before finishing.
Prerequisites
A PlanetScale organization with access to the Neki Platform Preview. Create an account if you do not have one, and see Neki availability and access for how an organization administrator joins the preview.
The PlanetScale CLI,
pscale0.344.0, authenticated with a service token that can create databases:
export PLANETSCALE_SERVICE_TOKEN_ID="<token id>"
export PLANETSCALE_SERVICE_TOKEN="<token>"
export PLANETSCALE_ORG="<organization name>"
pscale version
- Postgres client tools. This guide used
psql,pg_dumpandpg_restorefrom PostgreSQL 17.11 against a PostgreSQL 17.11 source and a Neki database running PostgreSQL 18.6.jqandpython3are also used. - A source Postgres database. The example source is a local PostgreSQL 17.11 database named
appdbon port 5544. Create it with this exact schema and data:
export SOURCE_DATABASE_URL="postgresql://postgres@localhost:5544/appdb?sslmode=disable"
psql "postgresql://postgres@localhost:5544/postgres?sslmode=disable" -c "CREATE DATABASE appdb"
psql "$SOURCE_DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
CREATE TYPE order_status AS ENUM ('pending','paid','shipped','refunded');
CREATE TABLE customers (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
profile jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
order_id bigserial PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(customer_id),
status order_status NOT NULL DEFAULT 'pending',
total_cents integer NOT NULL CHECK (total_cents >= 0),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
CREATE VIEW paid_orders AS SELECT * FROM orders WHERE status = 'paid';
INSERT INTO customers (email, profile)
SELECT 'user' || g || '@example.com', jsonb_build_object('tier', CASE WHEN g % 10 = 0 THEN 'pro' ELSE 'free' END)
FROM generate_series(1, 20000) g;
INSERT INTO orders (customer_id, status, total_cents)
SELECT (g * 7919) % 20000 + 1, (ARRAY['pending','paid','shipped','refunded'])[g % 4 + 1]::order_status, (g::bigint * 104729) % 100000
FROM generate_series(1, 100000) g;
SQL
Note: Output samples below are trimmed to the relevant lines. Each Neki shard bills as a three-node cluster. The
PS-10-AWS-ARM-NEKIsize lists at $30 per month per shard, prorated to the millisecond. Current rates are on the Neki pricing page. Delete the database when you finish withpscale database delete <name> --force.
1. Record baselines on the source
The checksum covers every column that matters for the comparison, ordered by primary key, so it changes if any value differs.
psql "$SOURCE_DATABASE_URL" -At <<'SQL'
SELECT 'customers', count(*), md5(string_agg(customer_id::text||email||profile::text, ',' ORDER BY customer_id)) FROM customers;
SELECT 'orders', count(*), md5(string_agg(order_id::text||customer_id::text||status::text||total_cents::text, ',' ORDER BY order_id)) FROM orders;
SELECT 'paid_orders view', count(*) FROM paid_orders;
SQL
Real output:
customers|20000|3c65c0429894cbb29f99a40cdd592244
orders|100000|a2e84a2792a3d0eb8d32259c71734477
paid_orders view|25000
The fixture is deterministic, so these values are the same on every run.
2. Create the Neki database and a role
pscale database create imported --engine neki --org "$PLANETSCALE_ORG" \
--region us-east --cluster-size PS-10-AWS-ARM-NEKI --replicas 2 --wait --format json | jq '{name, state, kind}'
Real output:
{
"name": "imported",
"state": "ready",
"kind": "neki"
}
Create an admin role after the database reports ready, and export the connection settings. The role JSON contains the password, so delete the file after reading it.
pscale role create imported main admin --inherited-roles postgres --org "$PLANETSCALE_ORG" --format json > role.json
export PGHOST=$(jq -r .access_host_url role.json) PGUSER=$(jq -r .username role.json) \
PGPASSWORD=$(jq -r .password role.json) PGPORT=5432 PGDATABASE=postgres PGSSLMODE=require
rm role.json
until psql -At -c "SELECT 1" >/dev/null 2>&1; do sleep 5; done
psql -At -c "SELECT version();"
Real output:
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) (Neki)
3. Dump the source and restore into Neki
Stop every writer on the source before this step and keep them stopped until cutover. The dump uses the custom format without ownership or privilege commands.
time pg_dump --format=custom --no-owner --no-privileges --file=source.dump "$SOURCE_DATABASE_URL"
ls -l source.dump | awk '{print $5, $9}'
Real output (the dump is about 1.1 MB for 120,000 rows):
real 0m0.175s
1108029 source.dump
Build a connection string for the Neki branch from the same role and restore. The user name and password are percent-encoded because they can contain characters that are not valid in a URI.
export NEKI_DATABASE_URL="postgresql://$(python3 -c "import urllib.parse,os;print(urllib.parse.quote(os.environ['PGUSER'],safe=''))"):$(python3 -c "import urllib.parse,os;print(urllib.parse.quote(os.environ['PGPASSWORD'],safe=''))")@$PGHOST:5432/postgres?sslmode=require"
time pg_restore --dbname="$NEKI_DATABASE_URL" --no-owner --no-privileges --exit-on-error --jobs=4 source.dump
For this dataset the restore printed no output and time reported real 0m8.730s. Any error line, or a non-zero exit status, means the restore did not complete.
The source URL sets sslmode=disable explicitly. The PGSSLMODE=require value exported for Neki in step 2 would otherwise apply to a local source that has no TLS, and pg_dump fails with server does not support SSL, but SSL was required. A sslmode set in the connection URL takes precedence over the environment variable, so pg_dump succeeds with both in place.
4. Validate the target
Reconnect with the Neki settings from step 2, run ANALYZE, and compare against the baselines from step 1.
psql "$NEKI_DATABASE_URL" -At <<'SQL'
ANALYZE;
SELECT 'customers', count(*), md5(string_agg(customer_id::text||email||profile::text, ',' ORDER BY customer_id)) FROM customers;
SELECT 'orders', count(*), md5(string_agg(order_id::text||customer_id::text||status::text||total_cents::text, ',' ORDER BY order_id)) FROM orders;
SELECT 'paid_orders view', count(*) FROM paid_orders;
SELECT 'enum labels', string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum;
SQL
Real output:
customers|20000|3c65c0429894cbb29f99a40cdd592244
orders|100000|a2e84a2792a3d0eb8d32259c71734477
paid_orders view|25000
enum labels|pending,paid,shipped,refunded
Both checksums, all three counts, and the enum label order match the source values from step 1.
5. Check identity and serial columns
Insert one row into each table and read the id it receives.
psql "$NEKI_DATABASE_URL" -At -c "INSERT INTO customers (email) VALUES ('new@example.com') RETURNING customer_id;" \
-c "INSERT INTO orders (customer_id, total_cents) VALUES (1, 100) RETURNING order_id;"
Real output (the INSERT 0 1 status lines are omitted):
20001
100001
The new ids continue from the source values: 20,001 follows 20,000 customers and 100,001 follows 100,000 orders.
Verify the result
Confirm that the imported data matches the source by comparing the orders checksum on both sides:
echo "source: $(psql "$SOURCE_DATABASE_URL" -At -c "SELECT md5(string_agg(order_id::text||customer_id::text||status::text||total_cents::text, ',' ORDER BY order_id)) FROM orders WHERE order_id <= 100000")"
echo "neki: $(psql "$NEKI_DATABASE_URL" -At -c "SELECT md5(string_agg(order_id::text||customer_id::text||status::text||total_cents::text, ',' ORDER BY order_id)) FROM orders WHERE order_id <= 100000")"
Expected: the two checksums are identical. The WHERE order_id <= 100000 filter excludes the row inserted in step 5, so the comparison still holds after that insert. A row count alone does not prove the contents match, which is why the check hashes the data.
How it works
pg_dump writes the schema and data as a custom-format archive and pg_restore applies it through the Neki router into the single authoritative shard, so the restored tables are unsharded. The restore runs CREATE statements first, so any object that already exists in the target stops it when --exit-on-error is set. Routers reserve sequence values in ranges, so a sequence's last_value on Neki runs ahead of the next value actually handed out. For the full import reference, see the Neki import guide.
Common issues
Restoring into a database that already has the objects
Running pg_restore a second time against the same Neki database stops on the first CREATE:
pg_restore: error: could not execute query: ERROR: type "order_status" already exists
Restore into a new, empty Neki database each time.
Sequence last_value differs from the source
After the inserts in step 5, SELECT last_value FROM customers_customer_id_seq returned 21000 on Neki and 20000 on the source, and orders_order_id_seq returned 101000 against 100000. Routers reserve ranges of 1,000 values, so compare the id that a new insert receives instead of last_value.
Foreign key counts include an internal constraint
SELECT count(*) FROM pg_constraint WHERE contype = 'f' returned 2 on Neki and 1 on the source. The extra row is __neki.repl_table_state.repl_table_state_repl_id_fkey, which belongs to Neki. Filter by schema, or compare conrelid::regclass values for your own tables.
Larger statements hit the 30 second statement timeout
SHOW statement_timeout returns 30s on Neki. A single INSERT ... SELECT of 200,000 generated rows was cancelled after about 30 seconds. The error was canceling statement due to user request in one run (psql 18.6, 31.0 seconds) and canceling statement due to statement timeout in another (psql 17.11, 30.2 seconds). 20,000-row inserts took between 12 and 21 seconds each. A 120,000-row pg_restore finished in 8.7 seconds, so use pg_restore or COPY for bulk loads.
Automated imports and external sources
PlanetScale's Platform Preview documentation states that automated Neki imports are not available and that connecting an external Postgres source to a migration workflow is not supported. This guide did not test those paths.
Next steps
- Reshard the imported tables onto more shards without downtime: see "Reshard a Live Table onto New Neki Shards with the PlanetScale CLI".
- Add a column to a large imported table with an online schema change: see "Run an Online Schema Change Across Neki Shards with the PlanetScale CLI".
- To try this on your own database, start with the Neki quickstart, then follow the Neki import guide for your source.
verification:
status: verified
tested_at: "2026-10-12"
product_version: "pscale 0.344.0; Neki PostgreSQL 18.6 (Platform Preview); source PostgreSQL 17.11"
command: "compare md5(string_agg(... ORDER BY order_id)) over orders WHERE order_id <= 100000 on the source and on Neki"
expected_result: "The source and Neki checksums are identical."