What PostgreSQL Actually Gets You (And Why I Keep Migrating Companies Away From MySQL)

Why I Keep Doing This Migration

Look, MySQL pissed me off one too many times. After 4 years of babysitting production databases, PostgreSQL just works better. PostgreSQL 16 dropped in September 2023 and fixed the JSON indexing shit that kept breaking our product search. The official PostgreSQL 16 announcement covers all the performance improvements, while this migration guide from 2025 provides current best practices.

Here's why I finally said "fuck this" and migrated our e-commerce platform's 47GB inventory database last year (maybe 52GB? I stopped counting after the third disk expansion):

MySQL kept breaking on us:

Hardware Reality Check - Don't Be Like Me

Anyway, this bit me in the ass hard and I don't want you to go through the same pain:

I learned this the hard way when pgloader crashed at 89% complete (pgloader heap exhaustion issue):

  • 16GB RAM minimum - I tried with 8GB and pgloader ate all available memory and crashed after 6 hours (pgloader memory requirements)
  • SSD storage is non-negotiable - My first migration took 18 hours on spinning rust, same database took 4 hours on SSD (PostgreSQL storage considerations)
  • Fast network connection - Migration over VPN to AWS took 14 hours, direct connection took 3 hours (migration best practices)
  • 3x storage space not 2x - PostgreSQL uses more space than MySQL for the same data, plus you need temp space for indexes. I think our 47GB MySQL database ended up needing like 140GB? Maybe 150GB? I honestly stopped counting when the disk usage alarm went off at 3am (PostgreSQL vs MySQL storage differences)

Tools that actually work:

PostgreSQL Performance Comparison

Pre-Migration Assessment (The Stuff That Will Bite You Later)

Run these queries to find the landmines in your schema (replace inventory_db with your actual database):

-- Find the data types that will cause pgloader to have a mental breakdown
SELECT DISTINCT DATA_TYPE 
FROM information_schema.COLUMNS 
WHERE TABLE_SCHEMA = 'inventory_db' 
AND DATA_TYPE IN ('ENUM', 'SET', 'YEAR', 'TINYINT');

-- Identify your biggest tables (these take forever to migrate)
SELECT TABLE_NAME, 
       ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) as SIZE_MB
FROM information_schema.TABLES 
WHERE TABLE_SCHEMA = 'inventory_db' 
ORDER BY SIZE_MB DESC;

-- Find stored procedures that you'll need to rewrite by hand
SELECT ROUTINE_NAME, ROUTINE_TYPE 
FROM information_schema.ROUTINES 
WHERE ROUTINE_SCHEMA = 'inventory_db';

The 4 things that will definitely fuck you up:

  • ENUM columns become CHECK constraints - Our product status went from ENUM('active','discontinued') to a CHECK constraint that half our app didn't expect
  • AUTO_INCREMENT sequences start weird - PostgreSQL sequences don't start at your current max ID unless you tell them to
  • Zero dates break everything - MySQL's 0000-00-00 dates will cause pgloader to error out. I had 1,247 records with invalid dates in our orders table
  • Case sensitivity will murder your app - PostgreSQL converts ProductTable to producttable, your ORM queries will fail with "relation does not exist"

Realistic Timeline Planning (Not The Bullshit Estimates You See Elsewhere)

OK, real talk time. Every migration guide lies about timelines:

