- 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.
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.
- 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 thedimitri/pgloaderDocker image. - Create the empty target database in PostgreSQL and a user that owns it.
- 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. - Read the summary report pgloader prints at the end: for each table, the rows read, rows imported, errors and time taken.
- Refine with a load file when you need to rename the schema, change casting rules, include or exclude tables, or materialise views as tables.
- Recreate what pgloader skips: views, triggers, procedures and events, written in PostgreSQL SQL or PL/pgSQL.
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 --versionLOAD 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 | pgloader default | Notes |
|---|---|---|
| INT AUTO_INCREMENT | serial (bigserial for wider columns) | Or an identity column; reset after loading |
| BIGINT AUTO_INCREMENT | bigserial | Or bigint GENERATED BY DEFAULT AS IDENTITY |
| TINYINT(1), BOOL, BOOLEAN | boolean | In MySQL, BOOL and BOOLEAN are synonyms for TINYINT(1) |
| INT UNSIGNED | bigint | PostgreSQL has no unsigned integers, so unsigned types move up one size |
| DECIMAL(p,s), NUMERIC | decimal / numeric, precision kept | |
| DOUBLE, FLOAT | double precision, float | |
| VARCHAR(n), CHAR(n) | varchar(n), char(n) | Null characters are removed during the load |
| TEXT, MEDIUMTEXT, LONGTEXT | text | PostgreSQL text has no length limit |
| BLOB, LONGBLOB, VARBINARY | bytea | |
| DATETIME, TIMESTAMP | timestamptz | Zero dates become NULL; choose timestamp if values are local times |
| DATE | date | Zero-date defaults are dropped and values set to NULL |
| YEAR | integer | |
| ENUM('a','b') | a new enum type named after the table and column | PostgreSQL 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 | PostgreSQL | Note |
|---|---|---|
`order` | "order" | Avoid quoted mixed-case names in PostgreSQL |
AUTO_INCREMENT, LAST_INSERT_ID() | Identity column, INSERT ... RETURNING id | |
INSERT ... ON DUPLICATE KEY UPDATE | INSERT ... ON CONFLICT (key) DO UPDATE SET col = EXCLUDED.col | EXCLUDED 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 20 | LIMIT 10 OFFSET 20 | Use the LIMIT n OFFSET m form in both |
NOW() | now() | PostgreSQL returns timestamp with time zone |
Zero date '0000-00-00' | NULL | Not a valid PostgreSQL date |
ENUM column | CREATE 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.
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;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;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
- pgloader documentation: MySQL to Postgres
- pgloader documentation: Installing pgloader
- pgloader documentation: introduction
- pgloader documentation: tutorial
- PostgreSQL: Date/time functions
- pgloader releases (GitHub)
- AWS DMS User Guide: MySQL to PostgreSQL conversion settings
- MySQL 8.4 Reference Manual: Using AUTO_INCREMENT
- MySQL 8.4 Reference Manual: Numeric type syntax
- MySQL 8.4 Reference Manual: Schema object names
- MySQL 8.4 Reference Manual: INSERT ... ON DUPLICATE KEY UPDATE
- MySQL 8.4 Reference Manual: The ENUM type
- MySQL 8.4 Reference Manual: DATE, DATETIME and TIMESTAMP
- MySQL 8.4 Reference Manual: Server SQL modes
- MySQL 8.4 Reference Manual: Flow control functions
- MySQL 8.4 Reference Manual: Aggregate functions
- MySQL 8.4 Reference Manual: CHECKSUM TABLE
- PostgreSQL: CREATE TABLE
- PostgreSQL: INSERT (ON CONFLICT)
- PostgreSQL: Enumerated types
- PostgreSQL: Lexical structure (identifiers)
- PostgreSQL: Aggregate functions
- PostgreSQL: Conditional expressions
- PostgreSQL: Returning data from modified rows
Checked 8 October 2026.
How we research guides: our editorial method. We link only to official downloads and never host installers.