All posts

SQL readability rules for joins and subqueries

Rules for readable SQL joins and subqueries with before/after examples: explicit JOINs, ON-clause layout, aliases, CTEs, EXISTS vs IN and the NOT IN NULL trap.

By Last reviewed 7 min readSQLJoinsReadability

A formatter fixes layout: line breaks, indentation, keyword case. It can't choose your join syntax, name your aliases or decide whether a subquery should be a CTE. Those choices decide whether the next person can read the query, and a few of them decide whether it returns the right rows.

This guide applies those rules to one query. Every complete query below is real output from the SQL formatter with default settings, so the before and after differ only in choices a formatter can't make. Short fragments are excerpts of that output or, where the text says so, hand layout.

The query before

It lists paid orders above the average paid order, with the customer's region, for customers who have never had a refund:

SELECT
  a.id,
  a.name,
  b.total,
  d.name
FROM
  customers a,
  orders b,
  regions d
WHERE
  a.id = b.customer_id
  AND a.region_id = d.id
  AND b.status = 'paid'
  AND a.id NOT IN (
    SELECT
      customer_id
    FROM
      refunds
  )
  AND b.total > (
    SELECT
      AVG(total)
    FROM
      orders
    WHERE
      status = 'paid'
  );

It's formatted and still hard to read. The joins hide among the filters in WHERE. The aliases a, b and d say nothing about their tables. Two output columns are called name. The paid filter is written twice. And the NOT IN has a bug that depends on your data.

Use explicit JOIN syntax

Comma joins put join conditions in WHERE, mixed in with filters. Explicit JOINs keep each condition next to the table it joins and leave WHERE for filtering. They also make a forgotten condition harder to miss. Drop a.region_id = d.id from the comma version and the query still runs: every order is paired with every region, and the row count multiplies by the number of regions. In PostgreSQL, a JOIN without ON or USING is a syntax error. Some databases, MySQL among them, accept it as a cross join, so explicit syntax helps everywhere but only guarantees an error in some.

Don't mix the two styles. JOIN binds more tightly than the comma, so here the ON clause can only see customers and regions, and PostgreSQL and MySQL both reject the reference to o:

SELECT *
FROM orders o, customers c
JOIN regions r ON r.id = o.region_id;  -- error: o is not visible here

Lay out ON clauses with the join key first

Start the ON clause with the join key, with the joined table's column on the left: ON o.customer_id = c.id reads as "orders belonging to this customer". Extra conditions come after the key.

For outer joins, where a condition goes changes the result. This counts each customer's paid orders, or tries to:

SELECT
  c.id,
  COUNT(o.id) AS paid_orders
FROM
  customers c
LEFT JOIN orders o
  ON o.customer_id = c.id
WHERE
  o.status = 'paid'
GROUP BY
  c.id;

Customers with no orders get NULL for o.status, NULL = 'paid' isn't true, and WHERE drops them, along with customers whose orders are all unpaid. The LEFT JOIN has quietly become an inner join. Put a filter on the optional side of an outer join into ON, and every customer stays, with a count of 0 where there are no paid orders:

LEFT JOIN orders o
  ON o.customer_id = c.id AND o.status = 'paid'

The formatter keeps all of an ON clause's conditions on one line. With three or more, break them by hand, one per line:

JOIN order_items oi
  ON oi.order_id = o.id
  AND oi.tenant_id = o.tenant_id
  AND oi.deleted_at IS NULL

Name aliases after their tables

An alias should remind you which table it stands for: c for customers, o for orders, oi for order_items, po for a paid_orders CTE. Aliases handed out by position (a, b, d) make the reader look back at FROM for every column.

Once a query has more than one table, qualify every column. An unqualified status in a query joining orders and customers works until someone adds a status column to customers; then the query fails as ambiguous. Give clashing output columns distinct aliases with AS, such as c.name AS customer_name and r.name AS region_name. Two columns called name are legal, but code that maps rows to objects by column name will often keep only one. For table aliases, AS is optional in most dialects and not allowed in Oracle, so many teams leave it out.

Prefer CTEs to deeply nested subqueries

Nested subqueries read inside out. To follow this one you start at the innermost SELECT and work outwards:

SELECT
  x.customer_id,
  x.order_count
FROM
  (
    SELECT
      o.customer_id,
      COUNT(*) AS order_count
    FROM
      (
        SELECT
          *
        FROM
          orders
        WHERE
          status = 'paid'
      ) o
    GROUP BY
      o.customer_id
  ) x
WHERE
  x.order_count > 5;

