Guide Migration

How to Convert SQL Between Database Dialects with AI (2026)

PostgreSQL to MySQL? SQL Server to Snowflake? A practical reference to the syntax differences that break migrations, with before/after conversion examples and a guide to the traps automated tools miss.

June 4, 2026 11 min read

Migrations Rarely Fail on the Data

On r/dataengineering this week, a thread titled "Db migration tooling" collected hundreds of comments from engineers who'd moved workloads between engines. The top-voted replies weren't about ETL pipelines or schema mapping — they were about the maddening accumulation of small SQL syntax differences: the date function that's spelled one way in Postgres and another in SQL Server, the GROUP_CONCAT that Snowflake calls LISTAGG, the || concatenation that MySQL doesn't accept.

On r/PostgreSQL, a developer posted "I migrated our workload from Aurora to LakeBase" and documented the same experience from the other direction: the actual data moved over a weekend without incident; the SQL queries took three more weeks to fix one by one. The schema was fine. The ETL was fine. The 200 individual syntax differences — that's what burns migration time.

This guide is the practical reference for that problem. Section 3 is a dialect-difference table you can bookmark. Sections 4 and 5 are full before/after conversion examples with annotated code. Section 6 covers the traps that automated converters miss. If you want to run conversions against your own queries while reading, open AI2SQL in a side tab — pick source dialect, pick target, paste your query. The 7-day trial covers the full dialect library.

Why SQL Dialects Diverge

ANSI SQL gives every database engine a shared core: SELECT, FROM, WHERE, JOIN, GROUP BY, HAVING, ORDER BY. The standard exists, and every major engine implements it. But the standard deliberately leaves implementation gaps — it doesn't specify how date arithmetic works, how string functions are named, how auto-incrementing columns are declared, or how identity columns behave across restarts. Vendors fill those gaps in their own way, and then accrete proprietary extensions over decades of competition.

The result is six categories of divergence that catch migrations off guard:

  • Row limiting syntax — how you express "give me the first N rows" varies by engine (LIMIT, TOP, FETCH FIRST).
  • String operations — concatenation, case functions, and pattern matching have different operator or function names.
  • Date and time functions — the most fragmented category; every engine has its own vocabulary for truncation, addition, and differencing.
  • Aggregate extensions — string aggregation (GROUP_CONCAT / STRING_AGG / LISTAGG) and ordered-set aggregates differ widely.
  • NULL handling — COALESCE is standard; ISNULL and IFNULL are vendor shortcuts with subtly different semantics.
  • DDL: identity and boolean — how you declare auto-incrementing primary keys and boolean columns has no standard form.

Understanding which category a function falls into tells you where to look first when a migrated query fails. The table in the next section covers all six.

Dialect Difference Reference Table

The table below covers the ten features that cause the most friction in migrations. Bookmark it; it's the lookup you'd otherwise spend 20 minutes assembling from documentation pages spread across five different vendor sites.

Once you've identified the differences, use AI2SQL to automate the rewrite — set your source and target dialect, paste the query, get the converted version with the correct function names for your target engine.

Feature PostgreSQL MySQL SQL Server Snowflake BigQuery
Row limit LIMIT n LIMIT n TOP n (before cols) or FETCH FIRST n ROWS ONLY LIMIT n LIMIT n
String concat col1 || col2 CONCAT(col1, col2) col1 + col2 col1 || col2 or CONCAT CONCAT(col1, col2)
Current timestamp NOW() / CURRENT_TIMESTAMP NOW() / SYSDATE() GETDATE() / SYSDATETIME() CURRENT_TIMESTAMP() CURRENT_TIMESTAMP
Date add / diff date + INTERVAL '7 days' / DATE_PART DATE_ADD(date, INTERVAL 7 DAY) / DATEDIFF DATEADD(day, 7, date) / DATEDIFF(day, …) DATEADD(day, 7, date) / DATEDIFF(day, …) DATE_ADD(date, INTERVAL 7 DAY) / DATE_DIFF
NULL fallback COALESCE(x, y) IFNULL(x, y) or COALESCE ISNULL(x, y) or COALESCE NVL(x, y) or COALESCE COALESCE(x, y) or IFNULL
Auto-increment SERIAL / GENERATED ALWAYS AS IDENTITY AUTO_INCREMENT IDENTITY(1,1) AUTOINCREMENT No native; use GENERATE_UUID() or sequence
Boolean type BOOLEAN (true/false literals) TINYINT(1) (0/1) BIT (0/1) BOOLEAN BOOL / BOOLEAN
Quoted identifiers Double quotes "col" Backticks `col` Square brackets [col] Double quotes "col" Backticks `col`
String aggregation STRING_AGG(col, ',') GROUP_CONCAT(col SEPARATOR ',') STRING_AGG(col, ',') WITHIN GROUP (ORDER BY col) LISTAGG(col, ',') WITHIN GROUP (ORDER BY col) STRING_AGG(col, ',')
Upsert INSERT … ON CONFLICT DO UPDATE INSERT … ON DUPLICATE KEY UPDATE MERGE INTO … USING … MERGE INTO … USING … MERGE INTO … USING …