How long this actually takes (based on my last 5 migrations):

  • Under 1GB: 1-2 hours (including debugging weird schema issues)
  • 1-10GB: 2-5 hours (our 8GB customer database took 4.5 hours)
  • 10-50GB: 4-8 hours (the 47GB inventory database took 7 hours including index rebuilds, or maybe 9 hours? I lost track after the UTF-8 issue)
  • 50-100GB: 8-16 hours (and that's if nothing breaks)

Downtime reality check:

  • Schedule for a weekend because shit will go wrong
  • Plan for 2x your estimated time - my "4 hour migration" took 9 hours when pgloader choked on our UTF8 data
  • Have pizza and coffee ready - you'll be debugging until 3am. I spent 6 hours tracking down why our user login system broke, turned out PostgreSQL was case-sensitive on the username column and half our users had mixed-case usernames in MySQL
  • Keep MySQL running until you're 100% sure PostgreSQL works - I rolled back twice before getting it right

The migration itself is the easy part. It's the 47 SQL queries in your application that assume MySQL's quirks that will ruin your week.

Database Migration Architecture

Migration Tools: What Actually Works (And What Doesn't)

Tool

What I Use It For

Setup Reality

Skill Level

Database Size

Real Cost

What's Good

What Sucks

Reality Check

pgloader

Everything under 100GB

45 mins cursing at config files

Learn to read docs or suffer

Up to 100GB

Free

Handles MySQL's weird shit, decent performance

Eats RAM like candy, UTF-8 makes it cry

Works great until it decides to eat 12GB of RAM for a 500MB table

Skyvia

When boss wants GUI

15 mins

Point and click

Up to 20GB

$39-199/month

Pretty interface, less thinking

Chokes on ENUM columns, expensive AF

Pretty until your ENUM columns break everything

AWS DMS

Big enterprise bullshit

3-4 hours

AWS certified

Unlimited

$400-3000/month

Handles massive databases

Costs more than my rent, AWS lock-in

Costs more than your car payment

Custom Scripts

When everything breaks

6-20 hours

Senior engineer

Unlimited

Your weekend

Total control

You'll hate yourself

You'll want to quit programming

The Actual Migration Process (Not the Theoretical Bullshit)

PostgreSQL Logo with Database

Phase 1: Installing PostgreSQL Without Breaking Your System

Alright, let's get this shit started:

Installing PostgreSQL (Ubuntu 22.04 - Other Distros Are Similar)

I now use PostgreSQL 17.5 in production since it's rock solid (official PostgreSQL installation guide):

## Add the official PostgreSQL repo (don't use Ubuntu's ancient version)
sudo sh -c 'echo \"deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main\" > /etc/apt/sources.list.d/pgdg.list'
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg
sudo apt update
sudo apt install postgresql-17 postgresql-contrib-17

For comprehensive installation instructions, see the DigitalOcean PostgreSQL Ubuntu 22.04 guide and Ubuntu official documentation.

macOS (if you're into that sort of thing):

## Homebrew works fine but I prefer Linux for databases
brew install postgresql@17
brew services start postgresql@17

Windows:
Just use Docker. Seriously. Download Docker Desktop and run PostgreSQL in a container instead of dealing with Windows service bullshit. Check out Docker's PostgreSQL best practices and production Docker PostgreSQL setup guide.

Setting Up Your Target Database

Create the database that will hold your migrated data:

## Connect as the postgres superuser
sudo -u postgres psql

## Create your actual database (I'm calling it inventory_prod)
CREATE DATABASE inventory_prod;
CREATE USER app_user WITH PASSWORD 'Tr7$mK9*wE2!nB8q';
GRANT ALL PRIVILEGES ON DATABASE inventory_prod TO app_user;

## Grant schema permissions
\c migrated_db
GRANT ALL ON SCHEMA public TO migration_user;
GRANT ALL ON ALL TABLES IN SCHEMA public TO migration_user;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO migration_user;

## Exit PostgreSQL
\q

Installing pgloader

Ubuntu/Debian:

sudo apt update
sudo apt install pgloader

macOS:

brew install pgloader

Using Docker (recommended for Windows):

docker pull dimitri/pgloader:latest

Phase 2: Pre-Migration Database Backup

Create MySQL Backup

Always backup before migration - this is your safety net (mysqldump best practices):

## Create a complete backup of your MySQL database
mysqldump -u username -p --single-transaction --routines --triggers database_name > mysql_backup_$(date +%Y%m%d_%H%M%S).sql

## For large databases, use compression
mysqldump -u username -p --single-transaction --routines --triggers database_name | gzip > mysql_backup_$(date +%Y%m%d_%H%M%S).sql.gz

For large databases (>50GB), consider alternative backup strategies like Percona XtraBackup for faster performance.

Verify your backup:

## Check backup file size (should not be 0)
ls -lh mysql_backup_*.sql

## Test restore to a temporary database (optional but recommended)
mysql -u username -p -e \"CREATE DATABASE backup_test;\"
mysql -u username -p backup_test < mysql_backup_*.sql
mysql -u username -p -e \"DROP DATABASE backup_test;\"

Phase 3: Execute Migration with pgloader

Basic Migration Command

For simple databases:

pgloader mysql://mysql_user:mysql_password@localhost/source_db \
         postgresql://migration_user:secure_password123@localhost/migrated_db

Advanced Migration with Configuration File

Create a file named migration.load:

LOAD DATABASE
     FROM mysql://mysql_user:mysql_password@mysql_host:3306/source_db
     INTO postgresql://migration_user:secure_password123@postgres_host:5432/migrated_db

WITH include drop, create tables, create indexes, reset sequences,
     workers = 4, concurrency = 1,
     multiple readers per thread, rows per range = 25000

SET work_mem to '256MB', maintenance_work_mem to '512MB'

-- Handle common data type conversions
CAST type datetime to timestamp drop default drop not null using zero-dates-to-null,
     type date drop not null using zero-dates-to-null,
     type tinyint(1) to boolean drop typemod,
     type enum when (= 'active') to boolean drop typemod

-- Skip problematic tables if needed  
INCLUDING ONLY TABLE NAMES MATCHING ~/^(?!temp_|log_|cache_)/

BEFORE LOAD DO
 $$ drop schema if exists mysql cascade; $$,
 $$ create schema mysql; $$;

Execute the migration (grab coffee, this takes forever):

pgloader migration.load

Pro tip: Run this in a screen session because your SSH connection WILL drop at 89% complete. Trust me - I learned this shit on Christmas Eve when my laptop decided to take a nap and nuked 8 hours of migration progress. Yeah, I was migrating databases on Christmas fucking Eve. My wife still roasts me about this during family dinners. "Remember when you chose PostgreSQL over presents?"

Monitor Migration Progress

During migration, pgloader provides real-time feedback:

  • Table creation progress
  • Row insertion counts
  • Error reporting
  • Performance statistics

Example output:

     table name     errors       rows      bytes      total time
--------------     ------     ------     ------      ----------
         fetch          0         10       2.4 kB          0.086s
        create          0         11       1.6 kB          0.048s
         users          0       5420     1.2 MB          2.234s
        orders          0      12450     3.8 MB          4.567s
      products          0       1250   456.7 kB          0.892s
--------------     ------     ------     ------      ----------
        TOTAL          0      19141     5.4 MB          7.827s

Phase 4: Post-Migration Verification

Data Integrity Checks

Compare row counts between databases:

-- MySQL
SELECT 
    TABLE_NAME,
    TABLE_ROWS as ROW_COUNT
FROM information_schema.TABLES 
WHERE TABLE_SCHEMA = 'source_db'
ORDER BY TABLE_NAME;

-- PostgreSQL  
SELECT 
    schemaname,
    tablename,
    n_tup_ins as row_count
FROM pg_stat_user_tables
ORDER BY tablename;

Verify data types were converted correctly:

-- PostgreSQL - Check converted data types
SELECT 
    column_name,
    data_type,
    is_nullable,
    column_default
FROM information_schema.columns 
WHERE table_catalog = 'migrated_db' 
AND table_schema = 'public'
ORDER BY table_name, ordinal_position;

Application Connection Update

Update your application's database configuration:

PHP (Laravel example):

// config/database.php
'pgsql' => [
    'driver' => 'pgsql',
    'host' => 'localhost',
    'port' => '5432', 
    'database' => 'migrated_db',
    'username' => 'migration_user',
    'password' => 'secure_password123',
    'charset' => 'utf8',
    'prefix' => '',
    'schema' => 'public',
],

Python (Django example):

## settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'migrated_db',
        'USER': 'migration_user', 
        'PASSWORD': 'secure_password123',
        'HOST': 'localhost',
        'PORT': '5432',
    }
}

Node.js (using pg library):

const { Pool } = require('pg')

const pool = new Pool({
  user: 'migration_user',
  host: 'localhost',
  database: 'migrated_db', 
  password: 'secure_password123',
  port: 5432,
})

With these steps completed, your MySQL database should be successfully migrated to PostgreSQL. The next phase involves testing your applications and optimizing performance for your new PostgreSQL setup.

Migration Reality Check: Questions I Get Asked Every Damn Week

Q

How long will this migration actually take?

A

Short answer:

Longer than you think. Always. Plan for murphy's law.Real timeline from my last 5 migrations:

  • Under 1GB: 1-2 hours (including debugging weird schema shit)

  • 1-5GB: 2-4 hours (our customer database took 3.5 hours because of encoding issues)

  • 5-20GB: 3-7 hours (the product catalog migration took 6 hours when pgloader choked on BLOB data)

  • 20-50GB: 6-12 hours (inventory database took 9 hours because MySQL had UTF-8 data stored as latin1)

  • 50-100GB: 8-20 hours (and that's if nothing breaks)What actually slows it down:

  • Your shitty network connection (VPN adds 3x time)

  • Spinning disk storage (2010 called, they want their HDDs back)

  • My

SQL's "creative" data storage (zero dates, invalid UTF-8, broken foreign keys)

  • Your 47 indexes that need rebuilding one by one
Q

Do I need to stop MySQL during migration?

A

Development:

Fuck no. pgloader can read from a live database just fine.Production**: Fuck yes, unless you enjoy data corruption.

Learned this the hard way when our order processing kept writing to MySQL while pgloader was copying the orders table. Result: 437 orders vanished into the digital void.

Took 6 hours to figure out where they went.Do this instead of shutting down MySQL completely:sql-- Put MySQL in read-only mode so apps can't writeSET GLOBAL read_only = ON;-- Verify no writes are happeningSHOW PROCESSLIST;

Q

What happens to my AUTO_INCREMENT sequences?

A

They become PostgreSQL sequences and it's usually fine but sometimes pgloader fucks up the starting value.

What pgloader is supposed to do:

  • Convert each AUTO_INCREMENT to a sequence
  • Set starting value to your current max ID + 1
  • Update table default to use nextval()What actually happened to me:

Our user IDs jumped from 15,847 to fucking 32,768 after migration because pgloader decided to be helpful with sequence defaults. Spent 2 hours staring at new user registrations wondering why IDs had massive gaps. Users were getting ID 32769, 32770 instead of 15848, 15849. Looked like we lost 15,000 users overnight.Check sequences after migration:```sql-- List all sequences\ds-- Check if starting values make sense

SELECT sequence_name, last_valueFROM information_schema.sequences;-- Fix sequences that started wrong (example)SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));```

Q

Will my stored procedures work in PostgreSQL?

A

Short answer:

Hahaha no. Plan to rewrite everything.What pgloader does**: Copies tables and data.

Stored procedures? Nope. Triggers? Nope. Functions? Nope. That's all on you.The painful reality:

  • MySQL and PostgreSQL stored procedure syntax is about as compatible as oil and water
  • DELIMITER $$ becomes $function$ which makes no sense until you've been using PostgreSQL for 6 months
  • MySQL's NOW() becomes CURRENT_TIMESTAMP and your date logic breaks
  • Control flow is completely different
  • our inventory update procedure took 3 days to rewriteMy recommendation:
  1. Get data migration working first
  2. Rewrite procedures one by one
  3. Test everything because PostgreSQL actually validates your SQL
Q

How do I handle ENUM columns?

A

MySQL ENUM columns are converted to PostgreSQL in two ways:

Method 1

  • CHECK constraints (pgloader default):```sql-- My

SQL ENUM('active','inactive','pending') becomes:

ALTER TABLE users ADD CONSTRAINT status_check

CHECK (status IN ('active','inactive','pending'));```Method 2

  • PostgreSQL ENUM type (manual):```sql-- Create custom enum type

CREATE TYPE user_status AS ENUM ('active','inactive','pending');ALTER TABLE users ALTER COLUMN status TYPE user_status USING status::user_status;```

Q

What if pgloader fails or gets stuck?

A

**Common causes and solutions:**1.

Memory issues:```bash# Reduce workers and batch size in .load file

WITH workers = 2, rows per range = 10000```2.

Connection timeouts:```bash# Increase timeout values

SET MySQL PARAMETERS net_read_timeout = '28800', net_write_timeout = '28800';```3.

Invalid data (zero dates, encoding issues):bash# Add data cleaning rules to .load fileCAST type datetime to timestamp drop default drop not null using zero-dates-to-null4.

Resume failed migration:bash# pgloader creates resume files automaticallypgloader --resume migration.load

Q

How do I verify my migration was successful?

A

**Essential verification steps:**1.

Row count comparison:```sql-- Compare total rows between databasesSELECT SUM(TABLE_ROWS) FROM information_schema.

TABLESWHERE TABLE_SCHEMA = 'mysql_db';SELECT SUM(n_tup_ins) FROM pg_stat_user_tables;2. Sample data verification:sql-- Check a few records manuallySELECT * FROM users ORDER BY id LIMIT 5;SELECT * FROM orders WHERE created_at >= '2024-01-01' LIMIT 10;```3.

Foreign key relationships:sql-- Verify referential integritySELECT COUNT(*) FROM orders oLEFT JOIN users u ON o.user_id = u.idWHERE u.id IS NULL; -- Should return 0

Q

Can I migrate only specific tables instead of the entire database?

A

Yes! pgloader allows selective table migration:

Method 1

  • Include specific tables:INCLUDING ONLY TABLE NAMES MATCHING ~<users|orders|products>Method 2
  • Exclude specific tables:INCLUDING ONLY TABLE NAMES MATCHING ~/^(?!temp_|log_|cache_)/Method 3
  • Command line:bashpgloader --include-table-pattern "users|orders" mysql://... postgresql://...
Q

What about my database users and permissions?

A

pgloader does not migrate My

SQL users, roles, or permissions.

You need to recreate them manually in PostgreSQL:Transfer user accounts:```sql-- In Postgre

SQL, create equivalent usersCREATE USER app_user WITH PASSWORD 'secure_password';CREATE USER readonly_user WITH PASSWORD 'another_password';-- Grant appropriate permissionsGRANT ALL ON DATABASE migrated_db TO app_user;GRANT CONNECT ON DATABASE migrated_db TO readonly_user;GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user;```

Post-Migration Optimization and Best Practices

PostgreSQL Performance Monitoring

Performance Tuning Your New PostgreSQL Database

So you got the data moved over? Cool. Now comes the part where PostgreSQL runs like ass until you tune it:

Essential PostgreSQL Configuration

After migration, PostgreSQL runs like garbage until you tune it. Default settings are made for a fucking Raspberry Pi. I nearly lost my shit when our main dashboard went from 2 seconds in MySQL to 45 seconds in PostgreSQL - I was ready to nuke the whole thing until I fixed the config. For comprehensive configuration tuning, check PostgreSQL Performance Tuning Guide and use PGTune for automated configuration calculations based on your hardware.

Update postgresql.conf with recommended settings:

## Memory settings (adjust based on your system)
shared_buffers = '256MB'              # 25% of available RAM  
work_mem = '64MB'                     # Memory per query operation
maintenance_work_mem = '512MB'        # For VACUUM, CREATE INDEX, etc.
effective_cache_size = '1GB'          # Available system cache

## Checkpoint settings for better write performance
checkpoint_completion_target = 0.9     # Spread checkpoints over time
wal_buffers = '16MB'                  # Write-ahead log buffer size
max_wal_size = '2GB'                  # Maximum WAL size

## Connection settings
max_connections = 100                 # Concurrent connections limit

## Query planner settings
random_page_cost = 1.1                # Cost of random disk access (SSD)
effective_io_concurrency = 200        # Concurrent I/O operations (SSD)

Apply configuration changes:

sudo systemctl reload postgresql
## or
sudo pg_ctl reload

Rebuild Statistics for Optimal Query Performance

PostgreSQL's query planner relies on table statistics to choose efficient execution plans. After migration, update these statistics (PostgreSQL ANALYZE documentation, performance tuning with EXPLAIN ANALYZE):

-- Connect to your migrated database
psql -U migration_user -d migrated_db

-- Update all table statistics (can take several minutes for large databases)
ANALYZE VERBOSE;

-- For better statistics on large tables, increase sample size
ALTER TABLE large_table_name ALTER COLUMN column_name SET STATISTICS 1000;
ANALYZE large_table_name;

-- Check if statistics are current
SELECT schemaname, tablename, last_analyze, last_autoanalyze 
FROM pg_stat_user_tables 
WHERE schemaname = 'public'
ORDER BY last_analyze DESC;

Index Optimization

pgloader recreates indexes, but you may need additional optimizations:

Identify missing indexes:

-- Find tables without indexes on foreign key columns
SELECT 
    t.table_name,
    c.column_name,
    c.data_type
FROM information_schema.table_constraints t
JOIN information_schema.key_column_usage k ON t.constraint_name = k.constraint_name
JOIN information_schema.columns c ON k.table_name = c.table_name AND k.column_name = c.column_name
WHERE t.constraint_type = 'FOREIGN KEY'
AND NOT EXISTS (
    SELECT 1 FROM pg_indexes 
    WHERE tablename = t.table_name 
    AND indexdef LIKE '%' || c.column_name || '%'
);

Create commonly needed indexes:

-- Index on foreign key columns (if not already present)
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
CREATE INDEX CONCURRENTLY idx_order_items_product_id ON order_items(product_id);

-- Index on frequently queried date columns
CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at);
CREATE INDEX CONCURRENTLY idx_users_last_login ON users(last_login);

-- Composite indexes for common query patterns
CREATE INDEX CONCURRENTLY idx_orders_status_date ON orders(status, created_at);

Use CONCURRENTLY to avoid locking tables during index creation.

Application Compatibility and Testing

SQL Syntax Differences to Address

Common MySQL to PostgreSQL syntax updates needed in applications:

1. String concatenation:

-- MySQL
SELECT CONCAT(first_name, ' ', last_name) as full_name FROM users;

-- PostgreSQL  
SELECT first_name || ' ' || last_name as full_name FROM users;

2. LIMIT with OFFSET:

-- MySQL
SELECT * FROM users LIMIT 10, 20;  -- OFFSET, LIMIT

-- PostgreSQL
SELECT * FROM users LIMIT 20 OFFSET 10;  -- LIMIT, OFFSET

3. Date functions:

-- MySQL
SELECT NOW(), DATE_FORMAT(created_at, '%Y-%m-%d') FROM orders;

-- PostgreSQL
SELECT CURRENT_TIMESTAMP, TO_CHAR(created_at, 'YYYY-MM-DD') FROM orders;

4. Case sensitivity:

-- MySQL (case insensitive by default)
SELECT * FROM Users WHERE Name = 'john';

-- PostgreSQL (case sensitive)
SELECT * FROM users WHERE name = 'john';  -- Use lowercase
-- or use ILIKE for case-insensitive matching
SELECT * FROM users WHERE name ILIKE 'john';

Testing Application Functionality

Create a systematic testing approach:

1. Database connection test:

## Python example
import psycopg2

try:
    conn = psycopg2.connect(
        host="localhost",
        database="migrated_db", 
        user="migration_user",
        password="secure_password123"
    )
    print("PostgreSQL connection successful!")
    conn.close()
except Exception as error:
    print(f"Connection failed: {error}")

2. Query performance comparison:

-- Enable query timing
	iming on

-- Test your most important queries
EXPLAIN ANALYZE SELECT * FROM users 
WHERE email = 'user@example.com';

-- Compare execution times with MySQL baseline

3. Data integrity verification:

-- Verify foreign key constraints work
INSERT INTO orders (user_id, total) VALUES (99999, 100.00);  
-- Should fail with foreign key error

-- Test unique constraints
INSERT INTO users (email) VALUES ('existing@email.com');
-- Should fail if email already exists

Backup and Maintenance Schedule

Automated Backup Strategy

Set up regular PostgreSQL backups (pg_dump best practices, PostgreSQL backup strategies):

#!/bin/bash
## daily_backup.sh - Place in /etc/cron.daily/

BACKUP_DIR="/backups/postgresql"
DB_NAME="migrated_db"
DB_USER="migration_user"
DATE=$(date +%Y%m%d_%H%M%S)

## Create backup directory
mkdir -p $BACKUP_DIR

## Create database backup
pg_dump -U $DB_USER -h localhost $DB_NAME | gzip > $BACKUP_DIR/backup_${DB_NAME}_${DATE}.sql.gz

## Keep only last 7 days of backups
find $BACKUP_DIR -name "backup_${DB_NAME}_*.sql.gz" -mtime +7 -delete

## Log backup completion
echo "$(date): Backup completed for $DB_NAME" >> /var/log/postgresql_backup.log

Make script executable and test:

sudo chmod +x /etc/cron.daily/daily_backup.sh
sudo /etc/cron.daily/daily_backup.sh  # Test manually

Regular Maintenance Tasks

Weekly maintenance script:

-- maintenance.sql - Run weekly via cron
-- Update table statistics
ANALYZE;

-- Clean up deleted rows and update indexes  
VACUUM ANALYZE;

-- For tables with frequent updates/deletes, use FULL vacuum monthly
-- VACUUM FULL large_table_name;  -- This locks the table

-- Check for bloated indexes
SELECT schemaname, tablename, indexname, 
       pg_size_pretty(pg_relation_size(indexname::regclass)) as index_size
FROM pg_indexes 
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexname::regclass) DESC;

