Migration toolkit for pgcopydb — used to migrate PostgreSQL databases to PlanetScale. These scripts run on a migration instance (EC2 or GCP Compute) that has network access to both the source PostgreSQL database and the PlanetScale target.
Permissions: The migration user needs access to all schemas and tables being migrated. For CDC migrations (--follow), it also needs the REPLICATION attribute. If you are using a dedicated migration user rather than the database owner, grant the following:
-- Create the migration user
CREATE ROLE migration_user WITH LOGIN PASSWORD 'your-strong-password' REPLICATION;
-- Connect and schema access
GRANT CONNECT ON DATABASE mydb TO migration_user;
GRANT USAGE ON SCHEMA public TO migration_user;
-- Read access to all tables and sequences
GRANT SELECT ON ALL TABLES IN SCHEMA public TO migration_user;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO migration_user;
-- For future tables created before migration starts
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO migration_user;
-- Prevent idle replication connection from being dropped during long COPY phases
ALTER ROLE migration_user SET wal_sender_timeout = 0;Repeat the GRANT USAGE, GRANT SELECT, and ALTER DEFAULT PRIVILEGES statements for each schema being migrated.
wal_sender_timeout: Setting this to 0 on the source prevents the replication slot from being dropped during long COPY phases. The initial data copy can take hours on large databases, and the default timeout (60s) may cause PostgreSQL to drop the idle replication connection before CDC streaming begins.
fix-replica-identity.sh permissions: The script runs ALTER TABLE ... REPLICA IDENTITY FULL on the source, which requires table ownership — SELECT alone is not sufficient. Grant migration_user membership in the role(s) that own the tables so it inherits ownership privileges. First, find which owner roles are involved:
-- Find roles that own tables without a primary key or unique index
SELECT DISTINCT r.rolname AS owner_role
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_roles r ON r.oid = c.relowner
WHERE c.relkind = 'r'
AND c.relreplident = 'd'
AND n.nspname NOT LIKE 'pg_%'
AND n.nspname != 'information_schema'
AND NOT EXISTS (
SELECT 1 FROM pg_constraint con
WHERE con.conrelid = c.oid
AND con.contype IN ('p', 'u')
);Then grant membership for each owner_role returned:
GRANT <owner_role> TO migration_user;After running fix-replica-identity.sh, revoke the membership:
REVOKE <owner_role> FROM migration_user;Row-Level Security (RLS) — Supabase and other RLS-heavy sources: If any source tables have RLS enabled, migration_user must bypass it — otherwise pgcopydb sees zero rows for those tables and the target ends up incomplete with no error. Check which tables use RLS (run per schema):
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = '<schema_name>'
AND rowsecurity = true;If any tables are returned, grant the bypass on the source (requires superuser):
ALTER ROLE migration_user BYPASSRLS;Logical replication (CDC only): Logical replication must be enabled on the source before starting a --follow migration. How to enable it depends on your platform:
-
Amazon RDS / Aurora: Set
rds.logical_replication = 1in the parameter group and reboot the instance. If you are using a custom parameter group, make sure it is associated with your instance. This change requires a reboot — it cannot be applied dynamically. -
Google Cloud SQL: Set the
cloudsql.logical_decodingdatabase flag toon. This requires an instance restart. You can set the flag via the Cloud Console,gcloud, or Terraform. If you are also using the pglogical extension, you must additionally setcloudsql.enable_pglogicaltoon. -
Google AlloyDB: Set the
alloydb.logical_decoding = ondatabase flag and restart the instance. See the AlloyDB documentation for details on setting database flags. -
Supabase: Logical replication is enabled by default —
wal_levelis already set tologicaland no configuration changes are needed. However, pgcopydb requires a direct connection (not a pooled connection). In your Supabase dashboard, go to Project Settings > Database to get the direct connection string. If your migration instance is outside Supabase's network, you will need to enable the IPv4 add-on to get a direct-connection-compatible hostname. -
Self-hosted PostgreSQL: Set
wal_level = logicalinpostgresql.confand restart PostgreSQL. Alternatively, useALTER SYSTEM SET wal_level = logical;followed by a restart.
You can verify logical replication is enabled on any platform with:
SHOW wal_level; -- should return 'logical'- The target database must be created before starting the migration — pgcopydb does not create databases.
- The PlanetScale Default user has all necessary permissions built in. No additional grants are required. See Managing Roles for details on users and roles.
- After the migration, you can tune target database settings from the PlanetScale dashboard. See Cluster configuration parameters for available options.
- Review the list of supported extensions to verify your source extensions are available on PlanetScale before migrating.
- Network access to both the source and target databases (typically deployed in the same VPC or peered network as the source).
- pgcopydb installed — built from the PlanetScale fork.
screeninstalled for running migrations in detached sessions.sqlite3installed for the monitoring scripts that read pgcopydb's internal catalogs.
-
Deploy these scripts to the migration instance home directory (
~/). -
Create
~/.envwith your connection strings:export PGCOPYDB_SOURCE_PGURI='postgresql://user:pass@source-host:5432/dbname' export PGCOPYDB_TARGET_PGURI='postgresql://user:pass@target-host:5432/dbname' export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...' # optional, for Slack alerts
-
Customize
~/filters.inito exclude schemas, tables, and extensions that should not be migrated. See Filter Configuration below. -
Make scripts executable:
chmod +x ~/*.sh
Before starting the migration, compare PostgreSQL parameters between source and target to identify differences that could affect performance. You can review and adjust target parameters in the PlanetScale dashboard — see Cluster configuration parameters for the full list of tunable settings. Note that many of the parameters on a PlanetScale cluster are already tuned appropriately and that copying directly from another provider may not bring benefit.
~/compare-pg-params.shRun the preflight check to validate that both databases and the migration instance are ready. This checks connectivity, WAL level, replication permissions, available slots/senders, conflicting publications, per-schema read permissions on objects that ~/filters.ini will actually migrate (schemas and tables outside that scope are skipped), extension compatibility (source extensions present on target), and local prerequisites:
~/preflight-check.shFix any FAILs before proceeding. Warnings should be reviewed — particularly wal_sender_timeout (set to 0 for large migrations) and leftover pgcopydb state from previous attempts.
If you are running a live migration with CDC (--follow), fix replica identity on tables that lack a primary key:
~/fix-replica-identity.shThis finds tables with default replica identity and no unique index, then sets them to REPLICA IDENTITY FULL on the source. You will be prompted to review and confirm before any changes are applied.
Start the migration inside a screen session so it survives SSH disconnects:
~/start-migration-screen.shThis runs run-migration.sh in a detached screen. To watch the live output:
screen -r migration # attach
# Ctrl-A D # detach (migration keeps running)Check overall migration progress (copy, indexes, constraints, vacuum) and see active operations (COPY, CREATE INDEX, VACUUM, ALTER TABLE) running on the target with their durations:
~/check-migration-status.shIf a COPY looks stalled or unusually slow during the initial copy, diagnose why on the target — blocking locks, wait events, competing vacuums, and whether data is still landing:
~/check-copy-stall.shIt is read-only and reports whether anything is actually lock-blocked (usually nothing is — a COPY waiting in ClientRead is waiting on the source feed, not the target) and whether autovacuum is throttling throughput.
Once the initial copy completes and CDC is streaming, check replication progress:
~/check-cdc-status.shWhen check-cdc-status.sh reports "CDC IS CAUGHT UP" (apply backlog < 100 MB), you are ready for cutover.
To receive Slack alerts for migration events (errors, initial copy completion, success/failure), set up the monitor separately. Requires SLACK_WEBHOOK_URL in ~/.env:
~/slack-migration-alerts.sh --test # verify webhook before installing
~/slack-migration-alerts.sh --setup # install cron job (default every 2 min)-
Stop writes to the source database (maintenance mode, read-only, connection drain, etc.).
-
Set the CDC endpoint — the script fetches the current WAL LSN from the source, displays it, and asks for confirmation before applying:
~/stop-cdc.sh MIGRATION_DIR=~/migration_YYYYMMDD-HHMMSS ~/stop-cdc.sh # explicit dir
-
Wait for pgcopydb to apply all remaining changes and exit. Monitor with
check-cdc-status.sh. -
Verify data on the target using
verify-migration.sh.~/verify-migration.shThe script reads
~/filters.iniand scopes its checks to the objects the migration actually copied, so schemas, tables, and extensions you excluded (for example Supabase'sauth,storage, andrealtimeschemas) are not reported as missing. Afilters.iniis required — verify must use the same scope the migration ran with — so the script errors out if one is not found; pass--filters <path>to use a file in a non-default location. -
Switch your application to the PlanetScale target.
After the migration is complete (or abandoned), clean up replication artifacts:
~/drop-replication-slots.sh # uses default slot name "pgcopydb"
~/drop-replication-slots.sh my_slot # custom slot nameThis drops the replication slot on the source, the replication origin on the target, and the pgcopydb sentinel schema. Always do this — unconsumed replication slots cause WAL to accumulate on the source until the disk fills up.
To halt a running migration immediately — for example when the source database is overloaded during the initial copy — use the emergency stop:
~/emergency-stop.sh # uses most recent migration dir
MIGRATION_DIR=~/migration_YYYYMMDD-HHMMSS ~/emergency-stop.sh # or specify explicitlyThis terminates pgcopydb and all of its workers at once (SIGTERM, escalating to SIGKILL on its own if needed) after prompting for confirmation and printing the consequences. It is stop-only: it does not drop the replication slot or touch the target, so the migration stays resumable with resume-migration.sh / resume-cdc.sh below.
If pgcopydb crashes, the instance reboots, or the migration is interrupted:
~/resume-migration.sh # uses most recent migration dir
MIGRATION_DIR=~/migration_YYYYMMDD-HHMMSS ~/resume-migration.sh # or specify explicitlyThis backs up the SQLite catalog before resuming and uses --not-consistent to allow resuming from a mid-transaction state. The script passes --split-tables-larger-than to match run-migration.sh — pgcopydb requires catalog consistency, so the resume must use the same split value as the original run.
If the initial COPY completed successfully but CDC was interrupted, you can resume only the CDC phase without re-attempting the clone:
~/resume-cdc.sh # uses most recent migration dir
MIGRATION_DIR=~/migration_YYYYMMDD-HHMMSS ~/resume-cdc.sh # or specify explicitlyThis runs pgcopydb follow directly (not clone --follow), skipping schema dump/restore, COPY, and index creation entirely. Use this when you know the data copy is complete and only CDC streaming needs to restart. Output is written to migration.log in the migration directory.
To start completely over, wipe the target and clean up replication:
~/target-clean.sh
~/drop-replication-slots.sh
~/start-migration-screen.shEvery migration needs a ~/filters.ini file to exclude objects that should not be copied. Use the filter to exclude source-specific schemas, tables, and extensions that are not needed on the target — particularly extensions not supported by PlanetScale. The file uses pgcopydb's filter syntax:
[exclude-schema]
schema_to_skip
[exclude-table]
public.table_to_skip
[exclude-extension]
extension_to_skip
[exclude-event-trigger]
trigger_to_skipAvailable sections: [exclude-schema], [exclude-table], [exclude-extension], [exclude-event-trigger], [include-only-schema], [include-only-table]. The include-only sections are mutually exclusive with the exclude sections — pgcopydb rejects include-only-table combined with exclude-schema/exclude-table, and include-only-schema combined with exclude-schema. preflight-check.sh emits a [WARN] if it detects one of these combinations.
Important: No comments are allowed inside sections — pgcopydb parses # lines as object names. Place all comments before the first section.
Shared parser: preflight-check.sh and verify-migration.sh both interpret ~/filters.ini through filters-lib.sh, a sourced helper library (not a standalone script). Keeping the parsing and scope logic in one place ensures both scripts honor the filter identically — preflight scopes its permission checks to what will be migrated, and verify scopes its comparison to what was migrated.
Amazon RDS:
[exclude-extension]
rds_tools
pg_repackSupabase:
[exclude-schema]
auth
supabase_functions
storage
cron
realtime
supabase_migrations
net
_supabase
graphql
graphql_public
[exclude-extension]
pg_net
pg_graphql
pg_repack
http
pg_stat_monitor
pgstattuple
[exclude-event-trigger]
supabase_functions.hooksGoogle AlloyDB:
[exclude-schema]
ai
google_ml
helpobj
perfsnap
pgsnap
[exclude-extension]
g_stats
google_columnar_engine
google_db_advisor
google_ml_integrationThe migration scripts have tunable parameters at the top of each file:
| Parameter | Default | Description |
|---|---|---|
TABLE_JOBS |
16 | Parallel COPY workers |
INDEX_JOBS |
12 | Parallel index creation workers |
--split-tables-larger-than |
50GB | Threshold for splitting large tables into parts |
--split-max-parts |
Same as TABLE_JOBS | Maximum number of parts per split table |
Adjust these based on your instance size and database characteristics. More jobs require more CPU cores and memory. A good baseline for TABLE_JOBS is fewer than the vCPU count of whichever is smaller — the SOURCE or TARGET. INDEX_JOBS should be fewer than the vCPUs on the TARGET. Exceeding these numbers can overwhelm the SOURCE during the COPY phase or the TARGET during index rebuilding. See Cluster configuration parameters for understanding target-side capacity.
The migration log at ~/migration_*/migration.log is the most valuable troubleshooting resource. It contains the full pgcopydb output including connection info, step-by-step progress with timestamps, table split decisions, extension filtering results, per-table COPY statistics, and full error details.
Every completed migration ends with a summary table showing wall clock and cumulative duration for each phase, total data transferred, and concurrency:
Step Connection Duration Transfer Concurrency
-------------------------------------------------- ---------- ---------- ---------- ------------
Catalog Queries (table ordering, filtering, etc) source 1s993 1
Dump Schema source 224ms 1
Prepare Schema target 755ms 1
COPY, INDEX, CONSTRAINTS, VACUUM (wall clock) both 53m52s 44
COPY (cumulative) both 1h15m 435 GB 16
CREATE INDEX (cumulative) target 1h27m 12
CONSTRAINTS (cumulative) target 830ms 12
VACUUM (cumulative) target 12m39s 16
Reset Sequences both 62ms 1
Finalize Schema both 48s 12
-------------------------------------------------- ---------- ---------- ---------- ------------
Total Wall Clock Duration both 54m42s 60
Useful searches in the log:
grep ERROR migration.log— find failuresgrep "errors ignored on restore" migration.log— pg_restore error countgrep -i split migration.log— table partitioning decisionsgrep s_depend migration.log— extension filtering verification- Check the last line for
Exit code: 0(success) or non-zero (failure)
pgcopydb tracks all migration state in SQLite databases inside the migration directory. Several of the monitoring scripts (check-migration-status.sh, check-cdc-status.sh, stop-cdc.sh) read directly from these catalogs.
schema/source.db— Primary tracking database with per-table timing, bytes transferred, index/constraint progress, and CDC sentinel state (replay_lsn,write_lsn,endpos).schema/filter.db— Extension filtering state. Thes_dependtable must have rows after STEP 1 or extension-owned objects won't be filtered.
# Which tables are still copying?
sqlite3 ~/migration_*/schema/source.db \
"SELECT nspname, relname, bytes FROM summary
WHERE tableoid IS NOT NULL AND done_time_epoch IS NULL;"
# Largest tables and how long they took
sqlite3 ~/migration_*/schema/source.db \
"SELECT nspname, relname, bytes, (done_time_epoch - start_time_epoch) as secs
FROM summary WHERE tableoid IS NOT NULL AND done_time_epoch IS NOT NULL
ORDER BY bytes DESC LIMIT 20;"
# CDC sentinel state (apply position, streaming position, endpos)
sqlite3 ~/migration_*/schema/source.db "SELECT * FROM sentinel;"
# Verify extension filtering worked
sqlite3 ~/migration_*/schema/filter.db "SELECT COUNT(*) FROM s_depend;"| Script | Phase | Description |
|---|---|---|
compare-pg-params.sh |
Prepare | Compare PostgreSQL parameters between source and target |
preflight-check.sh |
Prepare | Validate migration prerequisites (connectivity, WAL level, permissions, slots, extension compatibility) |
fix-replica-identity.sh |
Prepare | Set REPLICA IDENTITY FULL on tables without primary keys |
filters.ini |
Prepare | pgcopydb filter configuration |
filters-lib.sh |
Prepare | Shared filters.ini parser sourced by preflight-check.sh and verify-migration.sh (not run directly) |
run-migration.sh |
Migrate | Start a pgcopydb clone --follow migration |
start-migration-screen.sh |
Migrate | Run the migration in a detached screen session. |
check-migration-status.sh |
Monitor | Migration progress dashboard |
check-copy-stall.sh |
Monitor | Diagnose a stalled/slow COPY on the target (locks, waits, vacuums, throughput) |
check-cdc-status.sh |
Monitor | CDC replication progress and health |
slack-migration-alerts.sh |
Monitor | Slack alerts |
emergency-stop.sh |
Recovery | Immediately stop a running migration and all subprocesses |
resume-migration.sh |
Recovery | Resume an interrupted migration (full clone + CDC) |
resume-cdc.sh |
Recovery | Resume only the CDC phase (skips clone) |
target-clean.sh |
Recovery | Wipe target database for re-migration (prompts for confirmation) |
drop-replication-slots.sh |
Cleanup | Remove replication slots and origins |
stop-cdc.sh |
Cutover | Set CDC endpoint via SQLite to initiate cutover |
verify-migration.sh |
Cutover | Verify schema and data consistency between source and target |
- Do not use
pgcopydb --restart— it wipes the CDC directory and SQLite catalogs without cleaning the target database or correcting previous failures. To start over, use~/target-clean.sh+~/drop-replication-slots.sh+~/start-migration-screen.shinstead. - Always clean up replication slots when done — unconsumed slots cause unbounded WAL growth on the source.
- Verify extension filtering after STEP 1 — if
s_dependcount is 0, extension-owned objects won't be excluded.