Skip to content

Data quality

Exercise data-quality-audit-annex.pdf · PDF

You ask the games table a simple question: which is the oldest game? You sort by release date and the answer comes back at once. A game from October 2014. Right behind it, one from April 2004.

2014 before 2004. The query did not fail. There was no red message. The table just handed you a wrong answer with a straight face. That is what dirty data does: it does not crash, it lies quietly. Before you clean anything, you need to know exactly where the dirt is. This page teaches you to look.

What you will learn

  • Name the four kinds of dirty data: missing values, duplicates, wrong types or formats, and outliers
  • Count the empty cells of any column with COUNT(*) - COUNT(column)
  • Spot a number or a date that is really stored as text, and say what breaks because of it
  • Tell an empty cell that means "we do not know" from one that means something
  • Write a dirt inventory: column, kind of dirt, evidence, decision
  • Avoid the trap: why "same name" is not a duplicate and "very big" is not an error

Open the chest before you smelt

Data quality is how far you can trust a table to answer your question: its values are present, correct, written in one consistent way, and not repeated.

In the crafting game we use all year, Unit 1 was farming and looting: you brought the materials home. Unit 2 is the furnace: preparing them. But nobody smelts blind. First you open the loot chest and look: empty slots, the same item twice, broken items, one sword with absurd stats.

A data audit is that look. You inspect a table, count what is wrong with it and write it down, without changing anything.

Our chest is games: 26,688 Steam games, one per row, collected from the statistics site Steam Spy and left dirty on purpose. You can download the file from the Resources page.

flowchart LR
    A["<b>Audit</b><br>open the chest, count the dirt"] --> B["<b>Decide</b><br>fix, flag or leave"]
    B --> C["<b>Clean</b><br>the furnace: the next lessons"]
    C --> D["<b>Report</b><br>say what you did and what is left"]

Careful: today nobody fixes anything

The temptation is to start repairing the first problem you see. Resist it. A fix made before the audit is a guess, and you will not know what else you broke. The only product of an audit is a list.

Four kinds of dirt

Almost everything that can be wrong with a table falls into four groups. Each one has its own way of being found.

Kind In the chest In a table How to find it
Missing value An empty slot A NULL: no value at all COUNT(*) - COUNT(column)
Duplicate The same item twice The same real thing in two rows GROUP BY + HAVING COUNT(*) > 1
Wrong type or format A sword stored as a picture of a sword A date or a number stored as text; one thing spelled two ways Look at the column type; sort it; LIKE
Outlier An item with 9,999 damage A value far from all the others. Maybe an error, maybe real MIN, MAX, ORDER BY ... DESC

The next four sections take them one at a time, on games. Every number below comes from running the query on the class table.

Missing values: the empty slots

A missing value is a cell with nothing in it. In SQL it is written NULL, which means "no value", not zero and not an empty word.

You already know the tool from Unit 1. COUNT(*) counts rows. COUNT(column) counts only the rows where that column is not empty. The difference is the number of empty slots.

SELECT COUNT(*) AS total,
       COUNT(price) AS with_price,
       COUNT(*) - COUNT(price) AS no_price
FROM games;
total with_price no_price
26688 23593 3095

Do the same for every column, in one single SELECT, and you get the full picture:

Column Empty cells Share of the rows
metascore 23,838 89.3%
price 3,095 11.6%
developer 152 0.6%
publisher 120 0.4%
median_playtime 12 under 0.1%
average_playtime 9 under 0.1%
game 3 0.01%
number, release_date, owners 0 0%

Always write the percentage next to the count. "23,838 empty scores" sounds like a lot. "89.3% of the column is empty" tells you the column is almost not there.

Careful: an empty cell is not always an error

Dota 2 has no price. Is that a mistake? No: Dota 2 is free to play. That NULL carries information. Now look at metascore, the review score from the site Metacritic: 23,838 games have none, simply because no critic ever reviewed them. That NULL means "unknown".

Same empty slot, two different stories, and later two different treatments. An auditor writes down which story it is.

Missing values in disguise

