Keep Every Reading While Looking at Its Neighbours
Partition and order individual records for row numbers, ranks and previous-reading comparisons without collapsing them into groups.
14 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
Analytical Queries Needed to Keep Their Detail
BOTIE
For the technician, this means a reading can retain its timestamp and identity while receiving a position within its pump’s history. That position is meaningful only after we define the partition and its ordering.
Sources for this history
The Average Alone Has Lost the Reading We Need
Botie has several readings for each pump. A grouped average can describe each pump, but the technician also needs the latest individual reading and wants to compare it with the one before it. Grouping everything into one average loses those individual rows.
A window function calculates across related rows while keeping a result attached to each row. We will first decide which rows belong together, then put those rows in an order that gives “latest” a precise meaning.
The records are invented maintenance examples. The workshop exposes record operations; the SQL worksheet executes a real query in an isolated SQLite database.
Step 1 of 1
Number inside each pump rather than across the file
All timestamps are UTC. More recent readings come first.
| Pump | Reading ID | Time | Position |
|---|---|---|---|
| P7 | 3 | 09:00 | 1 |
| P7 | 1 | 08:00 | 2 |
| P8 | 4 | 09:00 | 1 |
| P8 | 2 | 08:00 | 2 |
P7
- Reading ID
- 3
- Time
- 09:00
- Position
- 1
P7
- Reading ID
- 1
- Time
- 08:00
- Position
- 2
P8
- Reading ID
- 4
- Time
- 09:00
- Position
- 1
P8
- Reading ID
- 2
- Time
- 08:00
- Position
- 2
The window annotates rows before selection
The sort gives each partition a deterministic order, then the window annotation preserves complete rows.
Select each pump’s latest complete row
Order each pump’s readings, number them within the pump, and retain position one.
BOTIE
One latest reading row per asset
[
{
"asset_id": "P7",
"reading_id": 3,
"value": 24
},
{
"asset_id": "P8",
"reading_id": 4,
"value": null
}
]Follow the operations
1. Timestamped reading records
[ { "reading_id": 1, "asset_id": "P7", "measured_at": "2026-01-01 08:00:00", "value": 20 }, { "reading_id": 2, "asset_id": "P8", "measured_at": "2026-01-01 08:00:00", "value": 50 }, { "reading_id": 3, "asset_id": "P7", "measured_at": "2026-01-01 09:00:00", "value": 24 }, { "reading_id": 4, "asset_id": "P8", "measured_at": "2026-01-01 09:00:00", "value": null } ]2. Order rows by named fields
[ { "reading_id": 3, "asset_id": "P7", "measured_at": "2026-01-01 09:00:00", "value": 24 }, { "reading_id": 1, "asset_id": "P7", "measured_at": "2026-01-01 08:00:00", "value": 20 }, { "reading_id": 4, "asset_id": "P8", "measured_at": "2026-01-01 09:00:00", "value": null }, { "reading_id": 2, "asset_id": "P8", "measured_at": "2026-01-01 08:00:00", "value": 50 } ]3. Number rows inside each partition
[ { "reading_id": 3, "asset_id": "P7", "measured_at": "2026-01-01 09:00:00", "value": 24, "rn": 1 }, { "reading_id": 1, "asset_id": "P7", "measured_at": "2026-01-01 08:00:00", "value": 20, "rn": 2 }, { "reading_id": 4, "asset_id": "P8", "measured_at": "2026-01-01 09:00:00", "value": null, "rn": 1 }, { "reading_id": 2, "asset_id": "P8", "measured_at": "2026-01-01 08:00:00", "value": 50, "rn": 2 } ]4. Set a constant
1
5. Keep rows passing a comparison
[ { "reading_id": 3, "asset_id": "P7", "measured_at": "2026-01-01 09:00:00", "value": 24, "rn": 1 }, { "reading_id": 4, "asset_id": "P8", "measured_at": "2026-01-01 09:00:00", "value": null, "rn": 1 } ]6. Choose table columns
[ { "asset_id": "P7", "reading_id": 3, "value": 24 }, { "asset_id": "P8", "reading_id": 4, "value": null } ]7. Order rows by named fields
[ { "asset_id": "P7", "reading_id": 3, "value": 24 }, { "asset_id": "P8", "reading_id": 4, "value": null } ]8. Result
[ { "asset_id": "P7", "reading_id": 3, "value": 24 }, { "asset_id": "P8", "reading_id": 4, "value": null } ]
The timestamp strings are normalised UTC values; mixed timezone strings require normalisation before lexicographic ordering.
1. Partition the comparison before choosing the function
PARTITION BY asset_id means each pump has its own window population. Without it, one global numbering could select the latest reading in the whole table and omit other pumps.
Unlike GROUP BY, the window annotation leaves each reading row available. Its value, ID and timestamp remain connected to the same observation.
Partitioning determines which rows may influence each other. It does not sort the final report.
2. Give latest a deterministic order
ROW_NUMBER assigns one position per row within the window order. We order measured_at descending, then reading_id descending. If two readings share a timestamp, the larger ID wins under this lesson’s explicit policy.
The ID is a tie breaker, not proof that the physical measurement happened later. A production system should choose a rule that matches its event identity and revision policy.
Window ORDER BY establishes row positions. The outer ORDER BY independently controls the displayed report.
Inspect every row’s assigned position
The CTE keeps the window result available for a later filter. Here we inspect all positions before choosing the winners.
| reading_id | asset_id | measured_at | value |
|---|---|---|---|
| 1 | P7 | 2026-01-01 08:00:00 | 20 |
| 2 | P8 | 2026-01-01 08:00:00 | 50 |
| 3 | P7 | 2026-01-01 09:00:00 | 24 |
| 4 | P8 | 2026-01-01 09:00:00 | NULL |
Database setup for this example
CREATE TABLE readings(reading_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, measured_at TEXT NOT NULL, value INTEGER);
INSERT INTO readings VALUES (1,'P7','2026-01-01 08:00:00',20),(2,'P8','2026-01-01 08:00:00',50),(3,'P7','2026-01-01 09:00:00',24),(4,'P8','2026-01-01 09:00:00',NULL);WITH numbered AS (
SELECT reading_id, asset_id, value,
ROW_NUMBER() OVER (PARTITION BY asset_id ORDER BY measured_at DESC, reading_id DESC) AS rn
FROM readings
)
SELECT asset_id, reading_id, value, rn FROM numbered ORDER BY asset_id, rn;| asset_id | reading_id | value | rn |
|---|---|---|---|
| P7 | 3 | 24 | 1 |
| P7 | 1 | 20 | 2 |
| P8 | 4 | NULL | 1 |
| P8 | 2 | 50 | 2 |
P8’s latest row receives position one even though its value is unknown. Filtering value first would instead ask for the latest known reading.
3. Filter a window result in a later query stage
Window functions operate after the input rows have passed the relevant WHERE and grouping stages. SQLite does not let WHERE in the same query directly filter ROW_NUMBER(). Put the annotation in a CTE or subquery, then filter its result.
Be careful with filters placed before the window. Restricting the source to a recent day changes what LAG can see at that day’s first reading. If the previous reading must come from earlier history, compute it before restricting the displayed rows.
The stage where a filter runs changes the rows available to the window.
Check yourself
Questions on Parts 1 to 3
Question 1 of 2
Which row receives number 1?
Use the information below.
P7 has readings 3 and 5 at the same latest timestamp. The policy orders timestamp descending, then reading_id descending.4. Choose what a tie should mean
ROW_NUMBER gives tied rows different positions. RANK gives peers the same position and leaves gaps after ties. DENSE_RANK gives peers the same position without those gaps. With values 90, 90 and 70 in descending order, RANK gives 1, 1 and 3; DENSE_RANK gives 1, 1 and 2.
For a competition-style value rank, order by the value alone if equal values should remain peers. Adding a unique ID to that ranking order breaks the tie. For selecting one latest row, that deterministic tie break is exactly what we want.
The function and ordering must express the requested tie policy together.
5. Read the previous observation without joining the table to itself
LAG(value) reads an earlier row in the partition’s order. It is useful for a reading-to-reading comparison. The first row has no predecessor and receives NULL unless a default was supplied.
An earlier row can itself contain NULL, so a NULL lag value does not always mean “there was no previous record.” Keep the previous reading’s ID too if the distinction matters.
Subtracting a previous value measures change between observations. It is not automatically a rate per hour.
Expose the previous reading beside each value
Here the window runs forward through each pump’s history.
| reading_id | asset_id | measured_at | value |
|---|---|---|---|
| 1 | P7 | 2026-01-01 08:00:00 | 20 |
| 2 | P8 | 2026-01-01 08:00:00 | 50 |
| 3 | P7 | 2026-01-01 09:00:00 | 24 |
| 4 | P8 | 2026-01-01 09:00:00 | NULL |
Database setup for this example
CREATE TABLE readings(reading_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, measured_at TEXT NOT NULL, value INTEGER);
INSERT INTO readings VALUES (1,'P7','2026-01-01 08:00:00',20),(2,'P8','2026-01-01 08:00:00',50),(3,'P7','2026-01-01 09:00:00',24),(4,'P8','2026-01-01 09:00:00',NULL);SELECT asset_id, reading_id, value,
LAG(value) OVER (PARTITION BY asset_id ORDER BY measured_at, reading_id) AS previous_value
FROM readings ORDER BY asset_id, measured_at, reading_id;| asset_id | reading_id | value | previous_value |
|---|---|---|---|
| P7 | 1 | 20 | NULL |
| P7 | 3 | 24 | 20 |
| P8 | 2 | 50 | NULL |
| P8 | 4 | NULL | 50 |
The first P8 row does not borrow a previous value from P7. Partitioning keeps those histories separate.
6. Try a tied timestamp before trusting the latest-row query
A MAX(measured_at) query joined back to the original table can return two rows when two readings share the maximum time. That might be correct for a request for all latest-time observations, but not for this one-row-per-pump contract.
The workshop first sorts complete records, then adds a row number and selects position one. New timestamps and ties should change the winner through those operations, without hard-coded reading IDs.
Inspect the sorted records when a winner looks wrong. The numbering cannot repair the wrong order.
7. Keep frame-sensitive calculations for an explicit frame
Row numbers, ranks and LAG describe positions or neighbours. Running totals and rolling averages also need a decision about which range of nearby rows contributes to each answer.
That range is a window frame. The next lesson will use tied timestamps to show why an implicit frame can produce a result different from the row-by-row total we expected.
A partition defines the group of related rows. A frame can select a smaller portion of that partition for the current calculation.
Check yourself
Questions on Parts 1 to 7
Question 1 of 2
What does RANK assign?
Use the information below.
Values ordered descending are 90, 90 and 70. The ranking order uses value alone.Try it with Botie
Build with Botie
Write and run SQL