skip to content

Using a self-join, how do you pair each sensor reading with the previous reading for that device?

level: seniorimportance: should knowfreq 42%

answer

  1. a join has no idea of row order
  2. adjacency has to be a predicate
  3. first pair with all earlier rows
  4. then keep only the nearest one
  5. nothing may fall in between

basics

~20 s

A join has no notion of "the row before", so the ON clause must define it: join the table to itself on the same device with an earlier timestamp, then keep only the nearest earlier row — via a MAX subquery or a NOT EXISTS check that no reading falls in between.

solid answer

~40 s

```sql SELECT c.reading_id, c.taken_at, p.taken_at AS prev_at, c.value - p.value AS delta FROM readings AS c JOIN readings AS p ON p.device_id = c.device_id AND p.taken_at < c.taken_at WHERE NOT EXISTS ( SELECT 1 FROM readings AS m WHERE m.device_id = c.device_id AND m.taken_at > p.taken_at AND m.taken_at < c.taken_at); ``` The join alone pairs each reading with *all* earlier readings for its device; the `NOT EXISTS` (or an equivalent `p.taken_at = (SELECT MAX(...))`) reduces that to the immediate predecessor. Two rules make or break it: repeat the partitioning column in every predicate so devices do not cross-contaminate, and never assume `p.id = c.id - 1` — surrogate keys have gaps and interleave devices. The earliest reading has no predecessor, so use `LEFT JOIN` if you must keep it.

code

sql · 9 lines
sql
SELECT c.reading_id, c.taken_at, p.taken_at AS prev_at,
       c.value - p.value AS delta
FROM readings AS c
JOIN readings AS p
  ON p.device_id = c.device_id
 AND p.taken_at = (SELECT MAX(r.taken_at)
                   FROM readings AS r
                   WHERE r.device_id = c.device_id
                     AND r.taken_at < c.taken_at);

go deeper

for a junior

Recognise that SQL has no built-in "previous row" in a join and that the previous row must be described by a condition. Be able to read the query and say what one output row means.

for a middle

Explain both reductions — nearest earlier timestamp via MAX, or nothing in between via NOT EXISTS — and why the raw self-join without one produces every earlier-later pair.

for a senior

Show the production instincts: repeat the partition key in every predicate, add a tiebreaker so adjacency is a total order, keep or deliberately drop the first row, and reject the id-arithmetic shortcut with concrete reasons.

for a principal

Weigh whether consecutive-row analysis belongs in the query layer at all for the data volumes involved, and set the convention for how event ordering is defined so every team's adjacency queries agree.

