SQL (PostgreSQL, MySQL, SQLite) Module 20 – Dialects and portability
Other dialects: SQL Server, Oracle, DuckDB
How T-SQL, Oracle SQL and DuckDB differ from PostgreSQL, MySQL and SQLite: row limits, keys, MERGE, QUALIFY and GROUP BY ALL, with DuckDB queries run.
What you will learn
- Recognise the syntax of T-SQL, Oracle SQL and DuckDB that differs from this track's engines
- Apply PostgreSQL, MySQL and SQLite skills to those three dialects
- Write a QUALIFY query for an engine that has no QUALIFY
- Check a dialect claim against its official documentation and your own server
Before you start
What you’ll learn · Before you start
What you will learn
- Recognise the syntax of T-SQL, Oracle SQL and DuckDB that differs from this track's engines
- Apply PostgreSQL, MySQL and SQLite skills to those three dialects
- Write a QUALIFY query for an engine that has no QUALIFY
- Check a dialect claim against its official documentation and your own server
Before you start
On this page
Beyond PostgreSQL, MySQL and SQLite, three dialects turn up most often in job descriptions and data work: T-SQL,
the SQL of Microsoft SQL Server; Oracle SQL, which adds its own row limits, sequences and DUAL table; and
DuckDB, a small analytical engine that reads files as if they were tables and adds shorthands such as QUALIFY
and GROUP BY ALL. The good news is that most of what you know transfers: the relational model, SELECT, joins,
GROUP BY, subqueries, common table expressions and window functions are the same language in all of them. What
differs is the edges, and the edges are short enough to list.
DuckDB runs in this lesson, with its own recorded output, because it is pinned here as DuckDB 1.5.6. T-SQL and Oracle SQL are described from their official documentation only, with no outputs: the next module takes those two engines side by side with results recorded on their own servers.
What transfers, and what each dialect spells its own way
In this track’s three engines you write LIMIT … OFFSET for a row limit, an identity column for a generated key,
ON CONFLICT … DO UPDATE for an upsert, || to join two strings, a common table expression to filter on a window
result, and a bare SELECT 1 when no table is needed. Here is what the other three want instead:
| Criterion | T-SQL | Oracle SQL | DuckDB |
|---|---|---|---|
| Row limit | TOP (n), or OFFSET … FETCH | OFFSET … FETCH FIRST | LIMIT … OFFSET |
| Generated key | IDENTITY(1, 1) | Identity or a SEQUENCE | A SEQUENCE |
| Insert or update | MERGE | MERGE | ON CONFLICT or MERGE |
| Join two strings | +, and || from 2025 | || | || |
| Filter on a window | A subquery or CTE | QUALIFY | QUALIFY |
| No table needed | SELECT 1 | SELECT 1 FROM DUAL | SELECT 1 |
Everything in that table is one line of code. The differences that cost a day are the ones in behaviour, and three of
them are worth memorising. Oracle stores text of length zero as a null, and its manual warns that this may change in
a future release, so code should not lean on it. T-SQL sorts nulls as the lowest value, which puts them first in an
ascending sort, as in MySQL and SQLite but not as in PostgreSQL. And DuckDB divides with two operators, / for
floating point and // for whole numbers, so the division trap of this track’s three engines has a third answer.
DuckDB: QUALIFY, and the query it replaces
QUALIFY keeps or drops rows by what a window function returned, as HAVING does for an aggregate. DuckDB’s
documentation describes it as a way to do that filtering without a subquery or a WITH clause, and places it after
the WINDOW clause and before ORDER BY.
"""The statements of ../duckdb/qualify.sql on DuckDB 1.5.6, as its shell prints them."""
from duckdb_server import duckdb_file
duckdb_file("../duckdb/qualify.sql") Output
┌────────┬──────────┬────────┐ │ city │ customer │ amount │ ├────────┼──────────┼────────┤ │ Jaipur │ Tara │ 600 │ │ Kochi │ Meena │ 700 │ │ Pune │ Kabir │ 900 │ └────────┴──────────┴────────┘ ┌────────┬──────────┬────────┐ │ city │ customer │ amount │ ├────────┼──────────┼────────┤ │ Jaipur │ Tara │ 600 │ │ Kochi │ Meena │ 700 │ │ Pune │ Kabir │ 900 │ └────────┴──────────┴────────┘ ┌────────┬──────────┬────────┐ │ city │ customer │ amount │ ├────────┼──────────┼────────┤ │ Jaipur │ Tara │ 600 │ │ Kochi │ Meena │ 700 │ │ Pune │ Ravi │ 900 │ └────────┴──────────┴────────┘ ┌────────┬──────────┬────────┐ │ city │ customer │ amount │ ├────────┼──────────┼────────┤ │ Jaipur │ Tara │ 600 │ │ Kochi │ Meena │ 700 │ │ Pune │ Kabir │ 900 │ │ Pune │ Ravi │ 900 │ └────────┴──────────┴────────┘
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 duckdb_qualify.py
-- DuckDB 1.5.6: the biggest order of each city, three ways, and what a tie does.
CREATE TABLE orders (id INTEGER PRIMARY KEY, city VARCHAR NOT NULL, customer VARCHAR NOT NULL, amount INTEGER NOT NULL);
INSERT INTO orders VALUES
(1, 'Pune', 'Asha', 400), (2, 'Pune', 'Ravi', 900), (3, 'Kochi', 'Meena', 700),
(4, 'Kochi', 'Arjun', 250), (5, 'Jaipur', 'Tara', 600), (6, 'Pune', 'Kabir', 900);
-- 1. QUALIFY filters on the window function itself, with a named window and a tie-breaker.
SELECT city, customer, amount
FROM orders
WINDOW w AS (PARTITION BY city ORDER BY amount DESC, customer)
QUALIFY row_number() OVER w = 1
ORDER BY city;
-- 2. The same answer as a common table expression, which PostgreSQL, MySQL and SQLite also run.
WITH ranked AS (
SELECT city, customer, amount,
row_number() OVER (PARTITION BY city ORDER BY amount DESC, customer) AS rn
FROM orders
)
SELECT city, customer, amount FROM ranked WHERE rn = 1 ORDER BY city;
-- 3. The same QUALIFY without the tie-breaker: Pune has two orders of 900, and one of them wins by chance.
SELECT city, customer, amount
FROM orders
QUALIFY row_number() OVER (PARTITION BY city ORDER BY amount DESC) = 1
ORDER BY city;
-- 4. Every tied row kept, by ranking instead of numbering.
SELECT city, customer, amount
FROM orders
QUALIFY rank() OVER (PARTITION BY city ORDER BY amount DESC) = 1
ORDER BY city, customer; The first two queries return the same three rows, so QUALIFY is a shorthand, not a new ability. The third shows why
the tie-breaker matters: Pune has two orders of 900, and without , customer in the window’s ORDER BY the engine
picks one of them, here Ravi rather than Kabir. The fourth keeps both tied rows by ranking instead of numbering.
On an engine without QUALIFY, the common table expression is the answer, because a window function may not appear in
WHERE at all:
-- The same question in SQLite 3.49.1, which has no QUALIFY: the biggest order of each city.
CREATE TABLE orders (id INTEGER PRIMARY KEY, city TEXT NOT NULL, customer TEXT NOT NULL, amount INTEGER NOT NULL);
INSERT INTO orders VALUES
(1, 'Pune', 'Asha', 400), (2, 'Pune', 'Ravi', 900), (3, 'Kochi', 'Meena', 700),
(4, 'Kochi', 'Arjun', 250), (5, 'Jaipur', 'Tara', 600), (6, 'Pune', 'Kabir', 900);
-- 1. The common table expression that stands in for QUALIFY, with a tie-breaker.
WITH ranked AS (
SELECT city, customer, amount,
row_number() OVER (PARTITION BY city ORDER BY amount DESC, customer) AS rn
FROM orders
)
SELECT city, customer, amount FROM ranked WHERE rn = 1 ORDER BY city;
-- 2. A window function in WHERE is refused: that is the rule QUALIFY exists to work around.
SELECT city, customer FROM orders WHERE row_number() OVER (PARTITION BY city ORDER BY amount DESC) = 1; Output (exit status 1)
┌────────┬──────────┬────────┐ │ city │ customer │ amount │ ├────────┼──────────┼────────┤ │ Jaipur │ Tara │ 600 │ │ Kochi │ Meena │ 700 │ │ Pune │ Kabir │ 900 │ └────────┴──────────┴────────┘
Printed as an error (standard error)
Parse error near line 16: misuse of window function row_number()
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < top_per_city.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
SQLite answers the second query with misuse of window function row_number(). PostgreSQL and MySQL refuse it too:
WHERE is evaluated before window functions are computed, which is the rule QUALIFY exists to work around. Oracle
has QUALIFY, and its manual pins the order of operations: FROM, WHERE, GROUP BY, HAVING, WINDOW,
QUALIFY, DISTINCT, ORDER BY, FETCH FIRST.
The other shorthands, named
DuckDB adds five more conveniences, each of which stands for a longer form you already know. GROUP BY ALL groups
by every column of the select list that is not inside an aggregate. ORDER BY ALL sorts by all the selected
columns, left to right. SELECT * EXCLUDE (a, b) returns every column except the ones named. COLUMNS('…')
picks the columns whose names match a pattern and applies one function to each. A file as a table lets
SELECT … FROM 'orders.csv' read a CSV or Parquet file with no CREATE TABLE at all.
The part below runs all five and gives the plain form beside each, then turns to T-SQL and Oracle SQL: what maps to what, which traps cost a day, and how to confirm each claim on a server of your own.
Your turn
Exercise · Medium · SQL
Write the QUALIFY query for an engine without it
The table orders has id, city, customer and amount.
In DuckDB this query answers "the biggest order of each city":
SELECT city, customer, amount FROM orders
QUALIFY row_number() OVER (PARTITION BY city ORDER BY amount DESC, customer) = 1
ORDER BY city;
SQLite, PostgreSQL and MySQL have no QUALIFY. Write the query that returns the same three columns and rows here, sorted by city. Where two orders of a city tie on amount, the one whose customer name comes first alphabetically wins, exactly as the window above says.
The starter code numbers the orders inside each city, which is the first half of the answer. Each test runs your SQL on a fresh table and compares the rows it returns, in order.
Starter code · top_per_city.sql
-- A start: every order with its position inside its city. Return only the first of each city.
SELECT city, customer, amount,
row_number() OVER (PARTITION BY city ORDER BY amount DESC, customer) AS rn
FROM orders
ORDER BY city, rn; The sample tests · tests.yaml
# Sample tests: each one builds a small orders table, runs your SQL on it and compares the rows it returns, in order.
tests:
- name: three cities, one tie broken by the customer name
ordered: true
setup: |
CREATE TABLE orders (id INTEGER PRIMARY KEY, city TEXT NOT NULL, customer TEXT NOT NULL, amount INTEGER NOT NULL);
INSERT INTO orders VALUES (1, 'Pune', 'Asha', 400), (2, 'Pune', 'Ravi', 900), (3, 'Kochi', 'Meena', 700),
(4, 'Kochi', 'Arjun', 250), (5, 'Jaipur', 'Tara', 600), (6, 'Pune', 'Kabir', 900);
expected:
columns: [city, customer, amount]
rows:
- [Jaipur, Tara, 600]
- [Kochi, Meena, 700]
- [Pune, Kabir, 900]
- name: one city with one order
ordered: true
setup: |
CREATE TABLE orders (id INTEGER PRIMARY KEY, city TEXT NOT NULL, customer TEXT NOT NULL, amount INTEGER NOT NULL);
INSERT INTO orders VALUES (1, 'Patna', 'Zoya', 1000);
expected:
columns: [city, customer, amount]
rows:
- [Patna, Zoya, 1000]
- name: every order of a city has the same amount
ordered: true
setup: |
CREATE TABLE orders (id INTEGER PRIMARY KEY, city TEXT NOT NULL, customer TEXT NOT NULL, amount INTEGER NOT NULL);
INSERT INTO orders VALUES (1, 'Surat', 'Dev', 500), (2, 'Surat', 'Bhavna', 500), (3, 'Surat', 'Chetan', 500);
expected:
columns: [city, customer, amount]
rows:
- [Surat, Bhavna, 500]
- name: cities sorted by name, not by amount
ordered: true
setup: |
CREATE TABLE orders (id INTEGER PRIMARY KEY, city TEXT NOT NULL, customer TEXT NOT NULL, amount INTEGER NOT NULL);
INSERT INTO orders VALUES (1, 'Mysuru', 'Latha', 100), (2, 'Bhopal', 'Imran', 900), (3, 'Agra', 'Nisha', 450),
(4, 'Agra', 'Omar', 450);
expected:
columns: [city, customer, amount]
rows:
- [Agra, Nisha, 450]
- [Bhopal, Imran, 900]
- [Mysuru, Latha, 100] A hint
A window function cannot go in WHERE, because WHERE is evaluated before the window functions are computed. Put the numbering query in a WITH block (or a subquery in FROM), give the number a name such as rn, and then filter rn = 1 in the outer query.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
The five DuckDB shorthands run, with the plain form beside each Pro: opens with a Pro, Premium or Ultimate pass
Shorthands are worth learning only if you know what they stand for, because the plain form is what you will write on every other engine. This part runs all five on DuckDB 1.5.6 and names the cost of each.
In this part:
- Five shorthands, one run, and what each one stands for
- What DuckDB is for, and when it is the wrong choice
- Reading a DuckDB query as a PostgreSQL user
2 runnable examples (Python, SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
T-SQL and Oracle SQL mapped from what you already write, with the traps named Pro: opens with a Pro, Premium or Ultimate pass
What follows is a mapping, not a tour: for each thing you write in PostgreSQL, MySQL or SQLite, the T-SQL and Oracle forms, taken from their vendor documentation, and the behaviour that catches people. No output is shown here, because neither engine runs in this lesson; the last section gives the statements that let you confirm every row on a server of your own.
In this part:
- The mapping table
- Missing values and empty text, side by side
- The four traps that cost a day
- How to confirm every row yourself, in ten minutes
about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What does QUALIFY filter, and how do you write the same query on an engine without it?
QUALIFYkeeps or drops rows by what a window function returned, asHAVINGdoes for an aggregate. With a window that partitions by city and orders by amount,QUALIFY row_number() OVER w = 1keeps each city's biggest order. Oracle SQL and DuckDB have it; PostgreSQL, MySQL, SQLite and SQL Server do not, and they refuse a window function inWHERE, becauseWHEREis evaluated before window functions are computed. The same query there computes the window in a common table expression or a subquery, names its result, and filters that name in the outer query. Oracle's manual putsQUALIFYafterWINDOWand beforeDISTINCTandORDER BY. -
Mid to senior
An analyst's DuckDB report uses QUALIFY, GROUP BY ALL and SELECT * EXCLUDE, and it now has to run nightly against PostgreSQL 18. Rewrite each construct, and say which parts of the report you would not trust after the rewrite.
-
Mid to senior
A SQL Server screen pages through orders with TOP (20) and an ORDER BY inside a view. Users report seeing the same order on two pages and never seeing others. Diagnose it from what Microsoft documents, and write the paging query you would ship.
-
Senior to staff
A product keeps its records in PostgreSQL. The analytics screen runs eight reports over three years of orders, 40 million rows, and each takes 20 to 40 seconds. Someone proposes DuckDB. How would you decide, and what would you measure?
Key takeaways
- The relational core transfers:
SELECT, joins,GROUP BY, subqueries, common table expressions and window functions are the same in T-SQL, Oracle SQL and DuckDB. - Row limits, generated keys, upserts, string concatenation and “no table needed” are the five edges spelled differently in each dialect.
QUALIFYfilters on a window result in Oracle and DuckDB; everywhere else the same query is a common table expression, becauseWHEREruns before window functions.- DuckDB’s shorthands each stand for a plain form, and reading files as tables guesses the types.
- Oracle treats an empty string as NULL, and T-SQL sorts NULLs first with no
NULLS LASTto change it.
References
- DuckDB documentation: QUALIFY Clause (DuckDB)
- DuckDB documentation: GROUP BY Clause (DuckDB)
- DuckDB documentation: Numeric Functions and Operators (DuckDB)
- DuckDB documentation: SELECT Clause (EXCLUDE and COLUMNS) (DuckDB)
- DuckDB documentation: CSV Import (DuckDB)
- SQL Server documentation: SELECT (Transact-SQL) (Microsoft)
- SQL Server documentation: TOP (Transact-SQL) (Microsoft)
- SQL Server documentation: ORDER BY clause (OFFSET and FETCH) (Microsoft)
- SQL Server documentation: || (String Concatenation) (Microsoft)
- SQL Server documentation: MERGE (Transact-SQL) (Microsoft)
- SQL Server documentation: IDENTITY (Property) (Microsoft)
- SQL Server documentation: Unique constraints and check constraints (Microsoft)
- SQL Server documentation: Create filtered indexes (Microsoft)
- Oracle AI Database SQL Language Reference: SELECT (Oracle Corporation)
- Oracle AI Database SQL Language Reference: Nulls (Oracle Corporation)
- Oracle AI Database SQL Language Reference: Data Types (Oracle Corporation)
- Oracle AI Database SQL Language Reference: MERGE (Oracle Corporation)
- Oracle AI Database SQL Language Reference: CREATE SEQUENCE (Oracle Corporation)
- PostgreSQL documentation: SELECT (window and LIMIT clauses) (The PostgreSQL Global Development Group)
- SQLite: Window Functions (SQLite)
- MySQL 8.4 Reference Manual: Window Functions (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress