Which Assets Have No Matching Inspection?
Keep the whole asset register while attaching whatever qualifying inspection evidence exists.
14 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
A Match-Only Query Could Hide Missing Evidence
BOTIE
The left side defines the population to retain. In this lesson that population is the asset register, so the absence of inspection evidence remains visible instead of removing the asset from the report.
Sources for this history
P4 Disappeared from the Report
Botie joins the asset register to the inspection log, and P4 disappears. That is awkward: the supervisor specifically needs to notice equipment with no recorded visits. A report containing only matches hides the absence they were looking for.
A left join begins from the rows we must retain. When a register entry finds no qualifying visit, SQL keeps the asset and supplies missing values for the visit fields. We will make that placeholder visible before counting anything.
These maintenance records are invented. The workshop exposes record operations; the SQL worksheet runs actual SQLite queries.
Step 1 of 1
An unmatched asset still contributes a result row
The right-side fields are placeholders for absent evidence.
| asset_id | Matched inspection_id | Meaning |
|---|---|---|
| P1 | 101 | A real matching visit |
| P4 | NULL | No matching visit exists |
P1
- Matched inspection_id
- 101
- Meaning
- A real matching visit
P4
- Matched inspection_id
- NULL
- Meaning
- No matching visit exists
Follow the same records into the query
The left identity survives, while only non-null matched visit keys contribute to the count.
Retain the register even when a finding is absent
Filter the visits to loose findings, left-join them onto assets, then count real visit identifiers per asset.
BOTIE
All assets with qualifying visit counts
[
{
"asset_id": "P1",
"loose_visits": 2
},
{
"asset_id": "P2",
"loose_visits": 1
},
{
"asset_id": "P3",
"loose_visits": 0
},
{
"asset_id": "P4",
"loose_visits": 0
}
]Follow the operations
1. Asset register
[ { "asset_id": "P1", "site": "north", "kind": "pump" }, { "asset_id": "P2", "site": "north", "kind": "fan" }, { "asset_id": "P3", "site": "south", "kind": "pump" }, { "asset_id": "P4", "site": "south", "kind": "pump" } ]2. Inspection visits
[ { "inspection_id": 101, "asset_id": "P1", "minutes": 30, "finding": "loose" }, { "inspection_id": 102, "asset_id": "P1", "minutes": null, "finding": null }, { "inspection_id": 103, "asset_id": "P2", "minutes": 0, "finding": "clear" }, { "inspection_id": 104, "asset_id": "P3", "minutes": 20, "finding": "clear" }, { "inspection_id": 105, "asset_id": "P2", "minutes": 10, "finding": "loose" }, { "inspection_id": 106, "asset_id": "P1", "minutes": 15, "finding": "loose" } ]3. Text comparison value
loose
4. Keep rows passing a comparison
[ { "inspection_id": 101, "asset_id": "P1", "minutes": 30, "finding": "loose" }, { "inspection_id": 105, "asset_id": "P2", "minutes": 10, "finding": "loose" }, { "inspection_id": 106, "asset_id": "P1", "minutes": 15, "finding": "loose" } ]5. Match rows on a key
[ { "asset_id": "P1", "site": "north", "kind": "pump", "visit_inspection_id": 101 }, { "asset_id": "P1", "site": "north", "kind": "pump", "visit_inspection_id": 106 }, { "asset_id": "P2", "site": "north", "kind": "fan", "visit_inspection_id": 105 }, { "asset_id": "P3", "site": "south", "kind": "pump", "visit_inspection_id": null }, { "asset_id": "P4", "site": "south", "kind": "pump", "visit_inspection_id": null } ]6. Summarise each group
[ { "asset_id": "P1", "loose_visits": 2 }, { "asset_id": "P2", "loose_visits": 1 }, { "asset_id": "P3", "loose_visits": 0 }, { "asset_id": "P4", "loose_visits": 0 } ]7. Order rows by named fields
[ { "asset_id": "P1", "loose_visits": 2 }, { "asset_id": "P2", "loose_visits": 1 }, { "asset_id": "P3", "loose_visits": 0 }, { "asset_id": "P4", "loose_visits": 0 } ]8. Result
[ { "asset_id": "P1", "loose_visits": 2 }, { "asset_id": "P2", "loose_visits": 1 }, { "asset_id": "P3", "loose_visits": 0 }, { "asset_id": "P4", "loose_visits": 0 } ]
The workshop calculates over small record lists to make the relational operations visible. It is not a SQL parser or query planner. The worksheet runs SQLite and is the authority for SQL behaviour, including types and NULL.
1. Put the population to preserve on the left
The report must contain every asset, so assets belongs on the left of LEFT JOIN. Reversing the tables preserves visits instead and answers a different question.
A matched asset can still produce several rows when it has several visits. The unmatched asset produces one row with missing right-side fields. Preserving all left records does not mean producing exactly one result row per left record.
Retention and multiplicity are separate properties of a join.
2. Inspect the unmatched row before aggregating
Look at the raw left-join output. P4 is present, but its inspection_id is NULL because there was no matched visit. That NULL came from the join operation, not from a stored inspection with a missing primary key.
A non-null key on the right table is useful evidence: if that key is NULL in the outer result, the row has no right partner. Testing a nullable measurement instead would confuse an actual visit with an unknown duration and an absent visit.
Use a right-side identity field to distinguish absent records from missing measurements.
Keep every asset while exposing visit matches
This raw result lets us see why a later row count needs care.
| asset_id | site | kind | active |
|---|---|---|---|
| P1 | north | pump | 1 |
| P2 | north | fan | 1 |
| P3 | south | pump | 0 |
| P4 | south | pump | 1 |
| inspection_id | asset_id | minutes | finding |
|---|---|---|---|
| 101 | P1 | 30 | loose |
| 102 | P1 | NULL | NULL |
| 103 | P2 | 0 | clear |
| 104 | P3 | 20 | clear |
| 105 | P2 | 10 | loose |
| 106 | P1 | 15 | loose |
| reading_id | asset_id | temperature |
|---|---|---|
| 1 | P1 | 70 |
| 2 | P1 | 80 |
| 3 | P2 | NULL |
| 4 | P3 | 65 |
Database setup for this example
CREATE TABLE assets (
asset_id TEXT PRIMARY KEY NOT NULL,
site TEXT NOT NULL,
kind TEXT NOT NULL,
active INTEGER NOT NULL CHECK (active IN (0, 1))
);
CREATE TABLE inspections (
inspection_id INTEGER PRIMARY KEY,
asset_id TEXT NOT NULL REFERENCES assets(asset_id),
minutes INTEGER CHECK (minutes >= 0),
finding TEXT
);
CREATE TABLE readings (
reading_id INTEGER PRIMARY KEY,
asset_id TEXT NOT NULL REFERENCES assets(asset_id),
temperature REAL
);
INSERT INTO assets VALUES
('P1','north','pump',1), ('P2','north','fan',1),
('P3','south','pump',0), ('P4','south','pump',1);
INSERT INTO inspections VALUES
(101,'P1',30,'loose'), (102,'P1',NULL,NULL),
(103,'P2',0,'clear'), (104,'P3',20,'clear'),
(105,'P2',10,'loose'), (106,'P1',15,'loose');
INSERT INTO readings VALUES
(1,'P1',70.0), (2,'P1',80.0), (3,'P2',NULL), (4,'P3',65.0);SELECT a.asset_id, i.inspection_id
FROM assets AS a LEFT JOIN inspections AS i ON i.asset_id = a.asset_id
ORDER BY a.asset_id, i.inspection_id;| asset_id | inspection_id |
|---|---|
| P1 | 101 |
| P1 | 102 |
| P1 | 106 |
| P2 | 103 |
| P2 | 105 |
| P3 | 104 |
| P4 | NULL |
P4 has one placeholder result row and no actual inspection.
3. Place a qualifying-match condition in ON
The new report counts loose-component visits while retaining all assets. Add finding = loose to the join condition, or filter the visit relation before joining. Then an asset with only clear visits has no qualifying partner but remains on the left.
Putting i.finding = loose in WHERE after the left join removes unmatched rows, because their right-side finding is NULL and the comparison is unknown. That would hide precisely the zero-match assets the report needs.
ON defines what counts as a match; WHERE filters the result rows after unmatched placeholders have been formed.
Check yourself
Questions on Parts 1 to 3
Question 1 of 2
What does COUNT(inspection_id) contribute for that row?
Use the information below.
assets has P4. No inspection matches it. A LEFT JOIN retains P4 with inspection_id NULL.4. Count matched keys rather than placeholder rows
After grouping by the asset identity, COUNT(i.inspection_id) counts actual matched visits. COUNT(*) counts every joined result row, including the one retained for an unmatched asset.
COUNT(i.minutes) asks yet another question: how many matched visits have a known duration? An existing visit with NULL minutes should still count as a visit. Choose the expression according to what the report is counting.
A non-null record identity is a better count target than a measurement that may be absent.
5. Interpret zero without inventing a measurement
A zero count of matched visits is meaningful evidence about this query’s matching condition. It can mean no visits at all, or visits exist but none have the requested finding. It does not establish that the asset is healthy or that nobody performed unrecorded work.
If we also sum minutes, a NULL sum can arise from no matches or all matched durations being unknown. Keep an actual-visit count and a known-duration count when that distinction matters.
The data can establish an absence of recorded evidence under a stated query, not every real-world absence.
6. Use the retained key when labelling the group
Group and display the asset_id from the left register. Grouping by the right inspection asset_id would collect all unmatched assets under a single NULL key. Their identities would disappear even though the join retained them.
This is another reason to qualify column names with aliases: the same spelling can carry different nullability and meaning after an outer join.
Keep the identity of the population you promised to preserve.
7. Check an asset with only nonqualifying visits
A good changed case gives one asset several clear visits and another no visits. Both should receive zero loose findings, while remaining separate asset rows.
That case detects a right-side condition placed after the join and a COUNT(*) shortcut. Retest an asset with actual loose visits to ensure the repair has not reduced every count to zero.
The output must retain the whole register and count only real qualifying partners.
Check yourself
Questions on Parts 1 to 7
Question 1 of 2
Which expression counts the visit?
Use the information below.
A real inspection has a non-null ID but minutes NULL.Try it with Botie
Build with Botie
Write and run SQL