Skip to content
Guide · Migrate between databases

MySQL to PostgreSQL Migration Guide

pgloader can move a MySQL database to PostgreSQL in one command: it reads the MySQL catalogue, creates the tables and indexes, converts the types and streams the data with COPY. This guide shows how to run it, what it converts and what it leaves to you (views, triggers, procedures, application SQL), the MySQL and PostgreSQL syntax differences you will meet, and the queries to validate the result.

Steps checked 8 October 2026 against the official documentation for each product. Versions covered: MySQL 8.4 LTS (and MariaDB) as source, PostgreSQL 18 (18.6) as target, pgloader 3.6.9 (v4 development build noted), AWS DMS 3.5.x/3.6.x. Next review due April 2027. Installers, versions and download pages change; follow the official page if a step differs.
Short answer
  • pgloader is the standard free route: one command with a MySQL and a PostgreSQL connection string creates the tables, indexes and foreign keys, loads the data and resets sequences.
  • pgloader does not migrate views or triggers, and it never converts stored procedures; plan to rewrite those, and the application's MySQL-specific SQL, by hand.
  • Type changes to expect: AUTO_INCREMENT becomes an identity or serial column, TINYINT(1) becomes boolean, UNSIGNED integers move up a size, DATETIME becomes timestamptz, and inline ENUMs become separate enum types.
  • Syntax changes to expect: backticks become double quotes, ON DUPLICATE KEY UPDATE becomes ON CONFLICT, IFNULL becomes COALESCE and GROUP_CONCAT becomes string_agg.
  • MySQL "zero dates" (0000-00-00) are not valid in PostgreSQL; pgloader's default rules turn them into NULL.
How we know: Research-based: pgloader behaviour and casting rules were checked against the pgloader documentation and release page, MySQL behaviour against the MySQL 8.4 Reference Manual, and PostgreSQL syntax against the PostgreSQL 18 documentation on 8 October 2026. The commands and SQL follow the documented syntax but were not run for this guide; test on a copy of your data first.

Before you migrate from MySQL to PostgreSQL

A MySQL to PostgreSQL migration is mostly mechanical for tables and data and mostly manual for code. Before starting, list each MySQL database and its size, the views, triggers, stored procedures and events, the SQL modes the server runs with, and every application and report that connects. Read the MySQL vs PostgreSQL comparison for the engine differences that affect design, and the database migration guide for the overall process.

One structural difference shapes the result: a MySQL database maps naturally to a PostgreSQL schema. pgloader's own example migrates the MySQL sakila database and renames the resulting schema with ALTER SCHEMA 'sakila' RENAME TO 'pagila', so decide early whether each MySQL database becomes a schema in one PostgreSQL database or a database of its own.

Migrating with pgloader, step by step

pgloader is open source; the latest stable release is 3.6.9. Its documentation now recommends a Java-based v4 for new deployments, published so far only as a development build, so the steps below use the v3 packages.

  1. Install pgloader on a machine that can reach both servers: from the PostgreSQL apt repository or Debian with apt-get install pgloader, from yum.postgresql.org on RPM systems, or with the dimitri/pgloader Docker image.
  2. Create the empty target database in PostgreSQL and a user that owns it.
  3. Run a first migration with the one-line command below against a copy or a quiet source. By default pgloader drops and recreates the target tables (include drop), creates indexes and foreign keys, downcases identifiers and resets sequences.
  4. Read the summary report pgloader prints at the end: for each table, the rows read, rows imported, errors and time taken.
  5. Refine with a load file when you need to rename the schema, change casting rules, include or exclude tables, or materialise views as tables.
  6. Recreate what pgloader skips: views, triggers, procedures and events, written in PostgreSQL SQL or PL/pgSQL.
Check the installation and run the simplest migration (bash)
pgloader --version

# one command: MySQL database "shop" into PostgreSQL database "shop"
pgloader mysql://<mysql-user>:<your-password>@<mysql-host>/shop \
         pgsql://<pg-user>:<your-password>@<pg-host>/shop

