Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 6 – Subqueries and set operations

UNION, INTERSECT and EXCEPT in SQL

Combine query results with UNION and UNION ALL, find shared and missing rows with INTERSECT and EXCEPT, and see where PostgreSQL, MySQL and SQLite differ.

  • Beginner
  • 20 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Combine result sets with UNION and UNION ALL
  • Find common and missing rows with INTERSECT and EXCEPT
  • Predict how duplicates, NULLs and operator precedence behave in each engine
  • Sort and limit a compound query

Before you start

What you’ll learn · Before you start

What you will learn

  • Combine result sets with UNION and UNION ALL
  • Find common and missing rows with INTERSECT and EXCEPT
  • Predict how duplicates, NULLs and operator precedence behave in each engine
  • Sort and limit a compound query

Before you start

On this page

UNION, INTERSECT and EXCEPT combine the results of two queries into one, the way you would combine two lists. UNION keeps every row that is in either result, INTERSECT keeps the rows that are in both, and EXCEPT keeps the rows of the first result that the second one lacks. They answer questions such as “who bought online or in the shop”, “who paid both ways” and “which products sold in March but not in April”. A join puts the columns of two tables side by side; a set operation stacks rows on top of each other, so both queries must return the same number of columns. All three remove duplicate rows unless you add ALL, and that one word is behind the most common mistake with them.

You can run each query below where you read it: the page runs SQLite 3.49.1 for you, inside sql.js 1.14.2. The text also notes each place where PostgreSQL 18 or MySQL 8.4 behaves differently, with their manuals as the source.

Three ways to combine two results

The payment codes in these examples

The shop in this lesson records how each order was paid: upi for UPI, card for a debit or credit card and cod for cash on delivery. The same queries work for any two channels, such as the app and the website, or two branches.

Each query below takes two lists of customers from the same table, the ones who paid by upi and the ones who paid by card, and combines them in one of the three ways:

One table, two lists, three set operations SQL · three_operators.sql
-- Eleven payments of an online shop: who paid, how ('upi', 'card' or 'cod' for cash on delivery) and how much,
-- in paise.
CREATE TABLE payments (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  method       TEXT    NOT NULL,
  amount_paise INTEGER NOT NULL
);
INSERT INTO payments VALUES
  (1,  'Asha',   'upi',   45000), (2,  'Bilal',  'card', 120000), (3,  'Asha',   'card',  30000),
  (4,  'Chitra', 'upi',    8000), (5,  'Dev',    'cod',   56000), (6,  'Bilal',  'card',  15000),
  (7,  'Asha',   'upi',   99000), (8,  'Esha',   'card',  64000), (9,  'Chitra', 'upi',   21000),
  (10, 'Farhan', 'upi',   37000), (11, 'Esha',   'upi',   12000);

-- 1. UNION: customers who paid by upi, by card or both, each name once.
SELECT customer FROM payments WHERE method = 'upi'
UNION
SELECT customer FROM payments WHERE method = 'card'
ORDER BY customer;

-- 2. INTERSECT: customers who paid both ways.
SELECT customer FROM payments WHERE method = 'upi'
INTERSECT
SELECT customer FROM payments WHERE method = 'card'
ORDER BY customer;

-- 3. EXCEPT: customers who paid by card but never by upi.
SELECT customer FROM payments WHERE method = 'card'
EXCEPT
SELECT customer FROM payments WHERE method = 'upi'
ORDER BY customer;

Output

┌──────────┐
│ customer │
├──────────┤
│ Asha     │
│ Bilal    │
│ Chitra   │
│ Esha     │
│ Farhan   │
└──────────┘
┌──────────┐
│ customer │
├──────────┤
│ Asha     │
│ Esha     │
└──────────┘
┌──────────┐
│ customer │
├──────────┤
│ Bilal    │
└──────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < three_operators.sql

Two customer lists combined three ways: UNION gives 5 names, INTERSECT gives Asha and Esha, card EXCEPT upi gives Bilal.Two lists of payersCombined three waysPaid by upiAsha · ChitraEsha · FarhanPaid by cardAsha · Bilal · Eshaupi UNION cardin either listAsha · Bilal · ChitraEsha · Farhanupi INTERSECT cardin both listsAsha · Eshacard EXCEPT upiin the card list onlyBilalset operations

One pair of lists, three set operations