Worked Conversion: PostgreSQL to SQL Server

This example is drawn from the kind of query that runs cleanly on Aurora or RDS PostgreSQL but fails immediately on SQL Server — it uses four features that don't translate directly: DATE_TRUNC, STRING_AGG (without ORDER BY), LIMIT, and ILIKE.

Original query (PostgreSQL):

SELECT
    DATE_TRUNC('month', o.created_at)   AS order_month,
    c.region,
    STRING_AGG(p.name, ', ')            AS products_sold,
    COUNT(o.id)                         AS order_count,
    SUM(o.total_amount)                 AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE c.email ILIKE '%@enterprise.com'
  AND o.status = 'fulfilled'
  AND o.created_at >= NOW() - INTERVAL '90 days'
GROUP BY 1, 2
ORDER BY order_month DESC, revenue DESC
LIMIT 20;

Converted query (SQL Server / T-SQL):

SELECT TOP 20
    DATETRUNC(month, o.created_at)      AS order_month,
    c.region,
    STRING_AGG(p.name, ', ')
        WITHIN GROUP (ORDER BY p.name)  AS products_sold,
    COUNT(o.id)                         AS order_count,
    SUM(o.total_amount)                 AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE c.email LIKE '%@enterprise.com' COLLATE SQL_Latin1_General_CP1_CI_AS
  AND o.status = 'fulfilled'
  AND o.created_at >= DATEADD(day, -90, GETDATE())
GROUP BY DATETRUNC(month, o.created_at), c.region
ORDER BY order_month DESC, revenue DESC;

Four translation points to note:

  • DATE_TRUNC('month', col) becomes DATETRUNC(month, col) — argument order flips, and the interval is a keyword not a string. SQL Server 2022+ supports DATETRUNC; on older versions you'd use DATEFROMPARTS(YEAR(col), MONTH(col), 1).
  • STRING_AGG exists in SQL Server 2017+, but it requires WITHIN GROUP (ORDER BY ...) — the PostgreSQL form doesn't need it. The ORDER BY inside WITHIN GROUP is mandatory in SQL Server even if you don't care about the order.
  • LIMIT 20 at the end becomes TOP 20 before the column list, and the GROUP BY clause can no longer use positional references (1, 2) — you must repeat the expressions.
  • ILIKE (case-insensitive LIKE) becomes LIKE ... COLLATE SQL_Latin1_General_CP1_CI_AS. The _CI_ in the collation name means case-insensitive. If your database has a CI collation by default, you can drop the COLLATE clause — but it's safer to be explicit.

Worked Conversion: MySQL to Snowflake

This example covers a common analytics query pattern — a cohort rollup using GROUP_CONCAT — that hits five Snowflake differences at once.

Original query (MySQL):

SELECT
    DATE_FORMAT(u.created_at, '%Y-%m') AS signup_month,
    u.plan_tier,
    GROUP_CONCAT(DISTINCT u.country ORDER BY u.country SEPARATOR ' | ') AS countries,
    COUNT(DISTINCT u.id)               AS user_count,
    SUM(e.event_count)                 AS total_events
FROM users u
LEFT JOIN (
    SELECT user_id, COUNT(*) AS event_count
    FROM events
    WHERE `event_type` != 'session_start'
    GROUP BY user_id
) e ON e.user_id = u.id
WHERE u.created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH)
  AND u.status = 'active'
GROUP BY 1, 2
ORDER BY signup_month DESC, user_count DESC
LIMIT 50 OFFSET 100;

Converted query (Snowflake):

SELECT
    TO_CHAR(u.created_at, 'YYYY-MM')   AS signup_month,
    u.plan_tier,
    LISTAGG(DISTINCT u.country, ' | ')
        WITHIN GROUP (ORDER BY u.country) AS countries,
    COUNT(DISTINCT u.id)               AS user_count,
    SUM(e.event_count)                 AS total_events
