MySQL → PostgreSQL schema converter

Paste SHOW CREATE TABLE output, mysqldump --no-data or a phpMyAdmin structure export. You get PostgreSQL DDL in run order (tables, indexes, triggers, foreign keys, sequence resets) and a note for every change that can bite. Runs in your browser; nothing is uploaded.


Examples (each one tested)

Each MySQL schema below was loaded into MariaDB 11.8, dumped, converted and run on PostgreSQL 17.11; the rows were copied across and inserts, updates, the CHECK, the foreign key, the trigger and the full-text index were exercised.

Laravel users table (SHOW CREATE TABLE / mysqldump)
MySQL
CREATE TABLE `users` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `email` varchar(255) NOT NULL,
  `email_verified_at` timestamp NULL DEFAULT NULL,
  `password` varchar(255) NOT NULL,
  `is_admin` tinyint(1) NOT NULL DEFAULT 0,
  `remember_token` varchar(100) DEFAULT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `users_email_unique` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=1042 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
PostgreSQL
CREATE TABLE users (
  id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
  name varchar(255) NOT NULL,
  email varchar(255) NOT NULL,
  email_verified_at timestamp(0),
  password varchar(255) NOT NULL,
  is_admin boolean NOT NULL DEFAULT false,
  remember_token varchar(100),
  created_at timestamp(0),
  updated_at timestamp(0),
  PRIMARY KEY (id)
);

-- Indexes
CREATE UNIQUE INDEX users_email_unique ON users (email);

-- After loading data: move each identity sequence past the copied ids, or the first insert fails with "duplicate key value violates unique constraint"
SELECT setval(pg_get_serial_sequence('users', 'id'), GREATEST(COALESCE(MAX(id), 0) + 1, 1042), false) FROM users;
ENUM, SET, unsigned ints, zero-date default, ON UPDATE CURRENT_TIMESTAMP, prefix + FULLTEXT index, foreign key
MySQL
CREATE TABLE `orders` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) unsigned NOT NULL,
  `status` enum('pending','paid','refunded') NOT NULL DEFAULT 'pending',
  `flags` set('gift','rush') DEFAULT NULL,
  `qty` smallint(5) unsigned NOT NULL DEFAULT 1,
  `code` char(8) DEFAULT NULL,
  `note` mediumtext DEFAULT NULL,
  `meta` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`meta`)),
  `shipped_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `updated_at` datetime(3) NOT NULL DEFAULT current_timestamp(3) ON UPDATE current_timestamp(3),
  PRIMARY KEY (`id`),
  KEY `idx_code` (`code`(4)),
  FULLTEXT KEY `ft_note` (`note`),
  CONSTRAINT `orders_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
PostgreSQL
CREATE TABLE orders (
  id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
  user_id bigint NOT NULL,
  status varchar(8) NOT NULL DEFAULT 'pending',
  flags text,
  qty integer NOT NULL DEFAULT 1,
  code char(8),
  note text,
  meta jsonb,
  shipped_at timestamp(0),
  updated_at timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  CHECK (status IN ('pending', 'paid', 'refunded'))
);

-- Indexes
CREATE INDEX idx_code ON orders (code);
CREATE INDEX orders_note_fulltext ON orders USING gin (to_tsvector('english', coalesce(note, '')));

-- ON UPDATE CURRENT_TIMESTAMP replacement
CREATE OR REPLACE FUNCTION tuskshift_touch() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  NEW := jsonb_populate_record(NEW, jsonb_build_object(TG_ARGV[0], CURRENT_TIMESTAMP));
  RETURN NEW;
END $$;
CREATE TRIGGER orders_updated_at_touch BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION tuskshift_touch('updated_at');

-- Foreign keys (add after the data copy; MyISAM or FOREIGN_KEY_CHECKS=0 imports can contain orphans)
ALTER TABLE orders ADD CONSTRAINT orders_user_id_foreign FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;