Text description of the diagram

The diagram has two boxes, one above the other, joined by an arrow labelled "set operations".

The upper box, "Two lists of payers", holds the two lists taken from the payments table: 1. The customers who paid by upi: Asha, Chitra, Esha and Farhan. 2. The customers who paid by card: Asha, Bilal and Esha.

The lower box shows what each set operation returns for these two lists: 1. upi UNION card keeps every name that is in either list, once: Asha, Bilal, Chitra, Esha and Farhan. 2. upi INTERSECT card keeps the names that are in both lists: Asha and Esha. 3. card EXCEPT upi keeps the names of the card list that are not in the upi list: only Bilal.

UNION returns five names. Asha paid by upi twice and by card once, yet she appears once, because UNION removes duplicate rows from the combined result, including duplicates inside each list. Dev is missing: he only ever paid cash, so neither list contains him. INTERSECT keeps Asha and Esha, the two customers on both lists. EXCEPT keeps the card payers who are not on the upi list, which leaves Bilal alone.

EXCEPT is the one operator whose order matters. Swap the two queries and the question changes: upi EXCEPT card returns Chitra and Farhan, the customers who paid by upi and never by card. UNION and INTERSECT give the same rows whichever query comes first.

UNION or UNION ALL

UNION ALL appends the second result to the first and removes nothing. UNION does the same and then removes every duplicate row, as if SELECT DISTINCT ran over the combined rows. Finding duplicates means sorting or hashing every row, which is why PostgreSQL’s manual says that UNION ALL is usually significantly quicker than UNION and asks you to use ALL when you can.

Speed is the smaller reason. The bigger one is that UNION changes the answer whenever two different events happen to look identical. Refunds recorded in two tables show how:

Five refunds, and three totals SQL · refund_totals.sql
-- Refunds are recorded in two tables: money sent back to the customer's account, and store credit.
-- Order 102 was refunded half each way, 99 rupees to the account and 99 rupees as credit.
CREATE TABLE account_refunds (order_id INTEGER NOT NULL, amount_paise INTEGER NOT NULL);
CREATE TABLE credit_refunds  (order_id INTEGER NOT NULL, amount_paise INTEGER NOT NULL);
INSERT INTO account_refunds VALUES (101, 25000), (102, 9900), (105, 9900);
INSERT INTO credit_refunds  VALUES (102, 9900), (104, 5000);

-- Every refund, five rows in all.
SELECT 'account' AS source, order_id, amount_paise FROM account_refunds
UNION ALL
SELECT 'credit', order_id, amount_paise FROM credit_refunds;

-- The total refunded, three ways.
SELECT 'UNION of amounts'          AS combined_with, sum(amount_paise) / 100.0 AS rupees
FROM (SELECT amount_paise FROM account_refunds UNION SELECT amount_paise FROM credit_refunds) AS r
UNION ALL
SELECT 'UNION of order and amount', sum(amount_paise) / 100.0
FROM (SELECT order_id, amount_paise FROM account_refunds
      UNION SELECT order_id, amount_paise FROM credit_refunds) AS r
UNION ALL
SELECT 'UNION ALL', sum(amount_paise) / 100.0
FROM (SELECT amount_paise FROM account_refunds UNION ALL SELECT amount_paise FROM credit_refunds) AS r;

Output

┌─────────┬──────────┬──────────────┐
│ source  │ order_id │ amount_paise │
├─────────┼──────────┼──────────────┤
│ account │ 101      │ 25000        │
│ account │ 102      │ 9900         │
│ account │ 105      │ 9900         │
│ credit  │ 102      │ 9900         │
│ credit  │ 104      │ 5000         │
└─────────┴──────────┴──────────────┘
┌───────────────────────────┬────────┐
│       combined_with       │ rupees │
├───────────────────────────┼────────┤
│ UNION of amounts          │ 399.0  │
│ UNION of order and amount │ 498.0  │
│ UNION ALL                 │ 597.0  │
└───────────────────────────┴────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < refund_totals.sql

The first query lists the five refunds, 597 rupees in all. Selecting only the amounts and combining them with UNION leaves three distinct values, 250, 99 and 50 rupees, so the total drops to 399: the three refunds of 99 rupees became one row. Adding the order number helps, but not enough, because order 102 was refunded 99 rupees each way and those two rows are still identical. Only UNION ALL keeps all five refunds and returns 597.