# the same with the official Docker image
docker run --rm -it dimitri/pgloader:latest pgloader --version
Load file shop.load (run with: pgloader shop.load)
LOAD DATABASE
     FROM mysql://<mysql-user>:<your-password>@<mysql-host>/shop
     INTO postgresql://<pg-user>:<your-password>@<pg-host>/shop

WITH include drop, create tables, create indexes, reset sequences,
     workers = 8, concurrency = 1

CAST type date drop not null drop default using zero-dates-to-null

ALTER SCHEMA 'shop' RENAME TO 'public';

What pgloader does not migrate

The pgloader documentation lists the limits for MySQL sources: views are not migrated (you can materialise chosen views as tables with MATERIALIZE VIEWS), triggers are not migrated, and of the geometric types only POINT is fully covered. Stored procedures and functions are outside pgloader's scope. For a managed alternative that converts code as well, DMS Schema Conversion supports a MySQL to PostgreSQL path, and AWS DMS can then copy the data with ongoing change capture; see the AWS DMS guide.

MySQL to PostgreSQL data types

These are pgloader's default casting rules for MySQL, which you can override with CAST in a load file. If you write the PostgreSQL schema yourself, modern PostgreSQL prefers GENERATED BY DEFAULT AS IDENTITY to the older serial types.

MySQL types and pgloader's default PostgreSQL types
MySQLpgloader defaultNotes
INT AUTO_INCREMENTserial (bigserial for wider columns)Or an identity column; reset after loading
BIGINT AUTO_INCREMENTbigserialOr bigint GENERATED BY DEFAULT AS IDENTITY
TINYINT(1), BOOL, BOOLEANbooleanIn MySQL, BOOL and BOOLEAN are synonyms for TINYINT(1)
INT UNSIGNEDbigintPostgreSQL has no unsigned integers, so unsigned types move up one size
DECIMAL(p,s), NUMERICdecimal / numeric, precision kept
DOUBLE, FLOATdouble precision, float
VARCHAR(n), CHAR(n)varchar(n), char(n)Null characters are removed during the load
TEXT, MEDIUMTEXT, LONGTEXTtextPostgreSQL text has no length limit
BLOB, LONGBLOB, VARBINARYbytea
DATETIME, TIMESTAMPtimestamptzZero dates become NULL; choose timestamp if values are local times
DATEdateZero-date defaults are dropped and values set to NULL
YEARinteger
ENUM('a','b')a new enum type named after the table and columnPostgreSQL enums are created with CREATE TYPE

Converting MySQL queries to PostgreSQL

There is no official MySQL to PostgreSQL query converter from either project, and online converters are unofficial; treat their output as a draft. The differences below cover most application queries. In particular, MySQL quotes identifiers with backticks, while PostgreSQL uses double quotes and folds unquoted names to lower case, which is why pgloader downcases identifiers by default.

MySQL syntax and PostgreSQL equivalents
MySQLPostgreSQLNote
`order`"order"Avoid quoted mixed-case names in PostgreSQL
AUTO_INCREMENT, LAST_INSERT_ID()Identity column, INSERT ... RETURNING id
INSERT ... ON DUPLICATE KEY UPDATEINSERT ... ON CONFLICT (key) DO UPDATE SET col = EXCLUDED.colEXCLUDED is the row proposed for insertion
IFNULL(a, b)COALESCE(a, b)COALESCE works in both
GROUP_CONCAT(x ORDER BY x SEPARATOR ', ')string_agg(x, ', ' ORDER BY x)
LIMIT 10 OFFSET 20LIMIT 10 OFFSET 20Use the LIMIT n OFFSET m form in both
NOW()now()PostgreSQL returns timestamp with time zone
Zero date '0000-00-00'NULLNot a valid PostgreSQL date
ENUM columnCREATE TYPE ... AS ENUM, then use the type

Worked example: a table, an upsert and validation queries