Execute via cron weekly:

## Add to root's crontab (sudo crontab -e)
0 2 * * 0 psql -U migration_user -d migrated_db -f /path/to/maintenance.sql

Monitoring and Alerting

Set up basic monitoring for your PostgreSQL instance:

PostgreSQL Database Performance Dashboard

1. Disk space monitoring:

-- Check database sizes
SELECT datname, pg_size_pretty(pg_database_size(datname)) 
FROM pg_database 
WHERE datistemplate = false;

-- Check table sizes
SELECT schemaname, tablename, 
       pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables 
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

2. Connection monitoring:

-- Monitor active connections
SELECT count(*) as active_connections,
       state,
       wait_event_type
FROM pg_stat_activity 
WHERE state IS NOT NULL
GROUP BY state, wait_event_type;

-- Check for long-running queries (over 5 minutes)
SELECT pid, now() - query_start as duration, query 
FROM pg_stat_activity 
WHERE state = 'active' 
AND now() - query_start > interval '5 minutes';

Your PostgreSQL database is now optimized and ready for production use. Regular monitoring and maintenance will keep it performing well as your data grows.

One last thing: Put a damn reminder in your calendar for 3 months from now to review this config. What works for 10GB turns to shit at 100GB. I've watched teams celebrate their "successful" migration, then come crying 6 months later because their queries crawled to a death. Don't be me circa 2022 when I ignored this and spent 4 hours at 2am figuring out why our entire app ground to a halt.