The rule that follows: when the rows are events you will count or add up, such as payments, refunds, log lines or stock movements, combine them with UNION ALL, and add a column that says where each row came from, like source in the first query. Use UNION when you want a list of distinct values, such as the names in the previous section.

The rules every compound query follows

A query built from several queries with set operators is a compound query. Each of its parts is an ordinary SELECT, but the parts must fit together, and the next example breaks the rules on purpose:

Column names, types, column counts and ORDER BY SQL · rules.sql
-- The rules of a compound query, tried on a small table of products.
CREATE TABLE products (id INTEGER PRIMARY KEY, code TEXT NOT NULL, price_paise INTEGER NOT NULL);
INSERT INTO products VALUES (1, 'TEA-250', 25000), (2, 'RICE-5K', 42000);

-- 1. The result's column names come from the first query.
SELECT id AS product, code AS label FROM products
UNION ALL
SELECT 3 AS another_name, 'GHEE-1L' AS ignored;

-- 2. SQLite mixes an integer and a text in one column; PostgreSQL refuses.
SELECT id AS value, typeof(id) AS type FROM products
UNION ALL
SELECT code, typeof(code) FROM products;

-- 3. Both sides must return the same number of columns.
SELECT id, code FROM products
UNION
SELECT id FROM products;

-- 4. ORDER BY belongs after the last query, where it sorts the whole result.
SELECT code FROM products ORDER BY code
UNION
SELECT 'GHEE-1L';

Output (exit status 1)

┌─────────┬─────────┐
│ product │  label  │
├─────────┼─────────┤
│ 1       │ TEA-250 │
│ 2       │ RICE-5K │
│ 3       │ GHEE-1L │
└─────────┴─────────┘
┌─────────┬─────────┐
│  value  │  type   │
├─────────┼─────────┤
│ 1       │ integer │
│ 2       │ integer │
│ TEA-250 │ text    │
│ RICE-5K │ text    │
└─────────┴─────────┘

Printed as an error (standard error)

Parse error near line 16: SELECTs to the left and right of UNION do not have the same number of result columns
Parse error near line 21: ORDER BY clause should come after UNION not before

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < rules.sql

  • The names come from the first query. The first result has the columns product and label; the aliases another_name and ignored in the second query change nothing. This holds in all three engines, so name the columns in the first query.
  • The types must fit together. SQLite keeps an integer and a text in the same result column, as typeof() shows. MySQL gives the column a type that fits both sides, a string type here. PostgreSQL resolves one type for each result column and fails when the types belong to different categories: UNION types integer and text cannot be matched. When you really mean to mix them, cast one side: CAST(id AS TEXT) in PostgreSQL and SQLite, CAST(id AS CHAR) in MySQL.
  • The column counts must match. The third query asks for two columns on one side and one on the other. SQLite answers that the SELECTs to the left and right of UNION do not have the same number of result columns; PostgreSQL says that each UNION query must have the same number of columns, and MySQL fails with error 1222.
  • ORDER BY goes at the very end. SQLite rejects the fourth query with “ORDER BY clause should come after UNION not before”; PostgreSQL and MySQL report a syntax error. At the end of a compound query, ORDER BY and LIMIT apply to the whole result, as the last section shows.

Duplicates and NULLs

A set operation compares whole rows, and for that comparison two NULLs count as the same value. This is a different rule from the = operator, under which NULL = NULL is not true, and it makes set operations behave differently from joins on columns that contain NULLs:

Coupons of two months, with NULL for no coupon SQL · nulls_in_sets.sql
-- Orders of two months with the coupon each used; NULL means no coupon.
CREATE TABLE march_orders (id INTEGER PRIMARY KEY, coupon TEXT);
CREATE TABLE april_orders (id INTEGER PRIMARY KEY, coupon TEXT);
INSERT INTO march_orders VALUES (1, 'WELCOME10'), (2, NULL), (3, 'FESTIVE'), (4, NULL);
INSERT INTO april_orders VALUES (5, 'FESTIVE'), (6, NULL), (7, 'SUMMER5');

-- 1. UNION: every coupon value of both months, the two NULLs of March and the one of April kept once.
SELECT coupon FROM march_orders
UNION
SELECT coupon FROM april_orders
ORDER BY coupon;

