Why MySQL to PostgreSQL Migrations Fail at the DDL Stage
Most MySQL to PostgreSQL migrations stall before a single row of data moves. The blocker is DDL: your CREATE TABLE statements, data types, constraints, and default expressions are all MySQL-specific. PostgreSQL rejects them.
🔄Free Tool
Convert this DDL to Snowflake, BigQuery, or PostgreSQL instantly →
This guide covers every DDL translation you'll hit during a MySQL to PostgreSQL migration, with exact syntax you can use directly.
Data Type Conversions
Numeric Types
| MySQL | PostgreSQL | Notes |
|---|---|---|
| TINYINT | SMALLINT | PostgreSQL has no TINYINT |
| TINYINT(1) | BOOLEAN | MySQL uses TINYINT(1) for booleans |
| MEDIUMINT | INTEGER | No direct equivalent; INTEGER is safe |
| INT / INTEGER | INTEGER | Direct mapping |
| BIGINT | BIGINT | Direct mapping |
| FLOAT | REAL | PostgreSQL REAL is 4-byte float |
| DOUBLE | DOUBLE PRECISION | Direct mapping |
| DECIMAL(p,s) | NUMERIC(p,s) | Direct mapping |
String Types
| MySQL | PostgreSQL | Notes |
|---|---|---|
| VARCHAR(n) | VARCHAR(n) | Direct mapping |
| TINYTEXT | TEXT | Treat all MySQL text variants as TEXT |
| TEXT | TEXT | Direct mapping |
| MEDIUMTEXT | TEXT | Direct mapping |
| LONGTEXT | TEXT | Direct mapping |
| CHAR(n) | CHAR(n) | Direct mapping |
| ENUM('a','b') | VARCHAR(n) + CHECK constraint | See below |
Handling ENUM:
-- MySQL
status ENUM('active', 'inactive', 'pending')
-- PostgreSQL option 1: CHECK constraint
status VARCHAR(10) NOT NULL CHECK (status IN ('active', 'inactive', 'pending'))
-- PostgreSQL option 2: CREATE TYPE
CREATE TYPE status_type AS ENUM ('active', 'inactive', 'pending');
Date and Time Types
| MySQL | PostgreSQL | Notes |
|---|---|---|
| DATE | DATE | Direct mapping |
| DATETIME | TIMESTAMP | No timezone |
| TIMESTAMP | TIMESTAMPTZ | MySQL TIMESTAMP stores UTC |
| YEAR | SMALLINT | No YEAR type in PostgreSQL |
Binary and JSON Types
| MySQL | PostgreSQL | Notes |
|---|---|---|
| BLOB / TINYBLOB / MEDIUMBLOB / LONGBLOB | BYTEA | PostgreSQL uses BYTEA |
| JSON | JSONB | Use JSONB — it's indexed and faster |
Auto-Increment and Sequences
-- MySQL
CREATE TABLE member (
member_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY
);
-- PostgreSQL (modern — preferred)
CREATE TABLE member (
member_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
String Functions That Change
| MySQL | PostgreSQL |
|---|---|
| IFNULL(a, b) | COALESCE(a, b) |
| IF(cond, a, b) | CASE WHEN cond THEN a ELSE b END |
| GROUP_CONCAT(col) | STRING_AGG(col, ',') |
| STR_TO_DATE(str, fmt) | TO_DATE(str, fmt) |
| DATE_FORMAT(dt, '%Y-%m') | TO_CHAR(dt, 'YYYY-MM') |
| DATEDIFF(a, b) | (a - b) |
| YEAR(dt) | EXTRACT(YEAR FROM dt) |
Index Syntax Differences
-- MySQL (inline indexes)
CREATE TABLE claim (
claim_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
member_id INT NOT NULL,
INDEX idx_member (member_id)
);
-- PostgreSQL (indexes are separate statements)
CREATE TABLE claim (
claim_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id INT NOT NULL
);
CREATE INDEX idx_member ON claim (member_id);
Case Sensitivity: The Silent Killer
PostgreSQL folds unquoted identifiers to lowercase. MySQL on macOS/Windows is case-insensitive by default.
Rule: never use quoted identifiers in PostgreSQL DDL. Convert mixed-case column names (MemberID, ClaimDate) to snake_case (member_id, claim_dt) during migration.
A Full MySQL → PostgreSQL Translation Example
-- ORIGINAL MySQL DDL
CREATE TABLE member (
MemberID INT NOT NULL AUTO_INCREMENT,
FirstName VARCHAR(100) NOT NULL,
DateOfBirth DATE NOT NULL,
Gender ENUM('M', 'F', 'U') NOT NULL DEFAULT 'U',
CreatedAt DATETIME NOT NULL DEFAULT NOW(),
UpdatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (MemberID),
INDEX idx_plan (PlanID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- TRANSLATED PostgreSQL DDL
CREATE TABLE member (
member_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
date_of_birth DATE NOT NULL,
gender VARCHAR(1) NOT NULL DEFAULT 'U' CHECK (gender IN ('M', 'F', 'U')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_member_plan ON member (plan_id);
Changes made: AUTO_INCREMENT → IDENTITY, mixed-case → snake_case, ENUM → CHECK constraint, DATETIME → TIMESTAMP, ENGINE/CHARSET clauses removed.
Automating DDL Conversion
For a schema with 50+ tables, use the DDL Converter tool to convert MySQL DDL to PostgreSQL automatically. After conversion, run the output through the SQL Linter to catch any remaining issues.
Migration Checklist
- AUTO_INCREMENT → GENERATED ALWAYS AS IDENTITY
- ENUM → VARCHAR + CHECK or CREATE TYPE
- TINYINT(1) → BOOLEAN
- BLOB → BYTEA
- JSON → JSONB
- Mixed-case identifiers → snake_case
- Inline indexes extracted to separate CREATE INDEX statements
- MySQL-specific functions replaced in views and stored procedures
- CHARACTER SET / ENGINE clauses removed
- Sequence starting values set to match migrated data max IDs
Free Tools
Ready to improve your data architecture?
Free tools for DDL conversion, SQL analysis, naming standards, and more.