MySQL → PostgreSQL SQL translator
Paste MySQL queries or Laravel DB::raw / whereRaw / selectRaw fragments. You get Postgres SQL and a short reason for every change. Runs in your browser; nothing is uploaded.
Raw SQL is one part. The Migration Kit ($49, $34.30 with LAUNCH30 until Nov 5) covers the schema, data fixes, rehearsal, cutover and rollback. Already on Postgres and seeing errors: the Error Fix Pack ($19) has tested fixes for the sequence, boolean = integer, out-of-range, zero-date, TIMESTAMP-shift and GROUP BY errors.
Examples (each one tested)
Every pair below ran on MariaDB 11.8 (MySQL side) and Postgres 17.11 against the same data, and both returned the same rows. For the ON CONFLICT ones the conflict column was filled in as the tool tells you to.
IFNULL, backticks, double-quoted strings, LIMIT offset, count
SELECT `id`, IFNULL(`name`, "n/a") AS name FROM `users` WHERE `active` = 1 ORDER BY `id` LIMIT 1, 2
SELECT "id", COALESCE("name", 'n/a') AS name FROM "users" WHERE "active" = true ORDER BY "id" LIMIT 2 OFFSET 1GROUP_CONCAT with DISTINCT, ORDER BY and SEPARATOR → string_agg
SELECT user_id, GROUP_CONCAT(DISTINCT status ORDER BY status SEPARATOR ', ') AS statuses FROM orders GROUP BY user_id ORDER BY user_id
SELECT user_id, string_agg(DISTINCT status::text, ', ' ORDER BY status::text) AS statuses FROM orders GROUP BY user_id ORDER BY user_id
DATE_FORMAT → to_char
SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s'), DATE_FORMAT(created_at, '%b %e, %Y'), DATE_FORMAT(created_at, '%d/%m/%y %h:%i %p'), DATE_FORMAT(created_at, '%W %M week of %Y') FROM orders ORDER BY id
SELECT to_char(created_at, 'YYYY-MM-DD HH24:MI:SS'), to_char(created_at, 'Mon FMDD, YYYY'), to_char(created_at, 'DD/MM/YY HH12:MI AM'), to_char(created_at, 'FMDay FMMonth" week of "YYYY') FROM orders ORDER BY id
IF() → CASE WHEN, tinyint(1) = 1 → boolean
SELECT id, IF(total > 50, 'big', 'small') AS size, IF(paid = 1, "yes", "no") AS p FROM orders ORDER BY id
SELECT id, CASE WHEN total > 50 THEN 'big' ELSE 'small' END AS size, CASE WHEN paid = true THEN 'yes' ELSE 'no' END AS p FROM orders ORDER BY id
LIKE on a _ci column → ILIKE
SELECT COUNT(*) FROM users WHERE name LIKE '%ALI%' OR email LIKE "BOB%"
SELECT COUNT(*) FROM users WHERE name ILIKE '%ALI%' OR email ILIKE 'BOB%'
UNIX_TIMESTAMP / FROM_UNIXTIME
SELECT UNIX_TIMESTAMP('2026-01-01 00:00:00'), FROM_UNIXTIME(1767225600, '%Y-%m-%d %H:%i'), UNIX_TIMESTAMP(created_at) FROM users ORDER BY idSELECT extract(epoch FROM '2026-01-01 00:00:00'::timestamptz)::bigint, to_char(to_timestamp(1767225600), 'YYYY-MM-DD HH24:MI'), extract(epoch FROM created_at)::bigint FROM users ORDER BY id
DATEDIFF, DATE_ADD, INTERVAL 1 DAY
SELECT id, DATEDIFF(created_at, '2026-01-01'), DATE(DATE_ADD(created_at, INTERVAL 7 DAY)), DATE_SUB(created_at, INTERVAL 2 HOUR) FROM users WHERE created_at > '2026-01-01' - INTERVAL 1 DAY ORDER BY id
SELECT id, (created_at::date - '2026-01-01'::date), DATE((created_at + interval '7 day')), (created_at - interval '2 hour') FROM users WHERE created_at > '2026-01-01'::timestamp - interval '1 day' ORDER BY id
ORDER BY FIELD()
SELECT id FROM users ORDER BY FIELD(id, 3, 1, 2), id
SELECT id FROM users ORDER BY COALESCE(array_position(ARRAY[3, 1, 2], id), 0), id
FIND_IN_SET in WHERE
SELECT id FROM users WHERE FIND_IN_SET('php', tags) ORDER BY idSELECT id FROM users WHERE (COALESCE(array_position(string_to_array(tags, ','), ('php')::text), 0) > 0) ORDER BY idINSERT IGNORE → ON CONFLICT DO NOTHING
INSERT IGNORE INTO users (id, name, email) VALUES (1, 'dup', 'dup@x.io'), (5, 'Dan', 'dan@x.io')
INSERT INTO users (id, name, email) VALUES (1, 'dup', 'dup@x.io'), (5, 'Dan', 'dan@x.io') ON CONFLICT DO NOTHING
ON DUPLICATE KEY UPDATE … VALUES() → ON CONFLICT … DO UPDATE … EXCLUDED
INSERT INTO stats (day, hits) VALUES ('2026-01-01', 5), ('2026-01-03', 1) ON DUPLICATE KEY UPDATE hits = hits + VALUES(hits)INSERT INTO stats (day, hits) VALUES ('2026-01-01', 5), ('2026-01-03', 1) ON CONFLICT (day) DO UPDATE SET hits = stats.hits + EXCLUDED.hitsREPLACE INTO
REPLACE INTO stats (day, hits) VALUES ('2026-01-02', 9), ('2026-01-04', 2)INSERT INTO stats (day, hits) VALUES ('2026-01-02', 9), ('2026-01-04', 2) ON CONFLICT (day) DO UPDATE SET day = EXCLUDED.day, hits = EXCLUDED.hitsJSON_UNQUOTE(JSON_EXTRACT()) → #>>
SELECT id, JSON_UNQUOTE(JSON_EXTRACT(meta, '$.plan')) AS plan FROM users WHERE JSON_UNQUOTE(JSON_EXTRACT(meta, '$.plan')) = 'pro' ORDER BY id
SELECT id, (meta #>> '{plan}') AS plan FROM users WHERE (meta #>> '{plan}') = 'pro' ORDER BY idTIMESTAMPDIFF
SELECT id, TIMESTAMPDIFF(HOUR, '2026-01-01 00:00:00', created_at), TIMESTAMPDIFF(MONTH, '2026-01-01 00:00:00', created_at), TIMESTAMPDIFF(DAY, created_at, '2026-04-01 00:00:00') FROM users ORDER BY id
SELECT id, trunc(extract(epoch FROM (created_at - '2026-01-01 00:00:00')) / 3600)::bigint, (extract(year FROM age(created_at, '2026-01-01 00:00:00')) * 12 + extract(month FROM age(created_at, '2026-01-01 00:00:00')))::int, trunc(extract(epoch FROM ('2026-04-01 00:00:00' - created_at)) / 86400)::bigint FROM users ORDER BY idWhat it converts
Backticks → double quotes · "strings" → 'strings' · backslash escapes · IFNULL → COALESCE · IF() → CASE · GROUP_CONCAT → string_agg · DATE_FORMAT / STR_TO_DATE / FROM_UNIXTIME formats → to_char / to_date patterns · NOW(), CURDATE(), CURTIME(), SYSDATE(), UTC_TIMESTAMP() · UNIX_TIMESTAMP · DATEDIFF, DATE_ADD/DATE_SUB, INTERVAL 30 DAY, TIMESTAMPDIFF, YEAR()/MONTH()/DAY() · LIMIT offset, count · INSERT IGNORE · ON DUPLICATE KEY UPDATE + VALUES() · REPLACE INTO (with warning) · col = 1 on boolean-looking columns · LIKE → ILIKE · REGEXP → ~* · || and && as OR/AND · <=> · FIELD() · FIND_IN_SET · SUBSTRING_INDEX · LOCATE / INSTR · JSON_EXTRACT and '$.a.b' paths · CAST AS UNSIGNED/SIGNED/CHAR/DATETIME · RAND() · index hints, SQL_CALC_FOUND_ROWS · # comments. It warns about CONCAT/GREATEST NULL behaviour, LENGTH bytes vs characters, GROUP BY strictness, @variables, UPDATE/DELETE … LIMIT and zero dates.
It is a rule-based rewriter, not a full SQL parser. Read the output and run it against a copy of your data before you ship it.
FAQ
How do I replace GROUP_CONCAT in Postgres?
Use string_agg(col::text, ',' ORDER BY col). GROUP_CONCAT(DISTINCT x ORDER BY x SEPARATOR '; ') becomes string_agg(DISTINCT x::text, '; ' ORDER BY x::text): with DISTINCT the ORDER BY must be the same expression. Postgres has no group_concat_max_len limit.
What is IFNULL in Postgres?
COALESCE(a, b). It works on MySQL too, so you can switch before the migration.
How do I convert DATE_FORMAT to Postgres?
to_char(value, pattern) with different letters: %Y → YYYY, %m → MM, %d → DD, %H → HH24, %i → MI, %s → SS, %b → Mon, %M → FMMonth, %e → FMDD, %p → AM. Literal words inside the pattern must be double-quoted.
Does LIMIT 10, 20 work on Postgres?
No. LIMIT offset, count is MySQL-only. Write LIMIT 20 OFFSET 10. If the values are bound (LIMIT ?, ?), swap the bindings too.
What replaces INSERT IGNORE and ON DUPLICATE KEY UPDATE?
INSERT ... ON CONFLICT DO NOTHING, and INSERT ... ON CONFLICT (unique_column) DO UPDATE SET col = EXCLUDED.col. Postgres needs the conflict column(s) named and a unique index on them. A bare column on the right-hand side (hits = hits + 1) is ambiguous on Postgres; write table.hits. In Laravel, upsert() generates this for you.
Why does my search return fewer rows after moving to Postgres?
MySQL _ci collations make LIKE and = case-insensitive. Postgres is case-sensitive. Use ILIKE, or lower(col) = lower(?) with an index on lower(col).
Why does WHERE active = 1 fail on Postgres?
tinyint(1) columns usually become boolean, and boolean = integer is error 42883. Write active = true in raw SQL. A bound value from Laravel ->where('active', 1) is accepted.
Is my SQL uploaded?
No. The translator is plain JavaScript that runs in your browser. Nothing is sent anywhere.
Not moved yet? Check the schema first with the free schema risk check (paste SHOW CREATE TABLE) and the free read-only data checker. Already moved and seeing an error: paste the error, get the fix.