-- After loading data: move each identity sequence past the copied ids, or the first insert fails with "duplicate key value violates unique constraint"
SELECT setval(pg_get_serial_sequence('orders', 'id'), COALESCE(MAX(id), 0) + 1, false) FROM orders;
phpMyAdmin export (keys and AUTO_INCREMENT in separate ALTER TABLE statements)
MySQL
CREATE TABLE `posts` (
  `id` int(10) UNSIGNED NOT NULL,
  `slug` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,
  `published` tinyint(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

ALTER TABLE `posts`
  ADD PRIMARY KEY (`id`),
  ADD UNIQUE KEY `posts_slug_unique` (`slug`);

ALTER TABLE `posts`
  MODIFY `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
PostgreSQL
CREATE TABLE posts (
  id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
  slug varchar(191) NOT NULL,
  published boolean NOT NULL DEFAULT true,
  PRIMARY KEY (id)
);

-- Indexes
CREATE UNIQUE INDEX posts_slug_unique ON posts (slug);

-- After loading data: move each identity sequence past the copied ids, or the first insert fails with "duplicate key value violates unique constraint"
SELECT setval(pg_get_serial_sequence('posts', 'id'), GREATEST(COALESCE(MAX(id), 0) + 1, 3), false) FROM posts;

What it converts

AUTO_INCREMENT → GENERATED BY DEFAULT AS IDENTITY + setval() after load (respects the table's AUTO_INCREMENT=) · tinyint(1)/bool/bit(1) → boolean · tinyint → smallint · smallint unsigned → integer · int unsigned → bigint · mediumint → integer · bigint unsigned → bigint (warned) · decimal → numeric · float/double → real/double precision · datetime(n)/timestamp(n) → timestamp(n) · year → smallint · enum → varchar + CHECK · set → text (warned) · json and MariaDB longtext + json_valid → jsonb · blob/binary → bytea · zero-date defaults dropped · current_timestamp() defaults · ON UPDATE CURRENT_TIMESTAMP → trigger · COMMENT → COMMENT ON · KEY/UNIQUE KEY → CREATE INDEX (duplicate names renamed) · prefix indexes → full column (warned) · FULLTEXT → GIN to_tsvector · SPATIAL → GiST (PostGIS) · FOREIGN KEY → ALTER TABLE ADD CONSTRAINT after the data · generated columns → STORED · reserved names (order, group, user) quoted · ENGINE/CHARSET/COLLATE removed, with case-sensitivity warnings on _ci email/username/slug columns · phpMyAdmin ALTER TABLE ADD/MODIFY sections folded in.

It converts structure only. Move the data with pgloader or CSV + COPY, then run the setval lines. It is a rule-based converter, not a full SQL parser: read the output and run it on a scratch database first.

FAQ

How do I convert a MySQL schema to PostgreSQL?

Dump the schema only (mysqldump --no-data mydb, or SHOW CREATE TABLE per table, or a phpMyAdmin structure export), paste it here and copy the result. Run the CREATE TABLE part, load the data, then run the index, foreign key and setval sections. Read the notes first: some changes (TIMESTAMP shifts, case-sensitivity, unsigned overflow) are not visible in the DDL.

What is AUTO_INCREMENT in PostgreSQL?

An identity column: id bigint GENERATED BY DEFAULT AS IDENTITY (or bigserial, which Laravel uses). BY DEFAULT lets you insert the old ids during the copy. After the copy you must run setval() to move the sequence past MAX(id), otherwise the first insert fails with "duplicate key value violates unique constraint".

What does tinyint(1) become in PostgreSQL?

boolean, which is what Laravel boolean() creates on pgsql. Raw SQL like WHERE active = 1 then fails with "operator does not exist: boolean = integer"; write = true. If a tinyint(1) column really stores 0-3, keep it as smallint (untick the option).

What is int unsigned in PostgreSQL?

Postgres has no unsigned types. int unsigned goes up to 4,294,967,295, Postgres integer only to 2,147,483,647, so the converter uses bigint. bigint unsigned stays bigint with a warning: use numeric(20,0) if values can exceed 9,223,372,036,854,775,807.

How do I convert ENUM to PostgreSQL?

The converter makes a varchar with a CHECK (col IN (...)) constraint, the same thing Laravel enum() does on pgsql. A native CREATE TYPE ... AS ENUM also works but is harder to change later. Note that ORDER BY becomes alphabetical.

What replaces ON UPDATE CURRENT_TIMESTAMP?

A BEFORE UPDATE trigger. The converter emits one small generic trigger function and one trigger per column.

Why was my index renamed?

MySQL index names are per table; Postgres index names are unique per schema. Two tables with an index called idx_user would make the second CREATE INDEX fail with "relation already exists", so the converter prefixes the table name.

Is my schema uploaded?

No. The converter is plain JavaScript that runs in your browser. Nothing is sent anywhere.

Next steps: run the free schema risk check for the data-level traps, the free read-only data checker against your MySQL database, and the SQL translator for raw queries. Already moved and seeing an error: paste the error, get the fix.

Published with deed.page