FROM users u
LEFT JOIN (
    SELECT user_id, COUNT(*) AS event_count
    FROM events
    WHERE "event_type" != 'session_start'
    GROUP BY user_id
) e ON e.user_id = u.id
WHERE u.created_at >= DATEADD(month, -6, CURRENT_TIMESTAMP())
  AND u.status = 'active'
GROUP BY 1, 2
ORDER BY signup_month DESC, user_count DESC
LIMIT 50
OFFSET 100;

Five changes worth noting:

  • DATE_FORMAT(col, '%Y-%m') becomes TO_CHAR(col, 'YYYY-MM') — Snowflake uses Oracle-style format masks, not strftime-style.
  • GROUP_CONCAT(DISTINCT ... ORDER BY ... SEPARATOR ...) becomes LISTAGG(DISTINCT ..., ...) WITHIN GROUP (ORDER BY ...) — the separator moves from a named argument to the second positional argument, and WITHIN GROUP is required.
  • MySQL backtick identifiers (`event_type`) become double-quote identifiers ("event_type") — and since Snowflake folds unquoted identifiers to uppercase, you only need to quote identifiers that were created with mixed case. If your columns are all lowercase in MySQL, they probably exist as uppercase in Snowflake after import; test before assuming double quotes are needed.
  • DATE_SUB(NOW(), INTERVAL 6 MONTH) becomes DATEADD(month, -6, CURRENT_TIMESTAMP()).
  • LIMIT 50 OFFSET 100 is valid Snowflake syntax — no change needed there. This is one of the pairs that's actually compatible.

The Traps Automated Converters Miss

The table in Section 3 covers the things you can look up. These are the things that look fine, pass a syntax check, and still return wrong results.

1. Integer Division

In PostgreSQL and SQL Server, dividing two integers returns an integer: 5 / 2 = 2. In MySQL, the same expression returns 2.5000 when at least one operand is DECIMAL or when sql_mode doesn't force integer division. Snowflake and BigQuery follow the PostgreSQL/SQL Server rule. If you have expressions like total_amount / num_items in WHERE clauses or computed columns, check whether the result type changes between source and target — it affects which rows pass comparison filters.

2. NULL Ordering in ORDER BY

PostgreSQL sorts NULLs last in ASC order and first in DESC order. SQL Server sorts NULLs first in both ASC and DESC. MySQL sorts NULLs first in ASC and last in DESC (the opposite of SQL Server). Snowflake and BigQuery follow PostgreSQL conventions. If you have queries that page over sorted results and some rows have NULL in the sort key, different NULL positions mean different rows come back on each page. NULLS FIRST / NULLS LAST is ANSI standard and works in PostgreSQL, Snowflake, and BigQuery — but not in MySQL or SQL Server. For MySQL/SQL Server, use ORDER BY CASE WHEN col IS NULL THEN 0 ELSE 1 END, col to force a specific NULL position.

3. Case Sensitivity of Identifiers

PostgreSQL folds unquoted identifiers to lowercase. Snowflake folds unquoted identifiers to uppercase. MySQL on Linux is case-sensitive for table names (and follows the filesystem); on Windows it's case-insensitive. SQL Server is case-insensitive by default for both identifiers and string comparisons, depending on collation. This means a column created as OrderDate in SQL Server might need to be referenced as "ORDERDATE" in Snowflake if it was bulk-imported unquoted. Test column names on a SELECT * before assuming the migration preserved case.

4. Implicit Type Coercion

MySQL silently coerces strings to integers when comparing a string column to an integer literal: WHERE status = 0 matches rows where status is '0', '0abc', and any non-numeric string (which coerces to 0). PostgreSQL throws a type error. SQL Server is somewhere in between depending on the column type and collation. If you have WHERE clauses comparing columns to untyped literals, audit them — the MySQL version may have been silently filtering out rows you meant to include, or including rows you meant to exclude.

The practical rule: run your converted query on a sample dataset from both the source and target engines and compare row counts. If the counts differ, one of these four traps is almost certainly the reason. An AI converter handles the syntax transformation; you still need to verify the semantics.

How AI2SQL Handles Dialect Conversion

The workflow in AI2SQL is designed around exactly the migration problem described above. You set a source dialect and a target dialect — both are explicit selections, not inferred — then paste the query. The converter handles the mechanical rewrites from the table in Section 3: function names, identifier quoting, LIMIT/TOP placement, date arithmetic, string aggregation syntax.

Schema awareness matters here in a way it doesn't for straight text-to-SQL. When converting a query that references actual table and column names, the tool uses the DDL you've pasted to check that referenced columns exist in the target schema after migration. This catches the case where a column was renamed during the migration — the converted SQL would reference the old name and fail silently if you weren't checking.