-- 2. INTERSECT: values that occur in both months. NULL counts as the same value on both sides.
SELECT coupon FROM march_orders
INTERSECT
SELECT coupon FROM april_orders
ORDER BY coupon;

-- 3. The same question as a join: NULL = NULL is not true, so the NULL rows never pair up.
SELECT DISTINCT m.coupon
FROM march_orders AS m
JOIN april_orders AS a ON a.coupon = m.coupon
ORDER BY m.coupon;

-- 4. EXCEPT: March values that April does not have. March's NULL goes, because April has a NULL too.
SELECT coupon FROM march_orders
EXCEPT
SELECT coupon FROM april_orders
ORDER BY coupon;

Output

┌───────────┐
│  coupon   │
├───────────┤
│           │
│ FESTIVE   │
│ SUMMER5   │
│ WELCOME10 │
└───────────┘
┌─────────┐
│ coupon  │
├─────────┤
│         │
│ FESTIVE │
└─────────┘
┌─────────┐
│ coupon  │
├─────────┤
│ FESTIVE │
└─────────┘
┌───────────┐
│  coupon   │
├───────────┤
│ WELCOME10 │
└───────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < nulls_in_sets.sql

In these output tables an empty cell is a NULL. UNION returns four values, and the three NULLs of the two months become a single row. INTERSECT returns FESTIVE and the NULL, because both months had orders without a coupon. The join asks the same question with a.coupon = m.coupon, a comparison that is never true for NULLs, so it finds only FESTIVE. EXCEPT removes March’s NULL as well, because April also has one. PostgreSQL sorts the NULL row last instead of first; the rows are the same.

The comparison also follows the column’s collation, its rule for comparing text. SQLite compares text byte by byte unless a column says otherwise, and PostgreSQL’s built-in collations count two strings as equal only when they have the same bytes, so 'Asha' and 'ASHA' stay two rows in both. MySQL 8.4’s default collation, utf8mb4_0900_ai_ci, is case- and accent-insensitive, so there SELECT 'Asha' UNION SELECT 'ASHA' returns a single row, and so does SELECT 'Jose' UNION SELECT 'José'. When the same data gives different row counts in two engines, compare the collations first.

INTERSECT and EXCEPT in each engine

The three operators exist in all three engines, but not every form of them, and they are not all read in the same order:

Set operations in PostgreSQL, MySQL and SQLite
Criterion PostgreSQL 18 and MySQL 8.4SQLite 3.49
UNION, UNION ALL YesYes
INTERSECT, EXCEPT Yes (MySQL since 8.0.31)Yes
INTERSECT ALL, EXCEPT ALL YesNo: a syntax error
A UNION B INTERSECT C A UNION (B INTERSECT C)(A UNION B) INTERSECT C
A part in parentheses Yes, for example with its own LIMITNo: use a subquery in FROM
ORDER BY at the end Names or positions of the result columns; MySQL also takes expressionsNames of any part, or positions

With ALL, the two operators count copies instead of only noting that a row exists. PostgreSQL’s manual gives the arithmetic: a row that appears m times on the left and n times on the right appears min(m, n) times after INTERSECT ALL and max(m − n, 0) times after EXCEPT ALL. A product sold three times in March and once in April therefore comes out once from INTERSECT ALL and twice from EXCEPT ALL, which is the difference in sales counts rather than in the list of products. MySQL added INTERSECT and EXCEPT in version 8.0.31, with DISTINCT and ALL for both; in SQLite only UNION has an ALL form.

INTERSECT goes first, except in SQLite

PostgreSQL and MySQL evaluate INTERSECT before UNION and EXCEPT, the way multiplication goes before addition, and their manuals say so in almost the same words. SQLite does not: its manual says that a compound query of three or more parts is grouped from left to right. The same text therefore gives different answers:

Every VIP, plus the customers who paid both ways SQL · precedence.sql
-- The same payments, and a list of VIP customers.
CREATE TABLE payments (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, method TEXT NOT NULL);
INSERT INTO payments VALUES
  (1, 'Asha', 'upi'), (2, 'Bilal', 'card'), (3, 'Asha', 'card'), (4, 'Chitra', 'upi'), (5, 'Dev', 'cod'),
  (6, 'Bilal', 'card'), (7, 'Asha', 'upi'), (8, 'Esha', 'card'), (9, 'Chitra', 'upi'), (10, 'Farhan', 'upi'),
  (11, 'Esha', 'upi');
