Sharon Ben-Moshe is the founder of sqlfmt, a browser-based SQL formatter and validator with misspelled-keyword suggestions across PostgreSQL, MySQL, SQLite, SQL Server, and BigQuery.
Multi-join queries turn into walls of text fast. Here's how to format JOINs, ON conditions, and join chains so the query structure reads at a glance.
Quick answer
Put each JOIN on its own line aligned with FROM, keep the join type explicit (INNER JOIN, LEFT JOIN), and place the ON condition on the same line as the JOIN when short or on an indented line below when it has multiple conditions. Always qualify columns with table aliases so the reader can trace each predicate to its table. The goal is that the list of joined tables reads as a vertical column you can scan top to bottom.
FAQs
Should the ON condition go on the same line as JOIN or a separate line?
Put a single-predicate ON on the same line as its JOIN — the join and its key are one thought. When the ON has two or more conditions, move each to an indented line below the JOIN with a leading AND, so the predicates line up in a scannable column and no line runs off the right edge.
What is the difference between putting a condition in ON versus WHERE?
A condition in ON is part of the join; a condition in WHERE filters the already-joined result. For INNER JOIN they produce the same result. For LEFT JOIN they do not: a filter on the right table placed in WHERE discards the NULL-extended rows, silently turning the LEFT JOIN into an INNER JOIN. To keep unmatched rows, that filter must go in ON.
Is it better to write JOIN or INNER JOIN?
They are functionally identical — a bare JOIN means INNER JOIN in every major SQL dialect. For readability, write INNER JOIN explicitly. In a query that mixes inner and outer joins, the explicit INNER label makes it instantly clear which joins can drop rows and which preserve them, without the reader having to infer it.
Why should I alias and qualify columns in a joined query?
Table aliases let you trace every column back to its source table, which is essential once a query joins more than two tables. Qualifying columns also protects against ambiguous-column errors: if two joined tables both gain a column of the same name after a schema change, an unqualified reference becomes ambiguous and the query breaks. Qualifying up front makes the query resilient.
How do you format a query with many joins?
Put each join on its own line aligned with FROM, write join types explicitly, and order the joins so each table connects to one already introduced above it. Give every table a short alias and qualify all columns. The result reads as a vertical sequence of tables you can follow link by link, rather than a block you have to parse.
Try it now
Paste your SQL into sqlfmt — format, validate, and catch typos free in your browser.
A query with one JOIN reads fine in almost any layout. A query with five JOINs, mixed INNER and LEFT, compound ON conditions, and filters that could live in either ON or WHERE becomes unreadable unless you format it deliberately. The rules are few and they pay off immediately.
This guide covers where the JOIN keyword goes, how to lay out ON conditions, when to keep a predicate in ON versus WHERE, how to format long join chains, and the alias discipline that makes every join traceable. Each rule comes with a before-and-after you can apply today.
Put each JOIN on its own line
The single most important rule: every JOIN starts a new line, left-aligned with FROM. This turns the set of tables in the query into a vertical list you can scan top to bottom. When joins are strung across lines or buried mid-line, you have to mentally parse where one table ends and the next begins.
Before: joins run together
-- The joined tables are hard to pick outSELECT o.id, c.name, p.title FROM orders o JOIN customers c ON o.customer_id = c.id JOIN products p ON o.product_id = p.id WHERE o.status = 'shipped';
After: one JOIN per line, aligned with FROM
SELECT o.id, c.name, p.titleFROM orders AS oJOIN customers AS c ON o.customer_id = c.idJOIN products AS p ON o.product_id = p.idWHERE o.status = 'shipped';
Now the reader sees three tables joined into orders, one per line, each with its join condition attached. The structure is visible before you read a single predicate.
Always write the join type explicitly
A bare JOIN means INNER JOIN in every major dialect, but writing it out makes intent unambiguous. The meaningful distinction in a multi-join query is INNER versus LEFT — it tells the reader whether rows can drop out or whether unmatched rows survive with NULLs. Spell out INNER JOIN and LEFT JOIN so that distinction is never inferred. The PostgreSQL table-expression docs describe how each join type resolves matches.
Before: bare JOIN hides intent
SELECT u.name, o.totalFROM users AS uJOIN orders AS o ON u.id = o.user_idJOIN refunds AS r ON o.id = r.order_id;
After: explicit join types
-- Now it's clear which tables can drop rowsSELECT u.name, o.totalFROM users AS uINNER JOIN orders AS o ON u.id = o.user_idLEFT JOIN refunds AS r ON o.id = r.order_id;
Aligning the JOIN keyword as shown (padding INNER and LEFT to the same width) is optional, but it makes the table names line up, which reinforces the vertical-list effect.
Where the ON condition goes
A single-predicate ON belongs on the same line as its JOIN — the join and the key it joins on are one thought. When the ON has two or more conditions, move each condition to an indented line below the JOIN, with the AND leading each continuation line. This keeps long predicates from pushing the line off the right edge while keeping them visually attached to their join.
Single condition: ON stays inline
FROM orders AS oLEFT JOIN customers AS c ON o.customer_id = c.id
Multiple conditions: each on its own indented line
FROM orders AS oLEFT JOIN customers AS c ON o.customer_id = c.id AND c.region = o.region AND c.active = TRUE
Leading with AND (rather than trailing it) lines up every condition under the same column, so you can scan the predicates of a join the same way you scan the joins themselves. This mirrors how leading commas line up a SELECT list.
ON versus WHERE: put the predicate where it belongs
This is a formatting decision with correctness consequences. A condition in ON is part of the join; a condition in WHERE filters the joined result. For INNER JOIN the two are equivalent, but for LEFT JOIN they are not — a filter on the right table placed in WHERE silently turns the LEFT JOIN into an INNER JOIN by discarding the NULL-extended rows.
The readable convention: keep join keys in ON, keep row filters in WHERE — unless the filter targets the right side of a LEFT JOIN and you want to preserve unmatched rows, in which case it must go in ON.
Formatting long join chains
When a query joins five or more tables, two habits keep it readable. First, order the joins so each one connects to a table already introduced above it — the reader follows the chain link by link instead of jumping around. Second, give every table a short, meaningful alias and qualify every column with it, so any predicate can be traced to its source without scrolling up to the FROM clause.
Notice that each join references a table already on screen: products joins orders, categories joins products, and so on. Aliases are short but recognizable (cat, not c2). The whole chain reads as a sequence, not a puzzle.
Alias every table, qualify every column
In a multi-table query, an unqualified column forces the reader (and sometimes the parser) to guess which table it came from. Qualify every column with its table alias, even when the name is unambiguous today — a later schema change can introduce a collision that turns an unqualified reference into an ambiguous-column error. Use AS for table aliases for consistency, and pick aliases that hint at the table name.
SQL JOIN formatting checklist
Put each JOIN on its own line, left-aligned with FROM.
Write the join type explicitly — INNER JOIN, LEFT JOIN — never a bare JOIN.
Keep a single ON condition inline; move multiple conditions to indented lines with leading AND.
Keep join keys in ON and row filters in WHERE — except filters on the right side of a LEFT JOIN, which belong in ON.
Order joins so each table connects to one already introduced above it.
Give every table a short, meaningful alias with AS.
Qualify every column with its table alias, even when unambiguous.
Optionally pad INNER/LEFT to equal width so table names align into a column.
Wrapping up
Well-formatted JOINs turn a query into a readable map of how its tables connect: one join per line, explicit types, ON conditions attached to their joins, and every column traceable to a table. The ON-versus-WHERE rule is the one place where formatting and correctness intersect — get it wrong on a LEFT JOIN and you silently lose rows. These conventions sit alongside the rest of SQL formatting best practices, and pair naturally with a consistent comma style in the SELECT list above your joins.
sqlfmt formats SQL across BigQuery, PostgreSQL, MySQL, SQLite, and SQL Server — paste a tangled multi-join query, pick your dialect, and it lays each join out on its own line with aligned conditions. The Pro plan adds live formatting as you type, so join chains stay readable while you build them.
-- Drops customers with no 2026 orders — acts like INNER JOINSELECT c.name, o.idFROM customers AS cLEFT JOIN orders AS o ON c.id = o.customer_idWHERE o.created_at >= '2026-01-01';
Fixed: filter in ON preserves unmatched rows
-- Keeps every customer; only 2026 orders attachSELECT c.name, o.idFROM customers AS cLEFT JOIN orders AS o ON c.id = o.customer_id AND o.created_at >= '2026-01-01';
A six-table join chain, formatted
SELECT o.id AS order_id, c.name AS customer, p.title AS product, cat.name AS category, w.city AS warehouse, s.carrier AS shipperFROM orders AS oINNER JOIN customers AS c ON o.customer_id = c.idINNER JOIN products AS p ON o.product_id = p.idINNER JOIN categories AS cat ON p.category_id = cat.idLEFT JOIN warehouses AS w ON o.warehouse_id = w.idLEFT JOIN shipments AS s ON o.id = s.order_idWHERE o.status = 'shipped'ORDER BY o.id;
Before: unqualified columns in a joined query
-- Which table does status come from? created_at?SELECT id, name, status, created_atFROM orders AS oJOIN customers AS c ON o.customer_id = c.id;
After: every column traced to its table
SELECT o.id, c.name, o.status, o.created_atFROM orders AS oINNER JOIN customers AS c ON o.customer_id = c.id;