The output comes with the converted SQL and a diff annotation that highlights which lines changed and why. That annotation is what you'd otherwise need to write yourself before handing the conversion to a teammate for review. Try the dialect converter on a query from your current migration — it handles the syntax mechanics while you focus on the behavioral differences in Section 6 that still need human eyes.

Honest note: test converted SQL on a representative sample before running against the full dataset. The converter handles the syntax accurately; the implicit behavioral differences (integer division, NULL ordering, coercion) are structural properties of each engine that require you to verify the output matches your intent. Run EXPLAIN on the converted query and compare the plan to the original — if the plan shape changes significantly, the semantics may have shifted even if the syntax is correct.

Start Converting SQL Between Dialects

Convert your queries — pick source, pick target, paste SQL

7-day trial, card required. Set source dialect, set target dialect, paste the query. The converter handles the table in Section 3 so you can focus on the behavioral traps in Section 6. Plans:

  • Start — $5/mo · 50 queries/day · for occasional SQL work
  • Pro — $11/mo · 500 queries/day · most popular, fits daily SQL
  • Team — $23/mo · unlimited queries + multi-user
See plans and pricing

Card required. Cancel any time before day 7 — no charge.

Frequently Asked Questions

Can AI convert PostgreSQL to MySQL accurately?

For the majority of queries, yes. AI handles the common differences well: LIMIT/OFFSET syntax (identical in this pair), string concatenation (|| to CONCAT()), date functions (DATE_TRUNC to DATE_FORMAT/DATE()), ILIKE to LIKE with case-insensitive collation, and SERIAL to AUTO_INCREMENT. Where it still requires human review: implicit type coercion edge cases, NULL ordering (MySQL doesn't support NULLS FIRST/LAST), and any use of PostgreSQL-specific extensions like jsonb operators or array functions that have no direct MySQL equivalent.

What's the hardest part of SQL dialect migration?

Not the obvious syntax differences — those are table-lookable. The hardest parts are implicit behaviors that differ silently: integer division (5/2 = 2 in PostgreSQL and SQL Server, but FLOAT in some contexts in MySQL), NULL ordering in ORDER BY clauses (PostgreSQL NULLs sort last ascending; SQL Server NULLs sort first), case sensitivity of string comparisons (MySQL on Windows is case-insensitive by default; PostgreSQL is always case-sensitive), and identifier case folding (PostgreSQL lowercases unquoted identifiers; Snowflake uppercases them). These produce queries that run without error but return wrong results — the worst kind of bug.

Does AI2SQL support Snowflake and BigQuery?

Yes. AI2SQL supports MySQL, PostgreSQL, SQL Server, Snowflake, BigQuery, Oracle, SQLite, and Redshift as both source and target dialects. You pick the target dialect before generating or converting, and the output uses the correct syntax for that engine — LISTAGG instead of STRING_AGG for Snowflake aggregation, ARRAY_AGG for BigQuery, TOP instead of LIMIT for SQL Server, and so on. Schema-aware conversion (paste your DDL) is available on all plans.

Will converted SQL run without changes?

For straightforward queries — SELECT, JOIN, WHERE, GROUP BY — converted SQL usually runs without modification. For complex queries involving window functions, CTEs, stored procedures, or dialect-specific extensions, you should test on a representative sample before running in production. The traps automated converters miss (implicit coercion, NULL ordering, identifier case sensitivity, integer division) require human review. The rule: run EXPLAIN on the converted query and compare the plan to the original to catch performance regressions as well as correctness issues.

How do I convert SQL Server T-SQL to PostgreSQL?

The main translation points: TOP n to LIMIT n (move from before the column list to end of query), GETDATE() to NOW() or CURRENT_TIMESTAMP, ISNULL(x, y) to COALESCE(x, y), square-bracket identifiers [col] to double-quote identifiers "col", IDENTITY column to SERIAL or GENERATED ALWAYS AS IDENTITY, string concatenation with + to ||, and DATEADD/DATEDIFF to interval arithmetic or date_part(). For AI-assisted conversion, paste the T-SQL query into AI2SQL with source set to SQL Server and target set to PostgreSQL — the output handles the mechanical rewrites; you review the implicit-behavior differences.

Convert SQL Between Any Two Dialects

AI2SQL handles PostgreSQL, MySQL, SQL Server, Snowflake, BigQuery, Oracle, Redshift, and SQLite. Pick your source and target dialect, paste the query, get the converted version. 7-day trial, card required.

Start the 7-day trial

Cancel any time before day 7 — no charge.