Every Fix Moved the League Table
Some questions should take one line of SQL. Which country exports the most farm produce, and who comes second, is one of them.
The table is ag_exports, from AgriSphere, an invented set of farm production and market files that LucidPlexus uses as practice data. It holds 103 rows of exports by country, commodity and year, 2015 to 2022, with faults planted on purpose so that learners have something to find. The countries are real. Every number is made up, so nothing below is a fact about real trade. That makes it a fair test, though: the faults are the ordinary kind, the ones that arrive in any spreadsheet that has passed through a few inboxes.
ag_exports103 rows · 4 columns · CSV →Agricultural Prices ag_prices101 rows · 3 columns · CSV →Agricultural Technology ag_technology100 rows · 4 columns · CSV →Country Master country_master4 rows · 3 columns · CSV →CREATE TABLE ag_exports AS SELECT * FROM read_csv('ag_exports.csv', header = true); Same files as this audit, faults included.So, the one-line answer:
SELECT Country, SUM(Export_tons) AS tons
FROM ag_exports
GROUP BY Country
ORDER BY tons DESC;
| Country | tons |
|---|---|
| China | 1,505,499 |
| Brazil | 1,295,610 |
| USA | 1,162,577 |
| India | 1,087,455 |
| … plus spellings the master list has never heard of |
Further down the full result come names the master list has never heard of: the USA has entered under more than one name, a tactic that would get you thrown out of most pub quizzes, and so has China. On the face of it, Brazil is second.
Nobody would publish that. But the instinct, to tidy the names and move on, skips the step that makes an audit worth doing. Before fixing anything, count each fault and see which answer it moves. Some faults are cosmetic. Others decide the result.
Count before you fix
Start with exact copies, rows identical in every column. The test is a difference of two counts:
SELECT COUNT(*) - COUNT(DISTINCT (Country, Commodity, Year, Export_tons))
AS exact_copies
FROM ag_exports;
It returns 3: one copy each of a Brazilian soybean row, a Brazilian rice row and an Indian corn row, about 226,000 tonnes counted twice, about 170,000 of them Brazil’s. Then the names, checked against the table that says which countries exist, country_master. An anti-join, a LEFT JOIN that keeps only the rows with no match, finds every spelling the master list doesn’t know:
SELECT e.Country, SUM(e.Export_tons) AS tons
FROM ag_exports AS e
LEFT JOIN country_master AS m ON m.country_name = e.Country
WHERE m.country_name IS NULL
GROUP BY e.Country
ORDER BY tons DESC;
| Country | tons |
|---|---|
| china | 131,621 |
| United States | 116,914 |
| U.S.A. | 95,218 |
Here’s the number that shows why the anti-join comes first. An ordinary inner join to the master list, the obvious way to “validate” the countries, would have dropped every one of those rows, silently: about 344,000 tonnes gone, no error, and Brazil still second. Last, the values that can’t be true:
SELECT Country, Commodity, Year, Export_tons
FROM ag_exports
WHERE Export_tons <= 0 OR Year IS NULL
ORDER BY Export_tons;
| Country | Commodity | Year | Export_tons |
|---|---|---|---|
| China | Soybean | 2019 | -4,500 |
| China | Wheat | 2015 | 0 |
| Brazil | Corn | 2019 | 0 |
| Brazil | Rice | NULL | 84,795 |
| Brazil | Wheat | NULL | 89,233 |
That’s a negative export of 4,500 tonnes, two exports of exactly nothing, and two Brazilian rows with no year. The other tables have their own collection, and FILTER lets one pass count several checks at once. On the prices:
SELECT COUNT(*) - COUNT(DISTINCT (Commodity, Date, Price_per_ton)) AS exact_copies,
COUNT(*) FILTER (WHERE Date IS NULL) AS no_date,
COUNT(*) FILTER (WHERE Price_per_ton > 1000) AS over_1000,
COUNT(DISTINCT Commodity) AS spellings
FROM ag_prices;
It finds one copied row, two prices ten times higher than the rest, some prices with no date, and soybean spelt three ways. (How many have no date is one of the counts the guided project asks you for, so we’ll leave that number there.) Here is every fault in the four tables, found the same way, with the answer each one moves:
| Table | Fault | The answer it moves |
|---|---|---|
| ag_exports | country names spelt other ways | Brazil 2nd, not the USA |
| ag_exports | exact copies | total about 226,000 tonnes too high |
| ag_exports | tonnage of zero or less | total 4,500 tonnes too low |
| ag_exports | no year | Brazil 3rd or 4th |
| ag_prices | price typed ten times over | average corn price 508.11; 316.00 without it |
| ag_prices | exact copy | one price counted twice |
| ag_prices | no date | prices that belong to no year |
| ag_prices | soybean spelt other ways | soybean split into three groups |
| ag_technology | gain typed as a percentage | average gain 70.6%, not 17.1% |
| ag_technology | adopted in 2035 | a farm from the future |
| ag_technology | technology spelt other ways | drones and sensors split in two |
Of those, the technology row deserves a second look. Three farms typed their efficiency gain as a percentage (27, 8 and 19) in a column of fractions (0.27, 0.08, 0.19). Those three cells lift the average gain from 17.1% to 70.6%, and they make irrigation systems look like the second-best technology at 84.2%. Fixed, irrigation is the worst of the four, at 14.5%. One farm’s decimal point was carrying a whole product category.
Fix one thing at a time, and watch
From here, the fixes for ag_exports go in a view, so the raw table stays as it arrived. A small mapping table gives every spelling its standard name, and a LEFT JOIN to it keeps any spelling the mapping has never seen, where a test can catch it:
CREATE TABLE blog_aliases AS
SELECT * FROM (VALUES ('USA', 'USA'), ('U.S.A.', 'USA'),
('United States', 'USA'), ('China', 'China'),
('china', 'China'), ('Brazil', 'Brazil'),
('India', 'India')) AS t(alias, country);
CREATE VIEW blog_clean AS
SELECT DISTINCT
COALESCE(a.country, e.Country) AS country,
e.Commodity AS commodity,
e.Year AS year,
e.Export_tons AS tons
FROM ag_exports AS e
LEFT JOIN blog_aliases AS a ON a.alias = e.Country
WHERE e.Year IS NOT NULL
AND e.Export_tons > 0;
That’s four decisions in one statement. Applied one at a time, each moves the league table:
Step by step: standardising the names adds the USA’s spellings together into about 1.37 million tonnes and puts it second. Removing the copies costs Brazil about 170,000 tonnes but not its third place. Removing the impossible values raises the total, because one of them was negative. And the two rows with no year, about 174,000 tonnes, both Brazilian, decide on their own whether Brazil or India is third. Dropping a row because its year is missing is a judgement, not a fact: the tonnes were presumably shipped, just not in any year anyone wrote down. That judgement comes back later.
After the view, the total is just under 5.0 million tonnes, down from 5,394,894 as delivered, and every fault planted in the table is gone. (The exact cleaned total is the answer to the first task of the Can I Trust This Data? guided project, so we’ll leave it for you to find.) It’s tempting to call the table clean.
The fault nobody planted
One check is left, and it’s the one that matters most: the key. The dataset’s own description says one row is one country’s exports of one commodity in one year. If that’s true, no country, commodity and year turns up on more than one row:
SELECT COUNT(*) AS contested_keys, SUM(n) AS rows_in_them, MAX(n) AS most_rows
FROM (SELECT country, commodity, year, COUNT(*) AS n
FROM blog_clean GROUP BY ALL HAVING COUNT(*) > 1);
| contested_keys | rows_in_them | most_rows |
|---|---|---|
| 25 | 59 | 4 |
Twenty-five of them do, with between two and four rows each, 59 rows in all. DISTINCT can’t touch them, because they aren’t copies; they disagree. The dataset’s notes on LucidPlexus say these shared keys were in the rows as supplied, before a single fault was planted. Someone built the table this way. To see how far apart the rows are, fold each contested key into a list. Here is the USA’s 2016:
SELECT commodity, list(tons ORDER BY tons) AS competing_rows
FROM blog_clean
WHERE country = 'USA' AND year = 2016
GROUP BY commodity
HAVING COUNT(*) > 1
ORDER BY commodity;
| commodity | competing_rows |
|---|---|
| Rice | [4207, 4541, 28664, 84151] |
| Soybean | [24841, 36726, 47301] |
| Wheat | [1372, 98829] |
So what is the USA’s rice export for 2016: 4,207, 4,541, 28,664 or 84,151 tonnes? If the rows are separate shipments, add them up. If they’re competing versions of one figure (a revision, a second source, an estimate keyed twice), one of them is right and nobody has said which. Each reading is a rule, and each rule is a query:
WITH per_key AS (
SELECT country, commodity, year,
SUM(tons) AS every_row,
MAX(tons) AS largest,
MIN(tons) AS smallest
FROM blog_clean
GROUP BY ALL
)
SELECT country,
SUM(every_row) AS every_row,
SUM(largest) AS largest_wins,
SUM(smallest) AS smallest_wins
FROM per_key
GROUP BY country
ORDER BY every_row DESC;
| country | every_row | largest_wins | smallest_wins |
|---|---|---|---|
| China | 1,642k | 1,179k | 874k |
| USA | 1,375k | 1,077k | 635k |
| India | 1,031k | 818k | 691k |
| Brazil | 952k | 828k | 645k |
Read along the USA’s row (tonnes rounded to the nearest thousand). Count every row and it’s second, with about 1.37 million tonnes. Let the largest row of each key win and it’s still second. Let the smallest win and it falls to about 635,000 tonnes, behind India and Brazil.
The USA is the second-biggest exporter in this table.
Or the smallest.
There’s a fourth defensible rule, too: take the mean of a key’s competing rows, on the view that each is a noisy measure of the same thing. Add the other open decision, whether the two rows with no year count, and there are eight ways to finish the cleaning: two choices about the missing years, four about the contested keys (every row, the largest, the smallest, the mean). Each produces a league table.
In every one of them, China is first. Below it, the USA, India and Brazil each take second, third and fourth place somewhere. The planted faults moved the answer, but each had one right fix. This one doesn’t: the whole lower half of the league is a choice the data can’t make.
- How many rows did you expect, and how many arrived?
- What is one row, and is that key really unique? Compare
COUNT(*)withCOUNT(DISTINCT key). - Does every code match the master list? Anti-join first; an inner join hides the misses.
- Which values are impossible, and which are only odd?
- For each fault, which answer does it move, and by how much? That decides which ones need a person, not a query.
Every query here ran in DuckDB 2.0.0 on the practice tables. Counts are the real results; cleaned tonnages are rounded so the guided project keeps its answers.
Who decides what a row is?
Looking back, the spellings, the copies, the minus sign and the decimal point were the easy part. Each had one correct fix, and a query could find it. The shared keys are different in kind. Only someone who knows how the table was made can say whether two rows for US rice in 2016 are two ships or two guesses, and until they do, every chart of this table has quietly chosen a league table on their behalf.
Which is fine, as long as someone can still be asked. But suppose the person who built the table has moved on, and nobody kept a note. Which of the eight league tables do you publish?
References
- Agricultural Exports, Prices, Technology and Country Master (Community datasets) — the four tables this audit used, faults and all; each page links the other three.
- Can I Trust This Data? (Academy course) — seventeen lessons on these four tables, from the first profile to the write-up.
- Can I Trust This Data? (guided project) — the same work as one notebook, with an automatic check for each task.
- Can I Trust This Data? (Library) — the field book behind the course.
- Data Quality Checks in SQL (cheat sheet) — a check for each dimension of quality, run on the same agricultural tables.
- The 10-Minute Data Quality Audit — ten checks for any new table, in DuckDB and pandas.
- Cleaning Text and Categories (cheat sheet) — mapping every spelling to one agreed name, and proving nothing else moved.
- DuckDB 2.0 documentation: GROUP BY clause — GROUP BY ALL, which groups by every column that isn’t aggregated.
- DuckDB 2.0 documentation: Aggregate functions — list() and ORDER BY inside an aggregate.
- DuckDB 2.0 documentation: FILTER clause — counting several faults in one pass with COUNT(*) FILTER (WHERE …).
Comments
No comments yet. Be the first to share your thoughts.