SQL Query Optimization

When Everything Goes to Shit: Advanced Troubleshooting

Q

My app shows "relation does not exist" errors after migration. WTF?

A

This is the #1 error everyone hits. Postgre

SQL is case-sensitive and your app isn't.

I see this every single migration

  • doesn't matter if you're using Rails, Django, or hand-rolled SQL. What's happening: Postgre

SQL converted your MySQL table Users to lowercase users, but your application is still looking for Users.

The exact error you'll see: ``` ERROR: relation "Users" does not exist LINE 1:

SELECT * FROM Users WHERE id = 1; ^ HINT: Perhaps you meant to reference the relation "users". Quick diagnosis:sql -- See what table names actually exist in PostgreSQL SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'; -- Spoiler alert: everything is lowercase now -- users, orders, products, not Users, Orders, Products Fix it the right way: 1. **Update your application code:**php // Stop doing this $query = "SELECT * FROM Users WHERE Status = 'active'"; // Start doing this $query = "SELECT * FROM users WHERE status = 'active'"; 2. **Or create views as a band-aid** (I did this for our legacy app):sql CREATE VIEW "Users" AS SELECT * FROM users; CREATE VIEW "Orders" AS SELECT * FROM orders; -- This works but it's technical debt ```

Q

PostgreSQL queries are slow as shit compared to MySQL. What gives?

A

Yeah, this one always freaks people out: This fucked with my head too. Our main dashboard went from loading in 2 seconds to taking 45 seconds.

I was literally drafting the rollback email until I figured out PostgreSQL was just being stupid about indexes. Here's what fixed it: Step 1

  • PostgreSQL doesn't know shit about your data yet: sql -- Update query planner statistics (this is critical) ANALYZE VERBOSE; -- Took 20 minutes on our 40GB database but queries got 10x faster Step 2
  • Check if pgloader created the right indexes: sql -- List what indexes actually exist SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY tablename; -- I found pgloader missed 3 critical indexes on our orders table Step 3
  • See what the query planner is actually doing: ```sql -- This shows you why your query sucks EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 123 AND status = 'pending'; -- Look for "Seq Scan"
  • that means no index is being used ``` Step 4
  • Create the missing indexes: sql -- Foreign key indexes (pgloader sometimes misses these) CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id); -- Multi-column indexes for common query patterns CREATE INDEX CONCURRENTLY idx_orders_user_status ON orders(user_id, status); -- Tell PostgreSQL to gather more statistics on important columns ALTER TABLE orders ALTER COLUMN user_id SET STATISTICS 1000; ANALYZE orders;
Q

How do I rollback when everything goes to hell?

A

**Don't panic.

I've been here 3 times.** Well, 4 if you count the time I nuked the wrong database. But we don't talk about that. If you catch it fast (within a few hours): 1. STOP YOUR APPLICATION IMMEDIATELY

  • don't let users create more inconsistent data 2. Nuke the PostgreSQL database: sql DROP DATABASE inventory_prod; CREATE DATABASE inventory_prod OWNER app_user; 3. **Restore My

SQL from your backup** (you did make a backup, right?): bash mysql -u root -p inventory_db < mysql_backup_20250909.sql 4. Point your app back to MySQL and pretend nothing happened For partial rollback (keep both databases): 1. Keep PostgreSQL running but switch application to MySQL 2. Fix issues in PostgreSQL while MySQL handles traffic 3. Re-run migration once issues are resolved

Q

Can I migrate incrementally? I don't want downtime.

A

**Option 1

  • Using Foreign Data Wrapper (Advanced):** sql -- Install mysql_fdw extension CREATE EXTENSION mysql_fdw; -- Create foreign server CREATE SERVER mysql_server FOREIGN DATA WRAPPER mysql_fdw OPTIONS (host 'mysql_host', port '3306'); -- Map user credentials CREATE USER MAPPING FOR migration_user SERVER mysql_server OPTIONS (username 'mysql_user', password 'mysql_pass'); -- Import schema from MySQL IMPORT FOREIGN SCHEMA mysql_db FROM SERVER mysql_server INTO public; -- Gradually copy data table by table INSERT INTO local_users SELECT * FROM mysql_users; INSERT INTO local_orders SELECT * FROM mysql_orders; **Option 2
  • Using Skyvia or similar tools:**
  • Set up ongoing synchronization between MySQL and PostgreSQL
  • Switch applications gradually to PostgreSQL
  • Monitor data consistency during transition
Q

How do I convert my MySQL stored procedures to PostgreSQL?

A

Common conversion patterns: MySQL function: sql DELIMITER $$ CREATE FUNCTION GetUserOrderCount(user_id INT) RETURNS INT READS SQL DATA BEGIN DECLARE order_count INT DEFAULT 0; SELECT COUNT(*) INTO order_count FROM orders WHERE orders.user_id = user_id; RETURN order_count; END$$ DELIMITER ; PostgreSQL equivalent: sql CREATE OR REPLACE FUNCTION get_user_order_count(p_user_id INTEGER) RETURNS INTEGER AS $$ DECLARE order_count INTEGER := 0; BEGIN SELECT COUNT(*) INTO order_count FROM orders WHERE user_id = p_user_id; RETURN order_count; END; $$ LANGUAGE plpgsql; Key differences:

  • PostgreSQL uses $$ ... $$ instead of DELIMITER
  • Variable declarations include data type
  • Function names typically use snake_case
  • Parameter names often prefixed (p_user_id)
Q

What about my MySQL triggers? Do I need to recreate them?

A

Yes, triggers must be manually recreated. pgloader does not migrate triggers. MySQL trigger example: sql CREATE TRIGGER update_user_modified BEFORE UPDATE ON users FOR EACH ROW SET NEW.modified_at = NOW(); PostgreSQL equivalent: sql -- Create trigger function first CREATE OR REPLACE FUNCTION update_modified_time() RETURNS TRIGGER AS $$ BEGIN NEW.modified_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; -- Then create trigger CREATE TRIGGER update_user_modified BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_modified_time();

Q

How do I handle MySQL's GROUP_CONCAT function in PostgreSQL?

A

MySQL: sql SELECT user_id, GROUP_CONCAT(product_name) as products FROM orders o JOIN order_items oi ON o.id = oi.order_id JOIN products p ON oi.product_id = p.id GROUP BY user_id; PostgreSQL equivalent: sql SELECT user_id, STRING_AGG(product_name, ',') as products FROM orders o JOIN order_items oi ON o.id = oi.order_id JOIN products p ON oi.product_id = p.id GROUP BY user_id; With custom separator: ```sql -- Postgre

