Joins and anti-joins

Part of the free LogicWiz SQL & Databases course, Interview pack: SQL interview problems. The queries and practice run in the app on real Postgres.

"Find customers who never ordered." Three correct answers exist, one of them breaks on a single NULL, and a fourth "obvious" answer double-counts. This lesson is the difference.

You'll learn to

  • Write anti-joins with NOT EXISTS and LEFT JOIN … IS NULL
  • Find pairs with self-joins without duplicates
  • Delete duplicate rows and keep the lowest id
  • Avoid the fan-out and ON-vs-WHERE traps

Learn

Three anti-joins

PatternNotes
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customerid = c.customerid)Safest; NULL-proof; usually the fastest plan
LEFT JOIN orders o ON … WHERE o.orderid IS NULLFine; test a NOT NULL column of the right table
WHERE customerid NOT IN (SELECT customerid FROM orders)Breaks if the subquery can return NULL

Customers who never ordered

SELECT c.name
FROM customers AS c
WHERE NOT EXISTS (
  SELECT 1
  FROM orders AS o
  WHERE o.customer_id = c.customer_id
);

Diya and Arjun signed up but never placed an order.

Pairs without doubles

SELECT a.name AS customer_a, b.name AS customer_b, a.city
FROM customers AS a
JOIN customers AS b
  ON b.city = a.city
 AND b.customer_id > a.customer_id
ORDER BY a.city, a.customer_id, b.customer_id;

b.customer_id > a.customer_id keeps each pair once and never pairs someone with themselves. With <> you would get (Aarav, Meher) and (Meher, Aarav).

Fan-out: join customers → orders → order_items and every customer row is repeated once per item. COUNT(*) and SUM then count too much. Aggregate in a subquery first, or use COUNT(DISTINCT …) when you only need a count.

Deleting duplicates in MySQL: DELETE a FROM contacts a JOIN contacts b ON a.email = b.email AND a.id > b.id;. Postgres writes the same idea as DELETE FROM contacts a USING contacts b WHERE ….

In one line

Anti-join with NOT EXISTS (never NOT IN over a nullable column); pairs with a.id < b.id; dedupe with DELETE … USING on a lower id; count DISTINCT after a fan-out.

Interview corner

How do you delete duplicate rows but keep one?

Self-join and delete the copies with a higher id: DELETE FROM t a USING t b WHERE a.email = b.email AND a.id > b.id;. Or number rows with ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) and delete where it is above 1. Then add a UNIQUE constraint so it cannot happen again.

NOT IN, NOT EXISTS or LEFT JOIN … IS NULL?

NOT EXISTS is the safe default: it ignores NULLs and Postgres runs it as an anti-join. LEFT JOIN … WHERE right.pk IS NULL is equivalent. NOT IN returns nothing if the subquery yields a NULL, so avoid it on nullable columns.

Why did my LEFT JOIN behave like an INNER JOIN?

A WHERE condition on the right table (WHERE o.status = 'delivered') is false or unknown for the NULL rows the LEFT JOIN added, so they are removed. Move that condition into the ON clause.