Some empty slots do not look empty. Try these two:

SELECT COUNT(*) FROM games WHERE average_playtime = 0;   -- 25757
SELECT COUNT(*) FROM games WHERE price = 0;              -- 0

average_playtime is 0 in 25,757 rows: 96.5% of the table. Nobody believes that 96.5% of Steam games were played for zero minutes. That zero means "we have no data". It is a missing value wearing a costume.

And price = 0 finds nothing. Not one game costs exactly zero. So where are the free games? They are the 3,095 NULL ones.

What you see What it is In games
NULL No value at all price IS NULL finds 3,095 rows
0 A value: the number zero price = 0 finds 0 rows
0 used as "no data" A disguised missing value average_playtime = 0 in 25,757 rows

Wrong types and formats: text in disguise

A data type is the kind of value a column is allowed to hold: whole number, decimal number, text, date. The type decides what you can do with the column.

Ask the database what each column is made of:

SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'games'
ORDER BY ordinal_position;
column_name data_type
number bigint
game text
release_date text
price double precision
owners text
developer text
publisher text
average_playtime double precision
median_playtime double precision
metascore double precision

Two columns are text and should not be: release_date (a date) and owners (a quantity). This is what broke the query at the top of the page:

SELECT game, release_date FROM games ORDER BY release_date LIMIT 3;
game release_date
Hazard Ops 8 Oct, 2014
The Dark Legions Apr 1, 2004
Geneforge 3 Apr 1, 2005

Text sorts like a dictionary, character by character. 8 comes before any letter, and Apr comes before Feb because A comes before F. A date stored as text cannot be sorted, filtered by year or put on a time axis.

It gets worse. A format is the way a value is written down. Most dates here look like Nov 16, 2004. Are they all like that?

SELECT release_date, COUNT(*) AS n
FROM games
WHERE release_date NOT LIKE '___ %, ____'   -- 3 letters, space, day, comma, 4 digits
GROUP BY release_date
ORDER BY n DESC;

42 rows have another shape: 41 give only the month and the year (Oct 2010 three times, Jun 2009 twice...) and one is written the other way round, 8 Oct, 2014. One column, three ways of writing a date.

owners has the same disease. It holds only 13 different values, and all of them are ranges written as text, such as 10,000,000 .. 20,000,000. The most common is 0 .. 20,000, with 17,923 games. Sort it to find the most owned game and you get nonsense:

SELECT game, owners FROM games ORDER BY owners DESC LIMIT 3;
game owners
DOOM 3 500,000 .. 1,000,000
Unreal Tournament 2004: Editor's Choice Edition 500,000 .. 1,000,000
Beyond Divinity 500,000 .. 1,000,000

Dota 2, with 100,000,000 .. 200,000,000, loses. As text, 5 beats 1, so "500 thousand" beats "100 million".

Duplicates: the same item twice

A duplicate is the same real thing recorded in more than one row.

Is any game in the table twice? Group by name and keep the names that appear more than once:

SELECT game, COUNT(*) AS n
FROM games
WHERE game IS NOT NULL
GROUP BY game
HAVING COUNT(*) > 1
ORDER BY n DESC, game;

74 names come back, covering 149 rows. At the top, Dark Matter, three times. Before you call it a duplicate, look at it:

SELECT number, game, release_date, developer
FROM games WHERE game = 'Dark Matter';
number game release_date developer
317 Dark Matter Oct 17, 2013 InterWave Studios
777 Dark Matter Feb 27, 2015 Meridian4
770 Dark Matter May 4, 2018 Barty Games

Three developers, three dates. These are three different games that happen to share a name. Delete two of them and you have destroyed real data.

Careful: number is not an identifier

It is tempting to think the column number tells rows apart. Check it: SELECT COUNT(*), COUNT(DISTINCT number) FROM games; returns 26,688 rows but only 8,846 different numbers. The same number is reused by many games. An audit also tells you what you cannot rely on.

Outliers: the item with absurd stats

An outlier is a value far away from all the others. It may be a typing error. It may also be perfectly real.