## "Previous" is not a join concept A join pairs rows that satisfy a predicate. It has no cursor, no ordering, and no idea what "the row before" means. So the whole task is to write a predicate that *characterises* the previous row, and there are only two honest ways to do that: say it is the maximum of the earlier rows, or say no row lies strictly between it and the current one. ## Formulation 1: the nearest earlier row is the maximum ```sql SELECT c.reading_id, c.taken_at, p.taken_at AS prev_at, c.value - p.value AS delta FROM readings AS c JOIN readings AS p ON p.device_id = c.device_id AND p.taken_at = (SELECT MAX(r.taken_at) FROM readings AS r WHERE r.device_id = c.device_id AND r.taken_at < c.taken_at); ``` The correlated subquery computes, for each current row, the largest timestamp strictly below it within the same device; the join then fetches that row. ## Formulation 2: nothing in between ```sql FROM readings AS c JOIN readings AS p ON p.device_id = c.device_id AND p.taken_at < c.taken_at WHERE NOT EXISTS (SELECT 1 FROM readings AS m WHERE m.device_id = c.device_id AND m.taken_at > p.taken_at AND m.taken_at < c.taken_at) ``` This reads as the definition of adjacency: `p` precedes `c` and nothing sits between them. Many people find it the clearest statement of intent. Both formulations produce the same rows. ## Why the reduction step is mandatory Drop the `NOT EXISTS` or the `MAX` and you no longer have consecutive pairs — you have every earlier row paired with every later one. For a device with 5 readings the 5th row alone produces 4 output rows and the device produces 10 in total. The symptom in the wild is a "change since last reading" report whose row count is far larger than the table and whose deltas look wildly wrong. If you see triangular growth in a result set, an adjacency reduction is missing. ## The offsets-by-arithmetic trap The tempting shortcut is `ON p.reading_id = c.reading_id - 1`. It is almost always wrong: - Surrogate keys have **gaps** — rolled-back transactions, deletes, cached sequence blocks — so `id - 1` may not exist and the row silently drops out. - With several devices writing into one table the ids **interleave**, so `id - 1` is usually a different device's reading. - Ids are not guaranteed to be assigned in timestamp order under concurrency. The arithmetic is only defensible over a dense, gap-free sequence you generated yourself, for example a row number materialised in a prior step. ## Partitioning and ties Every predicate that defines adjacency must repeat the partitioning column. `p.device_id = c.device_id` appears in the join *and* inside the `NOT EXISTS`; forget it in the inner query and a reading from another device will "come between" two of yours and wipe out legitimate pairs. Ties are the other correctness hazard. If two readings for one device share a timestamp, `<` excludes both of them from being each other's predecessor and the pair count changes shape — the second formulation may return two predecessors for one row. Adjacency needs a **total order**, so add a deterministic tiebreaker to every comparison, comparing row values: `(p.taken_at, p.reading_id) < (c.taken_at, c.reading_id)`. ## Keeping the first row The earliest reading of each device has no predecessor and disappears from an inner join. If the report needs a row per reading, use `LEFT JOIN` and let the predecessor columns come back NULL — then guard the arithmetic, since `c.value - p.value` is NULL when `p` is missing. ## Scope and the modern alternative The adjacency self-join is the portable formulation and predates window functions, which entered the standard in SQL:2003. Where your engine supports them, `LAG(value) OVER (PARTITION BY device_id ORDER BY taken_at)` expresses the same thing in one pass and without the correlated reduction; it is worth naming in an interview so it is clear you know the self-join is a deliberate choice, not the only tool. The self-join formulation still earns its keep when you need the *whole* predecessor row, when adjacency is defined by something more complex than an ordering (nearest earlier reading **of the same type**, say), or on an engine or a view definition where windows are unavailable. ## What the interviewer is testing That you can define adjacency as a predicate rather than reaching for a positional operator that does not exist; that you spot the id-arithmetic trap; that you repeat the partition key everywhere; and that you know the first row vanishes unless you ask for it.

  • Why is joining on p.reading_id = c.reading_id - 1 unsafe?
    Surrogate keys have gaps from rollbacks, deletes and cached sequence blocks, so the arithmetic may point at no row at all; and when several devices write to one table the ids interleave, so `id - 1` usually belongs to a different device. Ids are also not guaranteed to follow timestamp order under concurrency. Only a dense sequence you generated yourself makes the shortcut safe.
  • What happens if two readings for the same device share a timestamp?
    Adjacency stops being well defined: with a strict `<` the tied rows cannot precede each other, and the reduction step can return two predecessors for one row. Restore a total order by adding a deterministic tiebreaker to every comparison, for example `(p.taken_at, p.reading_id) < (c.taken_at, c.reading_id)`, applied in the join and inside the NOT EXISTS alike.
  • Why does the earliest reading vanish, and how do you keep it?
    It has no earlier row, so the inner join finds no partner and drops it. Use `LEFT JOIN` with the adjacency condition, which NULL-extends the predecessor columns for the first reading of each device. Any arithmetic over those columns then yields NULL, so wrap it — for instance report the delta as NULL deliberately, or coalesce it — rather than letting it look like zero change.

saying these in an interview costs you the question

  • Assumes the join returns rows in table order
  • Joins on id minus one and calls it the previous row
  • Omits the reduction and pairs every earlier row
  • Forgets the device key inside the NOT EXISTS
  • Ignores that the first row of each group disappears

context