CREATE TABLE vip (customer TEXT NOT NULL);
INSERT INTO vip VALUES ('Dev'), ('Farhan');

-- The question: every VIP, plus the customers who paid both by card and by upi.

-- 1. Written in that order. SQLite reads it from left to right: (vip UNION card) INTERSECT upi.
SELECT customer FROM vip
UNION
SELECT customer FROM payments WHERE method = 'card'
INTERSECT
SELECT customer FROM payments WHERE method = 'upi'
ORDER BY customer;

-- 2. The INTERSECT first: left to right is now the intended reading, in every engine.
SELECT customer FROM payments WHERE method = 'card'
INTERSECT
SELECT customer FROM payments WHERE method = 'upi'
UNION
SELECT customer FROM vip
ORDER BY customer;

-- 3. The INTERSECT inside a subquery: no reading order to remember.
SELECT customer FROM vip
UNION
SELECT customer FROM (
  SELECT customer FROM payments WHERE method = 'card'
  INTERSECT
  SELECT customer FROM payments WHERE method = 'upi'
) AS both_ways
ORDER BY customer;

Output

┌──────────┐
│ customer │
├──────────┤
│ Asha     │
│ Esha     │
│ Farhan   │
└──────────┘
┌──────────┐
│ customer │
├──────────┤
│ Asha     │
│ Dev      │
│ Esha     │
│ Farhan   │
└──────────┘
┌──────────┐
│ customer │
├──────────┤
│ Asha     │
│ Dev      │
│ Esha     │
│ Farhan   │
└──────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < precedence.sql

The question is “every VIP, plus the customers who paid both by card and by upi”, and the intended answer is Asha, Dev, Esha and Farhan. Written in the order of the sentence, SQLite first forms vip UNION card and then intersects that with the upi list, which drops Dev, a VIP who only ever paid cash. PostgreSQL and MySQL return all four names for the same text. The second and third queries return the right answer everywhere: the second puts the INTERSECT first, so that reading from left to right and the precedence of PostgreSQL and MySQL agree, and the third moves the INTERSECT into a subquery, which needs no rule at all. PostgreSQL and MySQL would also accept parentheses around card INTERSECT upi, but SQLite rejects parentheses around the parts of a compound query, so the subquery is the form that runs in all three.

Sorting and limiting the combined result

ORDER BY and LIMIT written after the last query belong to the compound query as a whole, so they sort and cut the combined rows. To sort or limit one part on its own, give that part its own subquery:

The top three overall, then the top two of each method SQL · sort_and_limit.sql
-- The shop's payments again, with their amounts in paise.
CREATE TABLE payments (
  id           INTEGER PRIMARY KEY,
  customer     TEXT    NOT NULL,
  method       TEXT    NOT NULL,
  amount_paise INTEGER NOT NULL
);
INSERT INTO payments VALUES
  (1,  'Asha',   'upi',   45000), (2,  'Bilal',  'card', 120000), (3,  'Asha',   'card',  30000),
  (4,  'Chitra', 'upi',    8000), (5,  'Dev',    'cod',   56000), (6,  'Bilal',  'card',  15000),
  (7,  'Asha',   'upi',   99000), (8,  'Esha',   'card',  64000), (9,  'Chitra', 'upi',   21000),
  (10, 'Farhan', 'upi',   37000), (11, 'Esha',   'upi',   12000);

-- 1. ORDER BY and LIMIT at the end apply to the combined result: the three largest upi or card payments.
SELECT id, customer, method, amount_paise FROM payments WHERE method = 'upi'
UNION ALL
SELECT id, customer, method, amount_paise FROM payments WHERE method = 'card'
ORDER BY amount_paise DESC
LIMIT 3;

-- 2. The two largest payments of each method: each query sorts and limits inside a subquery.
SELECT * FROM (SELECT id, customer, method, amount_paise FROM payments
               WHERE method = 'upi' ORDER BY amount_paise DESC LIMIT 2) AS top_upi
UNION ALL
SELECT * FROM (SELECT id, customer, method, amount_paise FROM payments
               WHERE method = 'card' ORDER BY amount_paise DESC LIMIT 2) AS top_card
ORDER BY method, amount_paise DESC;