Prices are in US dollars. Look at the edges of the column:

SELECT MIN(price), MAX(price), ROUND(AVG(price)::numeric, 2) AS mean FROM games;
min max mean
0.49 595.99 8.95

The average game costs 8.95 and the most expensive one costs 595.99. More than sixty times the average. A typo? Look before you decide:

SELECT game, price, developer
FROM games ORDER BY price DESC NULLS LAST LIMIT 4;
game price developer
ADR-Labelling Game 595.99 Suomen Kuljetusturva Oy
GameMaker Studio 2 UWP 399.99 YoYo Games Ltd.
VEGAS Pro 16 Edit Steam Edition 399 MAGIX Software GmbH
Marvelous Designer 8 for Steam 320 CLO Virtual Fashion

A game-making tool, a video editor, a 3D fashion design program. Steam also sells professional software, and software is expensive. 27 rows cost more than 100 dollars, and they are mostly products like these, not typing mistakes.

Careful: an outlier is not a mistake

Delete only what you can prove is wrong. 595.99 is a real price of a real product. An outlier is a question ("why is this one so different?"), and the answer is found by looking at the row, never by assuming.

The dirt inventory

A dirt inventory is the written result of an audit: one line per problem, each with its evidence and a decision.

Every line has four parts. The rule for the third one is strict: no number, no evidence. "There are NULLs" is not an audit. "3,095 NULL in price, 11.6% of the rows" is.

For the decision you have three verbs:

Decision Meaning When
Fix Repair the value You know what the right value is
Flag Leave it, and mark it so nobody forgets You do not know the value and will not invent one
Leave Keep it as it is and write it down as a limit The value is real, or nothing can be done

This is the inventory of games. It is also the map of the rest of the unit:

Column What we found Decision Lesson
game 3 rows without a name Drop the rows: a game with no name cannot be identified Types and formats
release_date Text, in three shapes (42 rows differ) Fix: convert to a real date Types and formats
owners Text. A range, not a number. 13 values Fix: extract the lower bound as a number Units and duplicates
game 74 names appear more than once Investigate: same game, or same name? Units and duplicates
price Up to 595.99. Real: software sold on Steam Leave the values; group them into bands Categorisation
price 3,095 NULL (11.6%): free to play Fix with what we know: free means 0 Imputation
metascore 23,838 NULL (89.3%) Flag, never invent a score Imputation
playtime 0 in 25,757 rows (96.5%) Leave and write it down as a limit The descriptive report

Why should I care?

Imagine your boss asks for "the average score of Steam games". You can compute it in one line. But the inventory tells you the average is built on 11% of the table, and not a random 11%. Cleaning is not making the table pretty. It is knowing exactly what your numbers can and cannot say.

Try it: audit games yourself

Open SQL → SQL Lab, choose the database Dades classe and the schema public. Run one statement at a time: select it, then press Run. If you run a whole block, SQL Lab only shows the last result.

Step 1 — Size and types. Run SELECT COUNT(*) FROM games; and then the information_schema.columns query from this page. You should see 26,688 rows and two text columns that should not be text.

Step 2 — Missing values. Write one SELECT with a COUNT(*) - COUNT(column) for each of the ten columns. Your numbers should match the table in "Missing values". Then run the two disguise checks (average_playtime = 0 and price = 0).

Step 3 — Wrong formats. Run the NOT LIKE query and the two owners queries. You should be able to say how many dates have a different shape (42) and why DOOM 3 is not really the most owned game.

Step 4 — Duplicates. Run the HAVING COUNT(*) > 1 query, then look at the Dark Matter rows. Write one sentence: are they the same game?

Step 5 — Outliers. Run the MIN/MAX query and the top prices. Decide, with a reason, whether 595.99 is a typing error.

SQL Lab only shows 1,000 rows. Is my table broken?

No. SQL Lab limits what it displays, not what the table holds. COUNT(*) always counts every row. If a different total comes back from COUNT(*), the file was probably uploaded twice: a real duplicate problem, and a nice one to write in your inventory.

