skip to content

A NATURAL JOIN report returned rows until a migration added created_at to both tables — why, and how do you fix it?

level: seniorimportance: should knowfreq 32%

answer

  1. nothing in the query text changed
  2. the condition is derived, not written
  3. new shared name, new equality
  4. two timestamps are never equal
  5. pin the list with USING instead

basics

~20 s

NATURAL JOIN re-derives its condition from whatever column names the two tables currently share, so created_at silently joined the key list and rows now match only when both timestamps are equal — almost never. Replace it with an explicit ON or USING.

solid answer

~40 s

`NATURAL JOIN` has no written join condition; the engine derives one from every column name the two tables have in common at compile time. Adding `created_at` to both tables added `AND l.created_at = r.created_at` to that derived condition, and two independently generated timestamps are essentially never equal, so the report collapsed to zero rows. Nothing about the query text changed, which is why the migration diff looked harmless. Confirm the diagnosis by listing both tables' columns — `information_schema.columns` will show the newly shared names — then fix it by writing the condition down: `JOIN r USING (order_id)` if the key name matches on both sides, or `JOIN r ON l.order_id = r.id` otherwise. The lasting fix is banning `NATURAL JOIN` from stored code; the same migration against `USING (order_id)` would have been a no-op.

code

sql · 8 lines
sql
-- what the query says
SELECT order_id, SUM(line_total) AS order_total
FROM orders NATURAL JOIN order_lines
GROUP BY order_id;

-- what it now means after created_at was added to both tables
-- ... ON orders.order_id = order_lines.order_id
--     AND orders.created_at = order_lines.created_at   <-- never true

go deeper

for a junior

Recall that NATURAL JOIN joins on all shared column names, so a new column with the same name on both sides becomes part of the join condition. That alone explains the empty report.

for a middle

Walk the mechanics: the derived condition gained AND l.created_at = r.created_at, timestamps never coincide, and the rewrite to USING (order_id) or an explicit ON restores it.

for a senior

Show the diagnostic path — text unchanged, data unchanged, so look at the schema — and name the failure-mode argument for explicit conditions: USING breaks the build, NATURAL JOIN breaks the numbers.

for a principal

Own the systemic fix: a lint rule against NATURAL JOIN, an audit of views and reports, and assertions on critical report outputs so a query that silently returns nothing cannot pass as success.

## What actually changed The query text is unchanged and the data is unchanged, so the instinct is to look at the data. The right place to look is the schema. `NATURAL JOIN` is defined as `USING (<every column name present in both tables>)`. That list is not stored with the query; it is derived each time the statement is compiled, from the tables as they exist then. Before the migration the tables shared exactly one name and the derived condition was: ```sql l.order_id = r.order_id ``` After a migration that added an audit column `created_at` to both tables, the derived condition became: ```sql l.order_id = r.order_id AND l.created_at = r.created_at ``` Two timestamps written by two different inserts are equal only by coincidence — at microsecond precision, essentially never. The join therefore matches almost nothing, and the report returns zero rows without an error, without a warning, and without any change to the SQL under version control. The same failure has quieter variants. A `status`, `name`, `id`, `type` or `updated_at` column added to one side of a pair that already has it on the other produces a partial collapse rather than a total one: some rows still match, the totals are wrong, and nobody notices for a quarter. ## Diagnosing it The tell is that the query text and the data both look fine while the result changed. A quick sequence: 1. **Expand the natural join by hand.** List both tables' columns and intersect the names. The standard, portable way is the catalog: ```sql SELECT column_name FROM information_schema.columns WHERE table_name = 'order_lines' INTERSECT SELECT column_name FROM information_schema.columns WHERE table_name = 'orders'; ``` Any name in that result that is not part of the intended key is your bug. 2. **Rewrite the join explicitly and compare.** Run the same query with `USING (order_id)` and see the rows come back. That both confirms the diagnosis and is the fix. 3. **Check the migration.** The change that broke it is a `ALTER TABLE ... ADD COLUMN` on either side, quite possibly in a different service's migration set. It will not mention the report at all. ## Fixing it The immediate fix is to write the condition down: ```sql -- before SELECT order_id, SUM(line_total) AS order_total FROM orders NATURAL JOIN order_lines GROUP BY order_id; -- after: the join condition is now part of the query, not of the schema SELECT order_id, SUM(l.line_total) AS order_total FROM orders o JOIN order_lines l USING (order_id) GROUP BY order_id; ``` `USING` keeps the pleasant properties people liked about `NATURAL JOIN` — a short join clause and a single merged key column that `SELECT *` shows once — while pinning the condition. Where the two sides name the key differently, `ON o.order_id = l.order_ref` is the right answer instead. Note the crucial asymmetry: `USING (order_id)` **fails loudly** if `order_id` is later renamed or dropped, because the statement no longer compiles. `NATURAL JOIN` fails **silently**, because it just derives a different condition. In a system with migrations you want the failure mode that stops a deploy, not the one that changes a number on a dashboard. ## Preventing a repeat - **Ban `NATURAL JOIN` in reviewed code.** It is one grep (`NATURAL JOIN`) in a lint rule or a review checklist. Ad-hoc exploration against a schema you can see is the only fair use. - **Audit views and reports first.** A `NATURAL JOIN` inside a view is the worst case: the definition is re-derived, and consumers never see the join at all. - **Watch `SELECT *` in derived tables feeding a join.** If a subquery selects `*`, the columns it exposes — and therefore any natural join above it — depend on yet another table's shape. - **Add a row-count assertion to critical reports.** A report that can go to zero rows without failing should have a check that notices. - **Treat 'add an audit column everywhere' migrations as risky.** Adding the *same* name to many tables is precisely the change that maximises natural-join collateral damage. ## The general lesson An implicit join condition couples a query's meaning to the schema's naming, in a way no diff shows. Explicit `ON` or `USING` moves that meaning into the query text, where review, version control and compile-time errors can all see it.

  • Why would USING (order_id) have survived the same migration untouched?
    Because `USING` states the join column list explicitly. Adding `created_at` to both tables adds an output column and nothing else — the condition is still `o.order_id = l.order_id`. And if someone later renames or drops `order_id`, the statement fails to compile, which surfaces the problem at deploy time instead of quietly returning different numbers.
  • What if the same NATURAL JOIN lives inside a view?
    It is worse. The view's definition is re-resolved against the base relations, so the join condition can change while every consumer sees an unchanged view name and column list. Consumers have no way to notice. Expand natural joins inside view definitions first when auditing, and treat views as stored code that deserves explicit join conditions.
  • Could this failure be partial rather than total, and how would you catch that?
    Yes — if the incidental shared column agrees for some rows (a `status` that often coincides, a date copied between tables), the join keeps returning rows but drops a subset. Nothing errors. Catching it needs an external signal: row-count or total-value assertions on the report, or reconciling against a query written with an explicit condition.

saying these in an interview costs you the question

  • Blames the data or a bad deploy rather than the derived join condition
  • Says adding a column cannot change what a query returns
  • Fixes it by deleting rows or adding a WHERE filter
  • Proposes keeping NATURAL JOIN and renaming the new column
  • Assumes the query would have errored if the join key changed

context