PGCOPYDB FORK(1)

🌸 PGCOPYDB FORK(1) β€” the manual page, but make it make sense 🌸

okay so bestie, this is the manual page for PGCOPYDB FORK(1) πŸ’– it's all about pgcopydb fork and its identical twin pgcopydb clone. Sit down, hydrate, and let's go ✨

✨ NAME ✨

pgcopydb fork β€” pgcopydb fork πŸ’…

The main pgcopydb operation is the clone operation, and for historical reasons and general user friendliness, two aliases are available that implement exactly the same operation:

  pgcopydb
    clone     Clone an entire database from source to target
    fork      Clone an entire database from source to target

πŸš€ PGCOPYDB CLONE

The command pgcopydb clone copies a database from the given source Postgres instance to the target Postgres instance. Iconic behaviour, truly.

  pgcopydb clone: Clone an entire database from source to target
  usage: pgcopydb clone  --source ... --target ... [ --table-jobs ... --index-jobs ... ]

    --source                      Postgres URI to the source database
    --target                      Postgres URI to the target database
    --dir                         Work directory to use
    --table-jobs                  Number of concurrent COPY jobs to run
    --index-jobs                  Number of concurrent CREATE INDEX jobs to run
    --restore-jobs                Number of concurrent jobs for pg_restore
    --large-objects-jobs          Number of concurrent Large Objects jobs to run
    --split-tables-larger-than    Same-table concurrency size threshold
    --split-max-parts             Maximum number of jobs for Same-table concurrency
    --estimate-table-sizes        Allow using estimates for relation sizes
    --drop-if-exists              On the target database, clean-up from a previous run first
    --roles                       Also copy roles found on source to target
    --no-role-passwords           Do not dump passwords for roles
    --no-owner                    Do not set ownership of objects to match the original database
    --no-acl                      Prevent restoration of access privileges (grant/revoke commands).
    --no-comments                 Do not output commands to restore comments
    --no-tablespaces              Do not output commands to select tablespaces
    --skip-large-objects          Skip copying large objects (blobs)
    --skip-extensions             Skip restoring extensions
    --skip-ext-comments           Skip restoring COMMENT ON EXTENSION
    --skip-collations             Skip restoring collations
    --skip-vacuum                 Skip running VACUUM ANALYZE
    --skip-analyze                Skip running vacuumdb --analyze-only
    --skip-db-properties          Skip copying ALTER DATABASE SET properties
    --skip-split-by-ctid          Skip spliting tables by ctid
    --requirements <filename>     List extensions requirements
    --filters <filename>          Use the filters defined in <filename>
    --fail-fast                   Abort early in case of error
    --restart                     Allow restarting when temp files exist already
    --resume                      Allow resuming operations after a failure
    --not-consistent              Allow taking a new snapshot on the source database
    --snapshot                    Use snapshot obtained with pg_export_snapshot
    --follow                      Implement logical decoding to replay changes
    --plugin                      Output plugin to use (pgoutput, test_decoding, wal2json)
    --wal2json-numeric-as-string  Print numeric data type as string when using wal2json output plugin
    --slot-name                   Use this Postgres replication slot name
    --create-slot                 Create the replication slot
    --origin                      Use this Postgres replication origin node name
    --endpos                      Stop replaying changes when reaching this LSN
    --use-copy-binary             Use the COPY BINARY format for COPY operations
    --all-databases               Clone all databases found on the source instance

πŸ¦‹ PGCOPYDB FORK

The command pgcopydb fork copies a database from the given source Postgres instance to the target Postgres instance. That's it. That's the whole thing. This command is literally an alias to the command pgcopydb clone we just looked at above. no cap πŸ’…


πŸŽ€ DESCRIPTION πŸŽ€