SQL allows custom separators SELECT user_id, STRING_AGG(product_name, ' | ' ORDER BY product_name) as products FROM orders o JOIN order_items oi ON o.id = oi.order_id JOIN products p ON oi.product_id = p.id GROUP BY user_id; ```

Q

Should I keep MySQL running after migration?

A

Hell yes, for at least a month. I learned this after a migration went sideways 3 weeks later. My standard approach: 1. Week 1-2:

Keep MySQL in read-only mode as insurance 2. Week 3-4: Monitor PostgreSQL like a hawk

  • users will find weird edge cases 3. Month 2:

If no major issues, shut down MySQL 4. Month 3: Delete MySQL data (but keep one final backup) Put MySQL in read-only mode: sql -- Prevent any accidental writes to MySQL SET GLOBAL read_only = ON; SET GLOBAL super_read_only = ON; -- Now only superusers can write (that's just you) Why I always do this:

  • Users find the weirdest bugs 2-3 weeks after "successful" migrations
  • PostgreSQL performance sometimes degrades as data grows
  • You'll sleep better knowing you can rollback quickly
  • Gives you time to optimize PostgreSQL without pressure When to finally kill MySQL:
  • Postgre

SQL has been rock solid for 30+ days

  • Performance is same or better than MySQL
  • Your backups and monitoring work
  • You've tested disaster recovery scenarios
  • Your team is comfortable with PostgreSQL troubleshooting Turning off that MySQL server hits different. Like finally throwing away the boxes after moving
  • you're committed now and it feels fucking liberating. Until 8 months later when accounting asks about some ancient transaction and you realize that "one final backup" you thought you kept got deleted during server cleanup. But hey, at least PostgreSQL hasn't shit the bed once since then.