Key takeaways

  • A data audit means looking and counting without changing anything. Its only product is a list.
  • There are four kinds of dirt: missing values, duplicates, wrong types or formats, and outliers.
  • COUNT(*) - COUNT(column) gives the empty cells of a column. Always add the percentage.
  • NULL and 0 are different things. A NULL can mean "unknown" (metascore) or carry meaning (a free game), and a 0 can be a missing value in disguise (average_playtime).
  • A date or number stored as text sorts like a dictionary and cannot be calculated with.
  • Same name is not a duplicate, and an outlier is not an error: look at the row before you decide.
  • A dirt inventory has four parts per line: column, kind of dirt, evidence with a number, and a decision (fix, flag or leave).

Quick check

1. Which query counts the empty cells of the column price?
  • SELECT COUNT(price) FROM games
  • SELECT COUNT(*) - COUNT(price) FROM games
  • SELECT COUNT(*) FROM games WHERE price = 0
  • SELECT MIN(price) FROM games

COUNT(*) counts every row and COUNT(price) skips the rows where price is NULL, so the difference is the number of empty cells: 3,095. Searching for price = 0 finds nothing, because zero is a value and NULL is the absence of one.

2. Sorting games by release_date puts Apr 1, 2004 before any date in February. Why?
  • Because April has fewer days than February in that year
  • Because the table was uploaded in the wrong order
  • Because the column is stored as text, and text sorts alphabetically
  • Because ORDER BY does not work on dates

release_date has the data type text, so the database compares it letter by letter: A comes before F. A real date column would sort by time. The type decides what you can do with a column, not how it looks.

3. Dota 2 has a NULL price and most games have a NULL metascore. What is the difference?
  • None: both are errors that must be deleted
  • The price is an outlier and the metascore is a duplicate
  • The empty price means the game is free; the empty metascore means nobody reviewed the game
  • The empty price is unknown; the empty metascore means the score is zero

The same empty cell can tell two stories. The price NULL carries information (free to play), so it can later be fixed with what we know. The metascore NULL means unknown, so it gets flagged, never invented.

4. Dark Matter appears three times in games, with three developers and three release dates. What should the audit say?
  • It is a duplicate: keep the first row and delete the other two
  • They are three different games that share a name: keep all three
  • It is an outlier, because no other name appears three times
  • The column number proves they are the same game

A duplicate is the same real thing in more than one row. Different developer and different date mean a different game. And number cannot help: it has only 8,846 different values for 26,688 rows, so it is not an identifier.

5. The most expensive row in games costs 595.99, and the mean price is 8.95. What is the right reaction?
  • Delete the row: it is obviously a typing error
  • Replace it with the mean price
  • Look at the row first: it is real software sold on Steam, so it stays
  • Ignore it: outliers never affect anything

An outlier is a question, not a verdict. The top prices belong to professional tools (a game engine, a video editor), which really do cost that much. You delete only what you can prove is wrong.

6. Which of these is a valid line of evidence in a dirt inventory?
  • "The price column has some NULLs"
  • "The dates look strange"
  • "COUNT(*) - COUNT(metascore) returns 23,838: 89.3% of the rows have no score"
  • "I fixed the owners column"

No number, no evidence. A valid line names the query and the figure it returned, with a percentage when it helps. And the last option breaks the first rule of an audit: nothing gets fixed yet.

7. You receive a table of a gym's members. The column visits_last_month is 0 for 95% of the members, and the gym is full every evening. Pick the two statements an auditor would write.
  • The zeros are probably missing values in disguise: "no data", not "zero visits"
  • The zeros are outliers and must be deleted
  • The finding goes in the inventory with its count and percentage before anything is changed
  • The column is clean, because it contains no NULL

This is the average_playtime case again: a 0 that means "nobody measured it". A column with no NULL can still be almost empty. The auditor writes the evidence down first and decides later whether to fix, flag or leave.

Now you know what is dirty, and you have a plan. Time to light the furnace: next, Cleaning: types and formats, where release_date finally becomes a real date.