Output

┌────┬──────────┬────────┬──────────────┐
│ id │ customer │ method │ amount_paise │
├────┼──────────┼────────┼──────────────┤
│ 2  │ Bilal    │ card   │ 120000       │
│ 7  │ Asha     │ upi    │ 99000        │
│ 8  │ Esha     │ card   │ 64000        │
└────┴──────────┴────────┴──────────────┘
┌────┬──────────┬────────┬──────────────┐
│ id │ customer │ method │ amount_paise │
├────┼──────────┼────────┼──────────────┤
│ 2  │ Bilal    │ card   │ 120000       │
│ 8  │ Esha     │ card   │ 64000        │
│ 7  │ Asha     │ upi    │ 99000        │
│ 1  │ Asha     │ upi    │ 45000        │
└────┴──────────┴────────┴──────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < sort_and_limit.sql

The first query returns the three largest payments of both methods together: Bilal’s 1,200 rupees by card, Asha’s 990 by upi and Esha’s 640 by card. The second takes the two largest of each method separately, which only works because each ORDER BY ... LIMIT 2 sits inside its own subquery; the final ORDER BY then arranges the four rows. In PostgreSQL and MySQL you may instead put each query in parentheses with its own ORDER BY and LIMIT, as their manuals show, but the subquery form runs everywhere. MySQL requires a name for each subquery in FROM, which is why they are called top_upi and top_card.

SQLite Online (SQL Playground) Combine two of your own CSV files with UNION ALL, INTERSECT or EXCEPT: import each as a table, and SQLite runs in your browser, as in this lesson.

Your turn

Exercise · Easy · SQL

Find the products ordered in March but not in April

A shop wants to know which products sold in March and then stopped selling in April. The table order_lines has the columns order_id, product_id and ordered_on, a date stored as text such as 2026-03-14.

Write a query that returns one column, product_id, with one row for each product that was ordered at least once in March (an ordered_on from 2026-03-01 up to and including 2026-03-31) and never in April (from 2026-04-01 up to and including 2026-04-30).

Each product appears once, however often it was ordered, and the order of the rows does not matter. The sample tests include orders on the first and last days of both months and on the first day of May.

Starter code · march_not_april.sql

-- One row per product ordered in March and never in April: product_id.
SELECT product_id
FROM order_lines
WHERE ordered_on >= '2026-03-01' AND ordered_on < '2026-04-01';
The sample tests · tests.yaml
# Sample tests: each one builds a small order_lines table, runs your query on it and compares the rows it returns
# (in any order).
tests:
  - name: keeps a product sold in March only and drops one sold in both months
    setup: |
      CREATE TABLE order_lines (order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, ordered_on TEXT NOT NULL);
      INSERT INTO order_lines VALUES
        (1, 10, '2026-03-05'), (2, 11, '2026-03-12'), (3, 11, '2026-04-02'), (4, 12, '2026-04-20');
    expected:
      columns: [product_id]
      rows:
        - [10]
  - name: lists a product once however often it sold in March
    setup: |
      CREATE TABLE order_lines (order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, ordered_on TEXT NOT NULL);
      INSERT INTO order_lines VALUES
        (1, 20, '2026-03-02'), (2, 20, '2026-03-09'), (3, 20, '2026-03-30'), (4, 21, '2026-03-15'),
        (5, 21, '2026-03-16');
    expected:
      columns: [product_id]
      rows:
        - [20]
        - [21]
  - name: counts the first and last days of each month correctly
    setup: |
      CREATE TABLE order_lines (order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, ordered_on TEXT NOT NULL);
      INSERT INTO order_lines VALUES
        (1, 30, '2026-03-01'), (2, 31, '2026-03-31'), (3, 31, '2026-04-01'), (4, 32, '2026-03-20'),
        (5, 32, '2026-04-30'), (6, 33, '2026-03-10'), (7, 33, '2026-05-01'), (8, 34, '2026-02-28');
    expected:
      columns: [product_id]
      rows:
        - [30]
        - [33]
A hint

