A MySQL to PostgreSQL migration tool with an interactive TUI wizard and a headless CLI mode. Cursor-based pagination, resumable migrations, and intelligent column mapping.
┌──────────────────────────────────────────────────────────────┐
│ pgpipe - Migration in Progress │
├──────────────────────────────────────────────────────────────┤
│ │
│ individuals (MySQL) → individuals (PostgreSQL) │
│ │
│ Progress: [████████████████░░░░░░░░] 67.2% │
│ │
│ Batches: 67 / 100 │
│ Rows: 335,000 / 500,000 (this run) │
│ Total: 4,835,000 / 9,234,567 (overall) │
│ │
│ Speed: 12,450 rows/sec │
│ Skipped: 177 (invalid JSON) │
│ │
└──────────────────────────────────────────────────────────────┘
- Interactive TUI - Built with Bubble Tea for a smooth terminal experience
- Headless CLI Mode -
pgpipe runfor scripted/automated migrations without the TUI - Config Generator -
pgpipe generate-configsintrospects schemas and writes per-table configs - Search & Filter - Press
/to search tables and columns instantly - Smart Column Mapping - Auto-matches columns by name, full editor for customization
- Cursor-Based Pagination - Efficient pagination using primary key, no OFFSET performance degradation
- Resumable Migrations - State file tracks progress, resume from any point
- Configuration Persistence - Saves wizard selections, resume from last step or start fresh
- Real-Time Progress - Live updates every 500ms showing batches, rows, speed, errors
- Batch Control - Run 1 to 2 billion rows per batch, minimum 1 for fine-grained testing
- Error Handling - Skip invalid rows (e.g., bad JSON), log errors to JSONL files
.envFile Support - Automatically loads.envfrom the current directory at startup
pgpipe auto-detects and applies transforms when column types differ between MySQL and PostgreSQL:
| Transform | MySQL Source | PostgreSQL Target | Behavior |
|---|---|---|---|
text_to_jsonb |
TEXT, VARCHAR, LONGTEXT, JSON |
JSON, JSONB |
Validates JSON; invalid rows are skipped and logged |
int_to_bool |
TINYINT, SMALLINT, INT, BIGINT |
BOOLEAN |
0 → false, non-zero → true, NULL → NULL |
string_to_uuid |
CHAR, VARCHAR |
UUID |
Passes UUID string through; PostgreSQL validates format |
Transforms are auto-detected in both the TUI wizard and generate-configs. You can also set them manually in config files.
Pre-built binaries are available for Linux and macOS on the Releases page.
# Linux (amd64)
curl -L https://github.com/RobertoGongora/pgpipe/releases/latest/download/pgpipe-linux-amd64 -o pgpipe
chmod +x pgpipe
# macOS (Apple Silicon)
curl -L https://github.com/RobertoGongora/pgpipe/releases/latest/download/pgpipe-darwin-arm64 -o pgpipe
chmod +x pgpipe
# macOS (Intel)
curl -L https://github.com/RobertoGongora/pgpipe/releases/latest/download/pgpipe-darwin-amd64 -o pgpipe
chmod +x pgpipego install github.com/RobertoGongora/pgpipe/cmd/pgpipe@latestRequires Go 1.24 or later.
git clone https://github.com/RobertoGongora/pgpipe.git
cd pgpipe
make buildCreate a .env file in your working directory (or export the variables directly):
# MySQL (source)
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=secret
MYSQL_DATABASE=source_db
# PostgreSQL (target)
PGSQL_HOST=localhost
PGSQL_PORT=5432
PGSQL_USER=postgres
PGSQL_PASSWORD=secret
PGSQL_DATABASE=target_dbpgpipe automatically loads .env from the current directory at startup.
./pgpipe- Connections - Verify MySQL and PostgreSQL connections
- Source Table - Select MySQL table (shows row counts)
- Source Columns - Select columns to migrate
- Target Table - Select PostgreSQL target table
- Column Mapping - Review auto-matched mappings, edit if needed
- Settings - Choose batch size and run mode
- Migration - Watch real-time progress
- Summary - Review results and error log location
pgpipe has three modes of operation:
pgpipe Launch the interactive TUI wizard
pgpipe run [--config=<path>] Run a migration headlessly from a config file
pgpipe generate-configs [flags] Generate per-table config files from live schemas
pgpipe --help Print usage information
Launches the interactive terminal wizard. This is the default when no subcommand is given. Configuration is saved to .pgpipe/config.yaml and migration state to .pgpipe/state.yaml.
Runs a migration headlessly from a config file. Useful for scripted, automated, or parallel migrations.
Usage: pgpipe run [--config=<path>]
Flags:
--config string Path to the migration config YAML file (default: .pgpipe/config.yaml)
Default behavior (no --config flag):
pgpipe runReads .pgpipe/config.yaml and saves state to .pgpipe/state.yaml — the same paths used by the TUI.
Per-table config (with --config flag):
pgpipe run --config=./configs/users.yamlReads the specified config file. The state file is automatically placed alongside the config as ./configs/.users.state.yaml. This allows running many tables in parallel without state file collisions.
The CLI always runs in continuous mode (processes all rows to completion). If a previous run was interrupted, it resumes from the last saved cursor position. If the config has changed since the last run (detected via SHA-256 hash), it starts fresh.
Example output:
[pgpipe] Starting migration: users → users (batch_size=5000)
[pgpipe] Connected to MySQL and PostgreSQL
[pgpipe] Source: 1,234,567 rows (id 1..1234567)
[pgpipe] users: batch 1 | 5,000/1,234,567 (0.4%) | imported=5,000 skipped=0 | elapsed=2s
[pgpipe] users: batch 2 | 10,000/1,234,567 (0.8%) | imported=10,000 skipped=0 | elapsed=4s
...
[pgpipe] Migration complete: users
Processed : 1,234,567 rows
Imported : 1,234,390 rows
Skipped : 177 rows
Duration : 8m32.451s
Errors : 177 (see .pgpipe/logs/2026-02-20_10-30-00_errors.jsonl)
Connects to both databases, introspects all tables, and writes one YAML config per matching table with auto-detected column mappings and transforms.
Usage: pgpipe generate-configs --output-dir=<dir> [flags]
Flags:
--output-dir string Directory to write generated config files (required)
--skip string Comma-separated list of table names to skip
--force Overwrite existing config files (default: skip existing)
Example:
# Generate configs for all tables
pgpipe generate-configs --output-dir=./configs
# Skip certain tables
pgpipe generate-configs --output-dir=./configs --skip=migrations,sessions,cache
# Regenerate all configs (overwrite existing)
pgpipe generate-configs --output-dir=./configs --forceTables are matched by name between MySQL and PostgreSQL. Tables with no matching PostgreSQL table are skipped. The generated configs contain only the migration: block — connection details always come from environment variables.
Example workflow — migrate all tables:
# 1. Generate configs
pgpipe generate-configs --output-dir=./configs
# 2. Review and adjust (edit transforms, remove tables you don't want)
ls ./configs/
# 3. Run them sequentially
for f in ./configs/*.yaml; do
pgpipe run --config="$f"
done
# 4. Or run them in parallel (each has its own state file)
for f in ./configs/*.yaml; do
pgpipe run --config="$f" &
done
waitConfig files used by pgpipe run follow this YAML structure. Files generated by generate-configs contain only the migration: block (connection details come from env vars):
migration:
source:
table: users
primary_key: id
columns:
- id
- name
- email
- metadata
- is_active
- external_id
target:
table: public.users
mapping:
- source: id
target: id
- source: name
target: name
- source: email
target: email
- source: metadata
target: metadata
transform: text_to_jsonb
- source: is_active
target: is_active
transform: int_to_bool
- source: external_id
target: external_id
transform: string_to_uuid
settings:
batch_size: 5000Connection details can also be included (passwords should use env var references):
mysql:
host: localhost
port: 3306
user: root
password: $MYSQL_PASSWORD
database: source_db
postgres:
host: localhost
port: 5432
user: postgres
password: $PGSQL_PASSWORD
database: target_dbEnvironment variables in YAML values (like $MYSQL_PASSWORD) are expanded at load time.
Continuous Mode: Runs until all rows are migrated
pgpipe → Select "Continuous" → Migrates all rows → Exits
Batch Mode: Runs N batches then stops (useful for controlled migrations)
pgpipe → Select "100 batches" → Migrates ~500K rows → Exits
pgpipe → Resume → Another 100 batches → ...
pgpipe saves your configuration and progress automatically:
./pgpipe
# First run after setup: "Saved configuration found!"
# Shows: source → target, column count, batch size
# Options: "Use saved configuration" or "Start fresh"
# Mid-migration: "Existing migration found!"
# Shows: progress %, rows processed, last run time
# Options: "Resume migration" or "Start new migration"Configuration is saved after each major step, so you can:
- Quit during wizard setup - resume from where you left off
- Run partial migrations - continue from last batch
- Modify saved config - navigate with ESC to change any setting
| Key | Action |
|---|---|
| / | Search/filter (tables, columns) |
| Up/Down or k/j | Navigate lists |
| Space | Toggle selection |
| Enter | Confirm / Continue / Edit |
| Left/Right or h/l | Adjust values |
| a | Select all (columns) |
| n | Select none (columns) |
| c | Continue (mapping screen) |
| e | Edit number (settings screen) |
| Esc | Go back / Exit search |
| q | Quit (saves progress during migration) |
| Ctrl+C | Force quit |
Instead of using OFFSET (which degrades performance as offset increases), pgpipe uses cursor-based pagination:
-- First batch
SELECT id, enrichment FROM individuals WHERE id > 0 ORDER BY id LIMIT 5000;
-- Next batch (cursor = last ID from previous batch)
SELECT id, enrichment FROM individuals WHERE id > 5000 ORDER BY id LIMIT 5000;Benefits:
- Constant performance - Each query uses the index
- Deterministic - No missed or duplicate rows
- Resumable - Store cursor value, resume from exact position
pgpipe auto-matches columns by exact name and detects transforms based on type pairs:
Source (MySQL) Target (PostgreSQL) Transform
enrichment (TEXT) → enrichment (JSONB) text_to_jsonb (auto)
is_active (TINYINT) → is_active (BOOLEAN) int_to_bool (auto)
ext_id (CHAR(36)) → ext_id (UUID) string_to_uuid (auto)
name (VARCHAR) → name (VARCHAR) (none)
extra_field (TEXT) → (no match) skipped
.pgpipe/ # Created at runtime
├── config.yaml # Saved configuration
├── state.yaml # Migration progress
└── logs/
└── 2026-02-20_10-30-00_errors.jsonl
Tracks migration progress for resumability:
config_hash: "sha256:abc123..."
session:
id: "2026-02-20_10-30-00"
error_log: ".pgpipe/logs/2026-02-20_10-30-00_errors.jsonl"
source:
table: individuals
total_rows: 9234567
primary_key: id
progress:
last_cursor: 4500000
processed_rows: 4500000
imported_rows: 4499823
skipped_rows: 177Each line is a JSON object for easy parsing:
{"mysql_id": 1234567, "error": "invalid JSON", "raw_preview": "{\"foo\": x...", "timestamp": "2026-02-20T10:30:45Z"}| Variable | Default | Description |
|---|---|---|
MYSQL_HOST |
localhost | MySQL host |
MYSQL_PORT |
3306 | MySQL port |
MYSQL_USER |
root | MySQL user |
MYSQL_PASSWORD |
- | MySQL password |
MYSQL_DATABASE |
- | MySQL database |
PGSQL_HOST |
localhost | PostgreSQL host |
PGSQL_PORT |
5432 | PostgreSQL port |
PGSQL_USER |
postgres | PostgreSQL user |
PGSQL_PASSWORD |
- | PostgreSQL password |
PGSQL_DATABASE |
- | PostgreSQL database |
PGSQL_SSLMODE |
prefer | PostgreSQL SSL mode (prefer, require, disable) |
Set PGSQL_SSLMODE=require for hosted providers like Supabase that mandate SSL connections.
pgpipe loads .env from the current working directory at startup. Values in .env override existing shell environment variables.
| Scenario | Behavior |
|---|---|
| Invalid JSON value | Row skipped, logged to JSONL, migration continues |
| Malformed UUID | Row skipped, logged, migration continues |
| Insert failure | Row skipped, logged, migration continues |
| User quit (q) | State saved, can resume later |
| Connection lost | Migration stops, state saved at last successful batch |
make build # Build for current platform
make build-linux # Cross-compile for linux/amd64
make build-all # Build all 4 platforms (linux/darwin x amd64/arm64) into dist/
make dev # Run from source (go run)
make test # Run all tests
make coverage # Show test coverage per package
make fmt # Format code
make lint # Run golangci-lint
make clean # Remove binaries and runtime dataFuture enhancements planned for v2:
- Automatic table creation with index replication
- Additional column transforms (dates, enums, etc.)
- Parallel batch processing
- Dry-run mode with migration preview
- Progress webhook notifications
- Docker image
- Migration templates (save/load complete configurations)
- Rollback support
Contributions are welcome! See CONTRIBUTING.md for development setup, coding standards, and the pull request process.
For bug reports and feature requests, please open an issue using the provided templates.
MIT License - see LICENSE file for details.
Copyright (c) 2026 Roberto Gongora