A CTE turns the same logic into named steps you read top to bottom:

WITH paid_orders AS (
  SELECT
    *
  FROM
    orders
  WHERE
    status = 'paid'
), order_counts AS (
  SELECT
    o.customer_id,
    COUNT(*) AS order_count
  FROM
    paid_orders o
  GROUP BY
    o.customer_id
)
SELECT
  oc.customer_id,
  oc.order_count
FROM
  order_counts oc
WHERE
  oc.order_count > 5;

Named steps also make simplifications visible: this one could be a single GROUP BY with HAVING COUNT(*) > 5. Keep short, single-use subqueries inline, like the AVG comparison in the rewrite below, and promote a subquery to a CTE when it's long, used twice, or worth a name.

On performance, a CTE is neither free nor a penalty by definition. Since PostgreSQL 12, a non-recursive, side-effect-free CTE referenced once is inlined into the main query (earlier versions always materialised it), and MATERIALIZED or NOT MATERIALIZED overrides that. Other databases have their own rules, so check the plan with EXPLAIN.

EXISTS vs IN, and the NOT IN trap

Both keep an outer row when a match exists, so pick the one that reads like the question. IN fits "is this value in that set" for a single column. EXISTS fits "is there a related row", especially when the match spans several columns:

WHERE
  EXISTS (
    SELECT
      1
    FROM
      support_tickets t
    WHERE
      t.customer_id = c.id
      AND t.tenant_id = c.tenant_id
      AND t.severity = 'high'
  );

The select list inside EXISTS is ignored, and SELECT 1 is a convention that says so. Many optimizers plan IN and EXISTS subqueries the same way, so choose for readability and check the plan if speed matters.

The negated forms are not interchangeable. x NOT IN (subquery) means x <> v for every value v the subquery returns, all ANDed together. If one value is NULL, that comparison is UNKNOWN, and the whole condition can never be TRUE:

-- 3 <> 1 AND 3 <> 2 AND 3 <> NULL
-- TRUE   AND TRUE   AND UNKNOWN  =>  UNKNOWN
SELECT 3 NOT IN (1, 2, NULL);  -- PostgreSQL returns NULL, not true

WHERE keeps only rows where the condition is TRUE. So in the original query, a single refund with a NULL customer_id makes a.id NOT IN (SELECT customer_id FROM refunds) reject every customer, and the query silently returns nothing. NOT EXISTS doesn't have this problem because it only asks whether a matching row exists. If you keep NOT IN, filter customer_id IS NOT NULL inside the subquery or make sure the column is declared NOT NULL.

The query after

WITH paid_orders AS (
  SELECT
    o.id,
    o.customer_id,
    o.total
  FROM
    orders o
  WHERE
    o.status = 'paid'
)
SELECT
  c.id AS customer_id,
  c.name AS customer_name,
  po.total,
  r.name AS region_name
FROM
  customers c
JOIN paid_orders po
  ON po.customer_id = c.id
JOIN regions r
  ON r.id = c.region_id
WHERE
  po.total > (
    SELECT
      AVG(p.total)
    FROM
      paid_orders p
  )
  AND NOT EXISTS (
    SELECT
      1
    FROM
      refunds rf
    WHERE
      rf.customer_id = c.id
  );

Joins carry their own conditions, aliases match their tables, output columns have unique names, and the paid filter lives in one place. Swapping NOT IN for NOT EXISTS is a behaviour change, not a style change: if refunds.customer_id ever holds a NULL, the old query returned no rows and the new one returns the rows you meant. Say so in the review rather than folding it into a "readability" commit. Pasting both versions into the diff checker makes it easy to walk a reviewer through each change.

Keep naming consistent

Readable joins depend on predictable names. Pick one convention and hold it across the schema: snake_case, foreign keys named after the table they point to (orders.customer_id to customers.id), _at for timestamps, is_ or has_ for booleans. Then a correct ON o.customer_id = c.id looks right at a glance, and a wrong one stands out.

Avoid naming tables and columns after SQL keywords. order and user are reserved in PostgreSQL and need quoting every time, and keyword-based formatters treat words like date and key as keywords: the DevPrix formatter uppercases a column called date because it can't tell it from the type.

Next steps

Run your next query through the SQL formatter so the layout is settled, then apply these rules to what's left. For how the layout rules themselves work, read how to format complex SQL queries.

Paste anything. devprix picks the tool.

Press ⌘K on any page, paste JSON, a JWT, a timestamp or a URL, and the right tool opens with your data filled in.