Write two queries, one for the products of March and one for the products of April, and put EXCEPT between them: it keeps the rows of the first result that the second does not have, and removes duplicates. For each month, compare ordered_on with the first day of the month (>=) and with the first day of the next month (<).

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

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

  1. Question 1 of 6 The first table holds the values 1, 1 and 2, the second the values 2 and 3. How many rows does this query return?

    Read the code, then choose one answer.

    SELECT x FROM first_table
    UNION ALL
    SELECT x FROM second_table;
    Show the answer to question 1

    Answer: 5

    UNION ALL appends the second result to the first and removes nothing, so 3 + 2 = 5 rows. Plain UNION would return 3 rows (1, 2 and 3), because it also removes the duplicate 1 inside the first table.

  2. Question 2 of 6 Two refund tables hold the amounts 100, 100 and 50, and 50. What does SUM return over their UNION (not UNION ALL) when only the amount column is selected?

    Type a number.

    Show the answer to question 2

    Answer: 150

    UNION keeps each distinct row once, so the four refunds shrink to the two values 100 and 50, which add up to 150. The real total is 300. Add a column that tells the rows apart, such as the order id, and combine with UNION ALL.

  3. Question 3 of 6 How many rows does SELECT NULL UNION SELECT NULL return in PostgreSQL, MySQL and SQLite?

    Choose one answer.

    Show the answer to question 3

    Answer: One row, holding NULL

    For duplicate removal, set operations treat two NULLs as the same value, so UNION keeps one NULL row. The rule is different from the = operator, under which NULL = NULL is not true.

  4. Question 4 of 6 In which engine does A UNION B INTERSECT C mean (A UNION B) INTERSECT C?

    Choose one answer.

    Show the answer to question 4

    Answer: SQLite

    SQLite evaluates a compound query from left to right, every operator at the same level. PostgreSQL and MySQL give INTERSECT a higher precedence than UNION and EXCEPT, so they read A UNION (B INTERSECT C).

  5. Question 5 of 6 What is the name of the result's column?

    Read the code, then choose one answer.

    SELECT 'Tea' AS product
    UNION ALL
    SELECT 'Rice' AS item;
    Show the answer to question 5

    Answer: product

    The column names of a compound query come from its first query. Aliases in the later queries are allowed and ignored.

  6. Question 6 of 6 In SQLite you want the two largest 'upi' payments and the two largest 'card' payments in one result. Where do the two ORDER BY ... LIMIT 2 clauses go?

    Choose one answer.

    Show the answer to question 6

    Answer: Each inside its own subquery in FROM, with UNION ALL between the two outer queries

    SQLite allows ORDER BY and LIMIT only at the end of a compound query, where they apply to the whole result, and it does not accept parentheses around the queries of a compound. A subquery in FROM can sort and limit, and this form also works in PostgreSQL and MySQL.

Interview questions

Fresher · What is the difference between UNION and UNION ALL, and when do you use each?

Both put the rows of two queries with matching column counts into one result. UNION ALL keeps every row, so its result has as many rows as the two inputs together. UNION also removes duplicate rows, both across the two inputs and inside each one, which costs a sort or a hash of the whole result. Use UNION ALL whenever the rows are events you will count or add up, such as payments or log lines, because two different events can look identical and UNION would silently merge them; adding a column that names each row’s source keeps them apart. Use UNION when you want distinct values, such as the list of customers who used either of two channels. If the inputs cannot overlap at all, UNION ALL gives the same answer with less work.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A finance report adds up refunds with SUM over a UNION of two refund tables, and its total is lower than the two tables' totals added together. Nobody deleted anything. Explain what happened, and rewrite the query so that the total is right and stays right.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    You copied a 3-million-row customers table to a new database and must show that the copy matches the original. How do you find missing, extra and changed rows with SQL, and what can make a check that returns nothing still miss differences?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • UNION keeps rows in either result, INTERSECT rows in both, EXCEPT rows of the first result that the second lacks; only EXCEPT depends on which query comes first.
  • All three remove duplicates unless you write ALL. Combine events you will count or add up with UNION ALL, or identical rows from different events vanish from the total.
  • The parts must return the same number of columns with compatible types; the result’s column names come from the first part, and ORDER BY and LIMIT go after the last one.
  • Set operations treat two NULLs as the same value and compare text with the column’s collation, which in MySQL is case-insensitive by default.
  • PostgreSQL and MySQL evaluate INTERSECT before UNION and EXCEPT; SQLite reads from left to right, so put the INTERSECT first or inside a subquery. INTERSECT ALL and EXCEPT ALL do not exist in SQLite.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.