The pgcopydb clone command implements both a base copy of a source database into a target database and also a full Logical Decoding client (docs: https://www.postgresql.org/docs/current/logicaldecoding.html). The default plugin is pgoutput (docs: https://www.postgresql.org/docs/current/protocol-logical-replication.html), which has been built into PostgreSQL core since version 10. The wal2json plugin (https://github.com/eulerto/wal2json/) and the test_decoding plugin (https://www.postgresql.org/docs/current/test-decoding.html) are also supported. so many options, we love choices πŸ’–

🧱 Base copy, or the clone operation

The pgcopydb clone command implements the following steps πŸ‘‘:

  1. pgcopydb gets the list of ordinary and partitioned tables from a catalog query on the source database, and also the list of indexes, and the list of sequences with their current values. ✨

When filtering is used, the list of objects OIDs that are meant to be filtered out is built during this step.

  1. pgcopydb calls into pg_dump to produce the pre-data section and the post-data</c> sections of the dump using Postgres custom format.
  2. The pre-data section of the dump is restored on the target database using the pg_restore command, creating all the Postgres objects from the source database into the target database.

When filtering is used, the pg_restore --use-list feature is used to filter the list of objects to restore in this step.

This step uses as many as --restore-jobs jobs for pg_restore to share the workload and restore the objects in parallel.

  1. Then as many as --table-jobs COPY sub-processes are started to share the workload and COPY the data from the source to the target database one table at a time, in a loop. πŸ”

A Postgres connection and a SQL query to the Postgres catalog table pg_class is used to get the list of tables with data to copy around, and the reltuples statistic is used to start with the tables with the greatest number of rows first, as an attempt to minimize the copy time. big brain energy fr fr.

  1. An auxiliary process loops through all the Large Objects found on the source database and copies its data parts over to the target database, much like pg_dump itself would.

This step is much like pg_dump | pg_restore for large objects data parts, except that there isn't a good way to do just that with the tooling. πŸ’€

  1. As many as --index-jobs CREATE INDEX sub-processes are started to share the workload and build indexes. In order to make sure to start the CREATE INDEX commands only after the COPY operation has completed, a queue mechanism is used. As soon as a table data COPY has completed, all the indexes for the table are queued for processing by the CREATE INDEX sub-processes.

The primary indexes are created as UNIQUE indexes at this stage.

  1. Then the PRIMARY KEY constraints are created USING the just built indexes. This two-steps approach allows the primary key index itself to be created in parallel with other indexes on the same table, avoiding an EXCLUSIVE LOCK while creating the index. slay honestly πŸ”₯
  2. As many as --table-jobs VACUUM ANALYZE sub-processes are started to share the workload. As soon as a table data COPY has completed, the table is queued for processing by the VACUUM ANALYZE sub-processes.
  3. An auxilliary process loops over the sequences on the source database and for each of them runs a separate query on the source to fetch the last_value and the is_called metadata the same way that pg_dump does.

For each sequence, pgcopydb then calls pg_catalog.setval() on the target database with the information obtained on the source database.

  1. The final stage consists now of running the pg_restore command for the post-data section script for the whole database, and that's where the foreign key constraints and other elements are created. πŸŽ‰

The post-data script is filtered out using the pg_restore --use-list option so that indexes and primary key constraints already created in steps 6 and 7 are properly skipped now.

This step uses as many as --restore-jobs jobs for pg_restore to share the workload and restore the objects in parallel.

πŸ” Postgres privileges, superuser, and dump and restore

Postgres has a notion of a superuser status that can be assigned to any role in the system, and the default role postgres has this status. From the Role Attributes documentation page (docs: https://www.postgresql.org/docs/current/role-attributes.html) we see that:

superuser status: A database superuser bypasses all permission checks, except the right to log in. This is a dangerous privilege and should not be used carelessly; it is best to do most of your work as a role that is not a superuser. To create a new database superuser, use CREATE ROLE name SUPERUSER. You must do this as a role that is already a superuser.
⚠️ bestie, that's a dangerous privilege. Do not use it carelessly. Periodt.

Some Postgres objects can only be created by superusers, and some read and write operations are only allowed to superuser roles, such as the following non-exclusive list:

  • Reading the pg_authid role password (docs: https://www.postgresql.org/docs/current/catalog-pg-authid.html) β€” even when encrypted β€” is restricted to roles with the superuser status. Reading this catalog table is done when calling pg_dumpall --roles-only so that the dump file can then be used to restore roles including their passwords.

It is possible to implement a pgcopydb migration that skips the passwords entirely when using the option --no-role-passwords. In that case though authentication might fail until passwords have been setup again correctly. 😭

  • Most of the available Postgres extensions, at least when being written in C, are then only allowed to be created by roles with superuser status.

When such an extension contains Extension Configuration Tables (docs: https://www.postgresql.org/docs/current/extend-extensions.html#EXTEND-EXTENSIONS-CONFIG-TABLES) and has been created with a role having superuser status, then the same superuser status is needed again to pg_dump and pg_restore that extension and its current configuration. πŸ’…

When using pgcopydb it is possible to split your migration in privileged and non-privileged parts, like in the following examples:

  $ coproc ( pgcopydb snapshot )

  # first two commands would use a superuser role to connect
  $ pgcopydb copy roles --source ... --target ...
  $ pgcopydb copy extensions --source ... --target ...
# now it's possible to use a non-superuser role to connect
$ pgcopydb clone --skip-extensions --source ... --target ...

$ kill -TERM ${COPROC_PID}
$ wait ${COPROC_PID}

so in a script like that, the pgcopydb copy roles and pgcopydb copy extensions calls get done with connection strings that log in as a role that has superuser status, and then the pgcopydb clone call gets done with a non-privileged role β€” usually the role that owns the source and target databases. cute, right? πŸ’…

⚠️ Warning: that said, there's currently a limitation in pg_dump that impacts pgcopydb, and it's kind of a jump scare. When an extension with a configuration table has been installed as superuser, even the main pgcopydb clone operation has to be done with superuser status. 😭 Why? Because pg_dump filtering (here, that --exclude-table option) does not apply to extension members, and pg_dump does not provide a mechanism to exclude extensions. so yeah. no loophole. bestie, that's just how it is.

✨ Extension-aware migration ✨

pgcopydb lowkey snoops the source catalog for certain extensions and then automatically runs extension-specific steps at exactly the right point in the pipeline. no configuration needed, no cap: the behaviour is triggered by the mere presence of the extension in the source database. πŸ¦‹

but! if an extension is listed under [exclude-extension] in the pgcopydb configuration filter file, it is never fetched into the catalog at all, so its hook gets skipped entirely. gone, not invited. πŸ’€

🌊 TimescaleDB

when timescaledb is detected in the source database, pgcopydb calls two functions on the target database:

  • Before pg_restore --pre-data: timescaledb_pre_restore() puts TimescaleDB into restore mode so it does not interfere with schema restore. πŸ›‘
  • After pg_restore --post-data: timescaledb_post_restore() re-enables normal TimescaleDB operation. βœ…

both the source and the target databases must have the TimescaleDB extension installed. both. no skipping. πŸ‘‘

🐝 Citus

when citus is detected in the source database, pgcopydb queries the citus_tables view on the source coordinator and, after pg_restore --pre-data (at which point tables exist on the target as empty plain PostgreSQL tables), calls create_distributed_table() and create_reference_table() on the target coordinator.

Co-location groups are preserved: within each group the alphabetically first table is created with shard_count := N, colocate_with := 'none' and the remaining tables reference it via colocate_with. Reference tables are always created after all distributed tables.

Data is then copied through the Citus coordinator, which routes rows to the correct shards transparently β€” shard tables are never visible to pgcopydb. πŸ‘» Both the source and the target must be Citus coordinators (multi-worker clusters are fully supported).

πŸ—‚οΈ Cloning all databases with --all-databases

when you use the --all-databases option, pgcopydb clone enumerates all non-template user databases on the source Postgres instance and clones each of them to the target instance in a single invocation. one command, all the databases, iconic. ✨

the source and target URIs must point to the instance level (e.g. postgres://host/postgres) rather than to a specific application database. pgcopydb derives per-database connection strings by substituting the database name component.

the operation is structured in three sequential phases:

  1. Phase I β€” snapshot export and pre-data: Roles are copied once at the instance level. A snapshot holder subprocess exports one consistent REPEATABLE READ snapshot per source database and holds those transactions open. A pool of --table-jobs pre-data workers then processes the databases in parallel, running schema fetch, pg_dump --pre-data, and pg_restore --pre-data for each.
  2. Phase II β€” global COPY, indexes, and VACUUM: All tables across all databases are sorted by size (largest first) and processed by a single shared pool of workers β€” --table-jobs COPY workers, --index-jobs index/constraint workers, and --table-jobs VACUUM workers. The pools are not allocated per-database; they serve the entire workload globally. 🌍
  3. Phase III β€” post-data: Databases are finalized one at a time: pg_restore --post-data applies remaining objects (triggers, rules, comments, etc.) and sequences are reset to match the source. πŸŽ€

a consolidated summary is printed at the end aggregating timings across all databases. so you get the receipts. πŸ’…

for details about the process tree and the cross-database COPY queue, go read Cloning all databases: the --all-databases option.

πŸ”„ Change Data Capture using Postgres Logical Decoding

when you use the --follow option, the steps from the pgcopydb follow command are also run concurrently to the main copy. The Change Data Capture is then automatically driven from a prefetch-only phase to the prefetch-and-catchup phase, which is enabled as soon as the base copy is done. seamless, fr fr. 🌈

peep the command pgcopydb stream sentinel set endpos to remote control the follow parts of the command even while the command is already running. yes, you can poke it while it's mid-run, main character energy. πŸ‘‘

the command pgcopydb stream cleanup must be used to free resources created to support the change data capture process.

πŸ”₯ Important: make sure to read the documentation for pgcopydb follow and the specifics about Logical Replication Restrictions as documented by Postgres. don't skip the homework, bestie.

πŸ“ Change Data Capture Example 1

a simple approach to applying changes after the initial base copy has been done follows:

$ pgcopydb clone --follow &

# later when the application is ready to make the switch
$ pgcopydb stream sentinel set endpos --current

# later when the migration is finished, clean-up both source and target
$ pgcopydb stream cleanup

🧚 Change Data Capture Example 2

sometimes you need more control over some of the steps taken here. given pgcopydb flexibility, it's possible to implement the following steps:

  1. Grab a snapshot from the source database and hold an open Postgres connection for the duration of the base copy. In case of crash or other problems with the main operations, it's then possible to resume processing of the base copy and the applying of the changes with the same snapshot again. This step is also implemented when using pgcopydb clone --follow. That said, if the command was interrupted (or crashed), then the snapshot would be lost. 😭
  2. Setup the logical decoding within the snapshot obtained in the previous step, and the replication tracking on the target database. The following SQL objects are then created:
    • a replication slot on the source database,
    • a replication origin on the target database. This step is also implemented when using pgcopydb clone --follow. There is no way to implement Change Data Capture with pgcopydb and skip creating those SQL objects.
  3. Start the base copy of the source database, and prefetch logical decoding changes to ensure that we consume from the replication slot and allow the source database server to recycle its WAL files.
  4. Remote control the apply process to stop consuming changes and applying them on the target database.
  5. Re-sync the sequences to their now-current values. Sequences are not handled by Postgres logical decoding, so extra care needs to be implemented manually here.
πŸ’œ Important: The next version of pgcopydb will include that step in the pgcopydb clone --snapshot command automatically, after it stops consuming changes and before the process terminates.
  1. Clean-up the specific resources created for supporting resumability of the whole process (replication slot on the source database, replication origin on the target database).
  2. Stop holding a snaphot on the source database by stopping the pgcopydb snapshot process left running in the background.

if the command pgcopydb clone --follow fails it's then possible to start it again. It will automatically discover what was done successfully and what needs to be done again because it failed or was interrupted (table copy, index creation, resuming replication slot consuming, resuming applying changes at the right LSN position, etc). glow up: it self-heals. 🫢

here's an example implement the previous steps:

$ pgcopydb snapshot &

$ pgcopydb stream setup

$ pgcopydb clone --follow &

# later when the application is ready to make the switch
$ pgcopydb stream sentinel set endpos --current

# when the follow process has terminated, re-sync the sequences
$ pgcopydb copy sequences

# later when the migration is finished, clean-up both source and target
$ pgcopydb stream cleanup

# now stop holding the snapshot transaction (adjust PID to your environment)
$ kill %1

πŸ’… OPTIONS πŸ’…

the following options are available to pgcopydb clone:

  • --source β€” Connection string to the source Postgres instance. See the Postgres documentation for connection strings for the details. In short both the quoted form "host=... dbname=..." and the URI form postgres://user@host:5432/dbname are supported. πŸŽ€
  • --target β€” Connection string to the target Postgres instance. πŸ’–
  • --dir β€” During its normal operations pgcopydb creates a lot of temporary files to track sub-processes progress. Temporary files are created in the directory specified by this option, or defaults to ${TMPDIR}/pgcopydb when the environment variable is set, or otherwise to /tmp/pgcopydb. πŸ—‚οΈ
  • --table-jobs β€” How many tables can be processed in parallel. This limit only applies to the COPY operations, more sub-processes will be running at the same time that this limit while the CREATE INDEX operations are in progress, though then the processes are only waiting for the target Postgres instance to do all the work. lowkey just vibing while the target does everything. πŸ™ˆ
  • --index-jobs β€” How many indexes can be built in parallel, globally. A good option is to set this option to the count of CPU cores that are available on the Postgres target system, minus some cores that are going to be used for handling the COPY operations. ⚑
  • --restore-jobs β€” How many threads or processes can be used during pg_restore. A good option is to set this option to the count of CPU cores that are available on the Postgres target system. πŸ‘‘

If that value isn't set, we just reuse whatever you passed to --index-jobs instead πŸ’«. And if that value isn't set either, we fall back to the default value for --index-jobs. Nested defaults, she contains multitudes.


  • --large-object-jobs β€” How many worker processes to spin up so Large Objects get copied concurrently 🫢. Bestie loves a parallel blob moment.
  • --split-tables-larger-than β€” Lets Same-table Concurrency happen when we're processing the source database. The value you hand over is expected to be a byte size, and the byte units it knows about are B, kB, MB, GB, TB, PB, and EB. So like… 1GB, 500MB, go wild, it knows the units πŸ’….
  • --estimate-table-sizes β€” Use estimates on table sizes to decide how to split tables when using Same-table Concurrency.

When you use this one, we run the command vacuumdb --analyze-only --jobs=<table-jobs> on the source database, and that updates the statistics for the number of pages of each relation. Then we take the number of pages plus the size of each page and estimate the actual size of the tables. Math but make it migration ✨.

And if you'd rather run the ANALYZE command manually before running pgcopydb, you can use the --skip-analyze option instead β€” that way you can cut down the time spent on the migration πŸ‘‘.

  • --drop-if-exists β€” When restoring the schema on the target Postgres instance, pgcopydb is actually using pg_restore under the hood. When you specify this option, these pg_restore options get used too: --clean --if-exists.

This is the move when you run the same command over and over β€” to fix a previous oopsie, or like, when it's running inside a continuous integration system πŸ”.

⚠️ This option causes DROP TABLE and DROP INDEX and other DROP commands to be used. Make sure you understand what you're doing here! Bestie. For real. No cap. πŸ’€
  • --roles β€” This one adds a preliminary step that copies the roles found on the source instance over to the target instance. And because Postgres roles are global objects β€” they don't live only inside one specific database's little world β€” all the roles get copied over when you use this option 🌈.

The command pg_dumpall --roles-only is used to fetch the list of roles from the source database, and that command includes support for passwords. Which means ✨ as a result ✨ this operation requires superuser privileges.

See also pgcopydb copy roles.

  • --no-role-passwords β€” Do not dump passwords for roles πŸ’œ. When restored, the roles will have a null password, and password authentication will always fail until the password is set. Since password values aren't needed when you specify this option, the role information gets read from the catalog view pg_roles instead of pg_authid. So bonus: this option also helps if access to pg_authid is restricted by some security policy πŸŽ€.
  • --no-owner β€” Do not output commands to set ownership of objects to match the original database πŸ™ˆ. By default, pg_restore issues ALTER OWNER or SET SESSION AUTHORIZATION statements to set ownership of created schema elements. Those statements will fail unless the initial connection to the database is made by a superuser (or the same user that owns all of the objects in the script).

With --no-owner, any user name can be used for the initial connection, and that user will own all the created objects. Simple. Iconic. ✨

  • --skip-large-objects β€” Skip copying large objects, also known as blobs, when copying the data from the source database to the target database πŸ¦‹.
  • --skip-extensions β€” Skip copying extensions from the source database to the target database.

When you use this, the schemas that extensions depend-on get skipped too β€” the vibe is that creating the needed extensions on the target system becomes the responsibility of another command (such as pgcopydb copy extensions), and the schemas that extensions depend-on are part of that responsibility πŸ’….

And because creating extensions requires superuser, this unlocks a multi-step approach: extensions get dealt with using superuser privileges, and then the rest of the pgcopydb operations happen without superuser privileges πŸ‘‘.

  • --skip-ext-comments β€” Skip copying COMMENT ON EXTENSION commands. This is implicit when you're using --skip-extensions πŸŽ€.
  • --requirements <filename> β€” This option lets you specify which version of an extension to install on the target database. The filename you give is expected to be a JSON file, and the JSON contents must be an array of objects with the keys "name" and "version" ⭐.

The command pgcopydb list extensions --requirements --json produces exactly such a JSON file, and you can use it on the target database instance to get started 🫢.

See also the command pgcopydb list extensions --available-versions.

See also pgcopydb list extensions.

  • --skip-collations β€” Skip copying collations from the source database to the target database πŸ’œ.

Sometimes the list of collations your Operating System provides on the source system and the target system are just… different 😭, and then a mapping needs to be manually installed before calling pgcopydb.

So this option lets pgcopydb skip over collations and assume all the needed collations have already been deployed on the target database. Trust fall 🀝.

See also pgcopydb list collations.

  • --skip-vacuum β€” Skip running VACUUM ANALYZE on the target database once a table has been copied, its indexes have been created, and constraints installed ✨.
  • --skip-analyze β€” Skip running vacuumdb --analyze-only on the source database to update the statistics that are required when estimating table sizes.

This option is only useful when you're using --estimate-table-sizes and you run the relevant ANALYZE command manually before running pgcopydb πŸŽ€.

  • --skip-db-properties β€” Skip fetching database properties and copying them using the SQL command ALTER DATABASE ... SET name = value πŸ’….

This is useful when:

  • the source and target database have a different set of properties, or
  • the target database is hosted in a way that disabled setting some of the properties that have been set on the source database, or
  • copying these settings is just not wanted πŸ™ˆ.
  • --skip-split-by-ctid β€” Skip splitting tables based on CTID during the copy operation πŸ¦‹. By default, pgcopydb splits large tables into smaller chunks based on the CTID column if there isn't a unique integer column in the table. But in some cases you may want to skip this splitting process β€” like when the CTID range scan is slow in the underlying system 😭.
  • --filters <filename> β€” This option lets you exclude tables and indexes from the copy operations. See Filtering for the details about the expected file format and the filtering options available πŸŽ€.
  • --fail-fast β€” Abort early in case of error by sending the TERM signal to all the processes in the pgcopydb process group πŸ’€. No waiting around, we're out.
  • --restart β€” When you run the pgcopydb command again, if the work directory already contains information from a previous run, then the command refuses to proceed and refuses to delete information that might be used for diagnostics and forensics πŸ”.

In that case, the --restart option can be used to allow pgcopydb to delete traces from a previous run. Fresh start energy ✨.

  • --resume β€” When the pgcopydb command was terminated before completion β€” either by an interrupt signal (such as C-c or SIGTERM) or because it crashed 😭 β€” it's possible to resume the database migration.

When resuming activity from a previous run, table data that was fully copied over to the target server is not sent again πŸ™…. But table data that was interrupted during the COPY has to be started from scratch even when using --resume: the COPY command in Postgres is transactional and it was rolled back. Facts are facts.

Same reasoning applies to the CREATE INDEX commands and ALTER TABLE commands that pgcopydb issues β€” those commands are skipped on a --resume run only if they're known to have run through to completion on the previous one πŸ‘‘.

🌸 Finally, using --resume requires the use of --not-consistent. Periodt.

Per the Postgres documentation about pg_export_snapshot:

Saves the transaction's current snapshot and returns a text string identifying the snapshot. This string must be passed (outside the database) to clients that want to import the snapshot. The snapshot is available for import only until the end of the transaction that exported it.

Now here's the thing bestie β€” when the pgcopydb process was interrupted (or crashed) on a previous run, it's possible to resume operations, but the snapshot that was exported does not exist anymore 😭. The pgcopydb command can only resume operations with a new snapshot, and thus can not ensure consistency of the whole data set, because each run is now using its own snapshot. That's the whole tradeoff, iykyk 🀝.

  • --snapshot β€” Instead of exporting its own snapshot by calling the PostgreSQL function pg_export_snapshot(), it's possible for pgcopydb to re-use an already exported snapshot ⭐.
  • --follow β€” When the --follow option is used, pgcopydb implements Change Data Capture, as detailed in the manual page for pgcopydb follow, in parallel to the main copy database steps πŸ”₯.

The replication slot is created using the same snapshot as the main database copy operation, and the changes to the source database are prefetched only during the initial copy, then prefetched and applied in a catchup process πŸ¦‹.

It's possible to give pgcopydb clone --follow a termination point (the LSN endpos) while the command is running, with the command pgcopydb stream sentinel set endpos ✨.

  • --plugin β€” which logical decoding output plugin are we even using, bestie? The default is pgoutput (<https://www.postgresql.org/docs/current/protocol-logical-replication.html>), and the way that it's literally built into PostgreSQL core (since Postgres 10) means you do not need to install any extension on the source server. Using pgoutput saves you from needing a superuser to install an extension, and it plays nice with any PostgreSQL publication-based setup. Iconic behavior, honestly. πŸ‘‘

You can also use test_decoding (<https://www.postgresql.org/docs/current/test-decoding.html>) or wal2json (<https://github.com/eulerto/wal2json/>). Both are still supported for backwards compatibility, so no, they didn't get ghosted. 🫢

  • --wal2json-numeric-as-string β€” when you're using the wal2json output plugin, this option tells wal2json to output numeric values as strings, which prevents some precision loss. Precision loss is giving "we lost data in the group chat", and we are not doing that. 🚫
Heads up: you need a wal2json plugin version on the source database that supports the --numeric-data-types-as-string option to actually use this one. Check the compatibility, bestie.

See also the documentation for wal2json (<https://github.com/eulerto/wal2json/pull/255>) about this option for the details, iykyk.

  • --slot-name β€” the logical decoding slot name to use. It defaults to pgcopydb, which is lowkey unfortunate when your use-case involves migrating more than one database from the source server. πŸ’€ Like babe, pick your own name, you deserve it.
  • --create-slot β€” instructs pgcopydb to create the logical replication slot to use. Manifesting the slot into existence ✨.
  • --endpos β€” the logical replication target LSN to use. It automatically stops replication and exits with normal exit status 0 when receiving reaches the specified LSN. And if there's a record with LSN exactly equal to lsn, that record will be output. No cap.
⚠️ Warning, and this one matters: the --endpos option is not aware of transaction boundaries and may truncate output partway through a transaction. Any partially output transaction will not be consumed and will be replayed again when the slot is next read from. Individual messages are never truncated. So like… it's not sloppy, it's just built different. But respect the boundary thing fr fr.

See also documentation for pg_recvlogical (<https://www.postgresql.org/docs/current/app-pgrecvlogical.html>).

  • --use-copy-binary β€” use the COPY WITH (FORMAT BINARY) instead of the COPY command. Binary mode, main character energy 🩷.

See also documentation for COPY (<https://www.postgresql.org/docs/current/sql-copy.html>).

  • --origin β€” the logical replication target system needs to track the transactions that have already been applied, so that if we get disconnected or need to resume operations, we can skip already replayed transactions. That's just good bookkeeping, bestie, we do keep receipts.

Postgres uses a notion of an origin node name as documented in Replication Progress Tracking (<https://www.postgresql.org/docs/current/replication-origins.html>). This option lets you pick your own node name, and it defaults to "pgcopydb". Picking a different name is useful in some advanced scenarios, like migrating several sources into the same target, where each source should have their own unique origin node name. One name per source, no sharing handles. πŸ’…

  • --verbose, --notice β€” increase current verbosity. The default level of verbosity is INFO. In ascending order, pgcopydb knows about the following verbosity levels: FATAL, ERROR, WARN, INFO, NOTICE, SQL, DEBUG, TRACE. Let her talk! πŸ—£οΈ
  • --debug β€” set current verbosity to DEBUG level. For when you need every single detail of the lore.
  • --trace β€” set current verbosity to TRACE level. Maximum yapping, and honestly? Obsessed.
  • --quiet β€” set current verbosity to ERROR level. Shhh era. 🀫

🌸 ENVIRONMENT 🌸

okay bestie so pgcopydb loves a good environment variable β€” every single one of these is just a lil backup plan for when you don't feel like typing the flag on the command line πŸ’… and honestly? relatable. here's the whole roster, no skips, no cap ✨

  • PGCOPYDB_SOURCE_PGURI β€” the connection string for your source Postgres instance. when --source is left off the command line, this is the one that gets used 🫢
  • PGCOPYDB_TARGET_PGURI β€” connection string for the target Postgres instance. when --target is missing from the command line, boom, this one steps in.
  • PGCOPYDB_TABLE_JOBS β€” how many concurrent jobs are allowed to run COPY operations in parallel. used when --table-jobs is omitted from the command line πŸ”₯
  • PGCOPYDB_INDEX_JOBS β€” how many concurrent jobs are allowed to run CREATE INDEX operations in parallel. used when --index-jobs is omitted from the command line.
  • PGCOPYDB_RESTORE_JOBS β€” how many concurrent jobs are allowed to run pg_restore operations in parallel. used when --restore-jobs is omitted from the command line.
  • PGCOPYDB_LARGE_OBJECTS_JOBS β€” how many concurrent jobs are allowed to copy Large Objects data in parallel. used when --large-objects-jobs is omitted from the command line πŸŽ€
  • PGCOPYDB_SPLIT_TABLES_LARGER_THAN β€” unlocks Same-table Concurrency when processing the source database. this value has to be a byte size, and the byte units it knows about are B, kB, MB, GB, TB, PB, and EB. used when --split-tables-larger-than is omitted from the command line ✨
  • PGCOPYDB_SPLIT_MAX_PARTS β€” caps the maximum number of parts when Same-table Concurrency is being used. used when --split-max-parts is omitted from the command line.
  • PGCOPYDB_ESTIMATE_TABLE_SIZES β€” when it's true (or yes, or on, or 1 β€” same input as a Postgres boolean) then pgcopydb estimates the size of tables to figure out whether or not to split tables. this option is only useful when querying the relation sizes on the source database is costly πŸ’€

when --estimate-table-sizes is omitted from the command line, this environment variable is used.

when this option is used, we run vacuumdb --analyze-only --jobs=<table-jobs> on the source database, which updates the statistics for the number of pages for each relation. later, we use that number of pages, plus the size for each page, to estimate the actual size of the tables πŸ“

if you'd rather run the ANALYZE command manually before running pgcopydb, you can use the --skip-analyze option or the PGCOPYDB_SKIP_ANALYZE environment variable. this way you can decrease the time spent on the migration πŸ‘‘

  • PGCOPYDB_OUTPUT_PLUGIN β€” the logical decoding output plugin to use. used when --plugin is omitted from the command line.
  • PGCOPYDB_WAL2JSON_NUMERIC_AS_STRING β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb uses the wal2json option --numeric-data-types-as-string when using the wal2json output plugin. used when --wal2json-numeric-as-string is omitted from the command line πŸ¦‹
  • PGCOPYDB_DROP_IF_EXISTS β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb uses the pg_restore options --clean --if-exists when creating the schema on the target Postgres instance. used when --drop-if-exists is omitted from the command line.
  • PGCOPYDB_FAIL_FAST β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb sends the TERM signal to every process in its process group the moment one process terminates with a non-zero return code 😭 used when --fail-fast is omitted from the command line.
  • PGCOPYDB_SKIP_VACUUM β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb skips the VACUUM ANALYZE jobs entirely, exactly like using the --skip-vacuum option.
  • PGCOPYDB_SKIP_ANALYZE β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb skips the vacuumdb --analyze-only commands entirely, same as when using the --skip-analyze option.
  • PGCOPYDB_SKIP_DB_PROPERTIES β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb skips the ALTER DATABASET SET properties commands that copy the settings from the source to the target database, same as when using the --skip-db-properties option.
  • PGCOPYDB_SKIP_CTID_SPLIT β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb skips the CTID split operation during the clone process, same as when using the --skip-split-by-ctid option.
  • PGCOPYDB_USE_COPY_BINARY β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb uses the COPY WITH (FORMAT BINARY) instead of the COPY command, same as when using the --use-copy-binary option 🌈
  • PGCOPYDB_SNAPSHOT β€” the Postgres snapshot identifier to re-use. see also --snapshot πŸ‘€
  • TMPDIR β€” the pgcopydb command creates all its work files and directories in ${TMPDIR}/pgcopydb, and it defaults to /tmp/pgcopydb πŸ“
  • PGCOPYDB_LOG_TIME_FORMAT β€” the logs time format defaults to %H:%M:%S when pgcopydb is used on an interactive terminal, and to %Y-%m-%d %H:%M:%S otherwise. this environment variable can be set to any format string other than those defaults.

see the documentation for strftime(3) for the details on the format string. see the documentation for isatty(3) for the details on detecting whether pgcopydb is running in an interactive terminal.

  • PGCOPYDB_LOG_JSON β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb formats its logs using JSON. it's giving structured ✨
{
  "timestamp": "2023-04-13 16:53:14",
  "pid": 87956,
  "error_level": 4,
  "error_severity": "INFO",
  "file_name": "main.c",
  "file_line_num": 165,
  "message": "Running pgcopydb version 0.11.19.g2290494.dirty from \"/Users/dim/dev/PostgreSQL/pgcopydb/src/bin/pgcopydb/pgcopydb\""
}
  • PGCOPYDB_LOG_FILENAME β€” when it's set to a filename (in a directory that must already exist) then pgcopydb writes its logs output to that filename in addition to the logs on the standard error output stream.
⚠️ if the file already exists, its content gets overwritten. in other words, the previous content would be lost when running the same command twice 😳 so yeah, watch out for that.
  • PGCOPYDB_LOG_JSON_FILE β€” when it's true (or yes, or on, or 1, same input as a Postgres boolean) then pgcopydb formats its logs using JSON when writing to PGCOPYDB_LOG_FILENAME.
  • XDG_DATA_HOME β€” the standard XDG Base Directory Specification (https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) defines several environment variables that let you control where programs should store their files.
    • XDG_DATA_HOME defines the base directory relative to which user-specific data files should be stored. if $XDG_DATA_HOME is either not set or empty, a default equal to $HOME/.local/share should be used.

when you're using Change Data Capture (through the --follow option and Postgres logical decoding with wal2json β€” https://github.com/eulerto/wal2json/) then pgcopydb pre-fetches changes in JSON files and transforms them into SQL files to apply to the target database 🧚

these files are stored at the following location, tried in this order:

  1. when --dir is used, then pgcopydb uses the cdc subdirectory of the --dir location,
  2. when XDG_DATA_HOME is set in the environment, then pgcopydb uses that location,
  3. when neither of the previous settings have been used then pgcopydb defaults to using ${HOME}/.local/share.

✨ EXAMPLES ✨

okay bestie, this is the part where it all comes together. πŸ’– Set your connection strings, flip on the drop setting, and let it rip:

$ export PGCOPYDB_SOURCE_PGURI=postgres://pagila:0wn3d@source/pagila
$ export PGCOPYDB_TARGET_PGURI=postgres://pagila:0wn3d@target/pagila
$ export PGCOPYDB_DROP_IF_EXISTS=on

$ pgcopydb clone --table-jobs 8 --index-jobs 12

like… look at that. eight table jobs, twelve index jobs, no notes. the main character energy is immaculate. πŸ‘‘


and here's what it actually spits out at you, log line by log line:

08:13:13.961 42893 INFO   [SOURCE] Copying database from "postgres://pagila:0wn3d@source/pagila?keepalives=1&keepalives_idle=10&keepalives_interval=10&keepalives_count=60"
08:13:13.961 42893 INFO   [TARGET] Copying database into "postgres://pagila:0wn3d@target/pagila?keepalives=1&keepalives_idle=10&keepalives_interval=10&keepalives_count=60"
08:13:14.009 42893 INFO   Using work dir "/tmp/pgcopydb"
08:13:14.017 42893 INFO   Exported snapshot "00000003-000000EB-1" from the source database
08:13:14.019 42904 INFO   STEP 1: fetch source database tables, indexes, and sequences
08:13:14.339 42904 INFO   Fetched information for 5 tables (including 0 tables split in 0 partitions total), with an estimated total of 1000 thousands tuples and 128 MB on-disk
08:13:14.342 42904 INFO   Fetched information for 4 indexes (supporting 4 constraints)
08:13:14.343 42904 INFO   Fetching information for 1 sequences
08:13:14.353 42904 INFO   Fetched information for 1 extensions
08:13:14.436 42904 INFO   Found 1 indexes (supporting 1 constraints) in the target database
08:13:14.443 42904 INFO   STEP 2: dump the source database schema (pre/post data)
08:13:14.448 42904 INFO    /usr/bin/pg_dump -Fc --snapshot 00000003-000000EB-1 --section=pre-data --section=post-data --file /tmp/pgcopydb/schema/schema.dump 'postgres://pagila:0wn3d@source/pagila?keepalives=1&keepalives_idle=10&keepalives_interval=10&keepalives_count=60'
08:13:14.513 42904 INFO   STEP 3: restore the pre-data section to the target database
08:13:14.524 42904 INFO    /usr/bin/pg_restore --dbname 'postgres://pagila:0wn3d@target/pagila?keepalives=1&keepalives_idle=10&keepalives_interval=10&keepalives_count=60' --section pre-data --jobs 2 --use-list /tmp/pgcopydb/schema/pre-filtered.list /tmp/pgcopydb/schema/schema.dump
08:13:14.608 42919 INFO   STEP 4: starting 8 table-data COPY processes
08:13:14.678 42921 INFO   STEP 8: starting 8 VACUUM processes
08:13:14.678 42904 INFO   Skipping large objects: none found.
08:13:14.693 42920 INFO   STEP 6: starting 2 CREATE INDEX processes
08:13:14.693 42920 INFO   STEP 7: constraints are built by the CREATE INDEX processes
08:13:14.699 42904 INFO   STEP 9: reset sequences values
08:13:14.700 42959 INFO   Set sequences values on the target database
08:13:16.716 42904 INFO   STEP 10: restore the post-data section to the target database
08:13:16.726 42904 INFO    /usr/bin/pg_restore --dbname 'postgres://pagila:0wn3d@target/pagila?keepalives=1&keepalives_idle=10&keepalives_interval=10&keepalives_count=60' --section post-data --jobs 2 --use-list /tmp/pgcopydb/schema/post-filtered.list /tmp/pgcopydb/schema/schema.dump
08:13:16.751 42904 INFO   All step are now done,  2s728 elapsed
08:13:16.752 42904 INFO   Printing summary for 5 tables and 4 indexes

notice how every STEP just goes? fetching, dumping the schema, restoring pre-data, the COPY processes, the VACUUMs, the CREATE INDEXes, the constraints riding along with them, the sequence reset, the post-data restore. πŸ¦‹ ten steps, zero drama, and the whole thing wraps in 2s728.


then it hits you with the receipts β€” the per-table summary:

  OID | Schema |             Name | Parts | copy duration | transmitted bytes | indexes | create index duration
------+--------+------------------+-------+---------------+-------------------+---------+----------------------
16398 | public | pgbench_accounts |     1 |         1s496 |             91 MB |       1 |                 302ms
16395 | public |  pgbench_tellers |     1 |          37ms |            1002 B |       1 |                  15ms
16401 | public | pgbench_branches |     1 |          45ms |              71 B |       1 |                  18ms
16386 | public |           table1 |     1 |          36ms |             984 B |       1 |                  21ms
16392 | public |  pgbench_history |     1 |          41ms |               0 B |       0 |                   0ms

five tables, four indexes, and the OIDs are right there if you need them. pgbench_history said "no indexes, no thoughts, head empty" and honestly? respected. 🫢


and the grand finale, the timing table, aka the vibe check in table form:

                                                         Step   Connection    Duration    Transfer   Concurrency
 --------------------------------------------------   ----------  ----------  ----------  ------------
   Catalog Queries (table ordering, filtering, etc)       source       119ms                         1
                                        Dump Schema       source        66ms                         1
                                     Prepare Schema       target        59ms                         1
      COPY, INDEX, CONSTRAINTS, VACUUM (wall clock)         both       2s125                        18
                                  COPY (cumulative)         both       1s655      128 MB             8
                          CREATE INDEX (cumulative)       target       343ms                         2
                           CONSTRAINTS (cumulative)       target        13ms                         2
                                VACUUM (cumulative)       target       144ms                         8
                                    Reset Sequences         both        15ms                         1
                         Large Objects (cumulative)       (null)         0ms                         0
                                    Finalize Schema         both        27ms                         2
 --------------------------------------------------   ----------  ----------  ----------  ------------
                          Total Wall Clock Duration         both       2s728                        24

the way that COPY is running 8 wide and still clocking in at 1s655 cumulative, transferring 128 MB, while the wall-clock line for COPY + INDEX + CONSTRAINTS + VACUUM is 2s125 at concurrency 18… that's the whole pipeline flexing. πŸ”₯ also the Large Objects (cumulative) row is (null) with 0ms and concurrency 0, because there were no large objects to find β€” the log already told us: Skipping large objects: none found. πŸ’€

Total wall clock duration: 2s728. ate. periodt. ✨


πŸ‘‘ AUTHOR πŸ‘‘

Dimitri Fontaine

2022-2024, Dimitri Fontaine


and that's a wrap on the whole page, bestie!! πŸŽ€ this was PGCOPYDB FORK(1), version 0.18, dated Jun 28, 2026 β€” go forth, fork, clone, and slay. no cap. πŸ’–πŸ‘‘πŸŒˆ

πŸ“¦ where this page comes from