Compare row counts and key ranges for every table, and a few aggregates for important columns. MySQL's CHECKSUM TABLE reports a checksum for a table's contents, but there is no matching PostgreSQL command and the values would not be comparable across engines, so rely on counts, sums and sampled rows instead. After a pgloader run with reset sequences, check that the next generated id is above the highest copied id before the application starts inserting.

MySQL 8.4 source
CREATE TABLE customers (
    id         INT UNSIGNED NOT NULL AUTO_INCREMENT,
    email      VARCHAR(255) NOT NULL,
    is_active  TINYINT(1)   NOT NULL DEFAULT 1,
    plan       ENUM('free','pro','team') NOT NULL DEFAULT 'free',
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_customers_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO customers (email, plan) VALUES ('ana@example.com', 'pro') AS new
ON DUPLICATE KEY UPDATE plan = new.plan;
PostgreSQL 18 target (hand-written equivalent)
CREATE TYPE customer_plan AS ENUM ('free', 'pro', 'team');

CREATE TABLE customers (
    id         bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    email      varchar(255)  NOT NULL UNIQUE,
    is_active  boolean       NOT NULL DEFAULT true,
    plan       customer_plan NOT NULL DEFAULT 'free',
    created_at timestamp     NOT NULL DEFAULT now()
);

INSERT INTO customers (email, plan) VALUES ('ana@example.com', 'pro')
ON CONFLICT (email) DO UPDATE SET plan = EXCLUDED.plan;
Validation: run on both servers and compare (MySQL and PostgreSQL)
SELECT COUNT(*)  AS row_count,
       MIN(id)   AS min_id,
       MAX(id)   AS max_id,
       SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS active_rows   -- MySQL
FROM customers;

SELECT COUNT(*)  AS row_count,
       MIN(id)   AS min_id,
       MAX(id)   AS max_id,
       COUNT(*) FILTER (WHERE is_active) AS active_rows                -- PostgreSQL
FROM customers;

Cutover, and the next steps

  • Rehearse the full pgloader run on a copy and time it; that time, plus validation, is your downtime for an offline cutover.
  • Freeze writes on MySQL, run the final load, validate, then switch the application's driver and connection string to PostgreSQL.
  • Keep MySQL unchanged until the agreed rollback window closes; take a mysqldump backup before you start.
  • For little downtime, use a replication-based tool instead, such as AWS DMS with full load plus CDC into Amazon RDS or Aurora PostgreSQL. The RDS MySQL vs RDS PostgreSQL comparison covers the managed-service side.

To work with the new database, see pgAdmin or DBeaver, and for a refresher on the SQL used in these checks, the SQL beginner course.

Frequently asked questions

How do I convert a MySQL database to PostgreSQL?

Install pgloader and run pgloader mysql://user@host/db pgsql://user@host/db. It creates the tables, indexes and foreign keys, converts types and loads the data. Then recreate views, triggers and procedures by hand and update the application's SQL.

Is there a MySQL to PostgreSQL query converter?

Neither MySQL nor PostgreSQL publishes one. Unofficial online converters exist; use their output only as a starting point and test every query. For code inside the database, DMS Schema Conversion offers a managed MySQL to PostgreSQL conversion path on AWS.

What happens to AUTO_INCREMENT columns?

pgloader converts them to serial or bigserial columns backed by sequences and resets the sequences after loading. If you write the schema yourself, use an identity column (GENERATED BY DEFAULT AS IDENTITY) and set it past the highest copied value.

Does pgloader migrate MySQL views and triggers?

No. Its documentation lists views and triggers as not migrated. You can materialise selected views as tables during the load, but recreating them as real views has to be done manually.

How do I handle MySQL zero dates in PostgreSQL?

PostgreSQL rejects 0000-00-00. pgloader's default casting rules convert zero dates to NULL and drop zero-date defaults. MySQL 8.4's default SQL mode already includes NO_ZERO_DATE, so newer data rarely contains them; older tables may.

Sources

Checked 8 October 2026.

How we research guides: our editorial method. We link only to official downloads and never host installers.

Database installed?

Write your first queries with the free beginner course, then practise on real problems.