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.


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
MySQL
SELECT `id`, IFNULL(`name`, "n/a") AS name FROM `users` WHERE `active` = 1 ORDER BY `id` LIMIT 1, 2
Postgres
SELECT "id", COALESCE("name", 'n/a') AS name FROM "users" WHERE "active" = true ORDER BY "id" LIMIT 2 OFFSET 1
GROUP_CONCAT with DISTINCT, ORDER BY and SEPARATOR → string_agg
MySQL
SELECT user_id, GROUP_CONCAT(DISTINCT status ORDER BY status SEPARATOR ', ') AS statuses FROM orders GROUP BY user_id ORDER BY user_id
Postgres
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
MySQL
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
Postgres
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
MySQL
SELECT id, IF(total > 50, 'big', 'small') AS size, IF(paid = 1, "yes", "no") AS p FROM orders ORDER BY id
Postgres
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
MySQL
SELECT COUNT(*) FROM users WHERE name LIKE '%ALI%' OR email LIKE "BOB%"
Postgres
SELECT COUNT(*) FROM users WHERE name ILIKE '%ALI%' OR email ILIKE 'BOB%'
UNIX_TIMESTAMP / FROM_UNIXTIME
MySQL
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 id
Postgres
SELECT 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
MySQL
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
Postgres
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()
MySQL
SELECT id FROM users ORDER BY FIELD(id, 3, 1, 2), id
Postgres
SELECT id FROM users ORDER BY COALESCE(array_position(ARRAY[3, 1, 2], id), 0), id
FIND_IN_SET in WHERE
MySQL
SELECT id FROM users WHERE FIND_IN_SET('php', tags) ORDER BY id
Postgres
SELECT id FROM users WHERE (COALESCE(array_position(string_to_array(tags, ','), ('php')::text), 0) > 0) ORDER BY id
INSERT IGNORE → ON CONFLICT DO NOTHING
MySQL
INSERT IGNORE INTO users (id, name, email) VALUES (1, 'dup', 'dup@x.io'), (5, 'Dan', 'dan@x.io')
Postgres
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
MySQL
INSERT INTO stats (day, hits) VALUES ('2026-01-01', 5), ('2026-01-03', 1) ON DUPLICATE KEY UPDATE hits = hits + VALUES(hits)
Postgres
INSERT INTO stats (day, hits) VALUES ('2026-01-01', 5), ('2026-01-03', 1) ON CONFLICT (day) DO UPDATE SET hits = stats.hits + EXCLUDED.hits
REPLACE INTO
MySQL
REPLACE INTO stats (day, hits) VALUES ('2026-01-02', 9), ('2026-01-04', 2)
Postgres
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.hits
JSON_UNQUOTE(JSON_EXTRACT()) → #>>
MySQL
SELECT id, JSON_UNQUOTE(JSON_EXTRACT(meta, '$.plan')) AS plan FROM users WHERE JSON_UNQUOTE(JSON_EXTRACT(meta, '$.plan')) = 'pro' ORDER BY id
Postgres
SELECT id, (meta #>> '{plan}') AS plan FROM users WHERE (meta #>> '{plan}') = 'pro' ORDER BY id
TIMESTAMPDIFF
MySQL
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
Postgres
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 id

What 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.

Published with deed.page