Cleaning: units and duplicates¶
Exercise cleaning-units-duplicates-annex.pdf · PDF
In a crafting game, two items that are the same stack in one slot. Now and then two items look identical and still refuse to stack. Same icon, same name, two slots. Something you cannot see makes them different.
Data does this all the time. A space that is not a space. Three games called exactly the same that are not the same game. A number that looks like "how many people own this" and is not. On this page you learn to find the hidden difference before you merge, split or delete anything.
What you will learn
- Find out which character really sits inside a text with
ASCII, and explain what a non-breaking space is - Extract a number from a text range with
SPLIT_PARTandREGEXP_REPLACE, and cast it - Say what unit a column has, and why a number without its unit is a new kind of dirt
- Tell a true duplicate (the same row twice) from an apparent duplicate (the same name, another game)
- Remove true duplicates with
DISTINCTorROW_NUMBER - Avoid the trap: why "one row per game name" silently deletes 77 real rows
Looks identical, will not stack¶
The dirt inventory left two lines for today: owners is text and holds a range, and 74 game names appear more than once. Both hide the same kind of trap, so we keep one analogy for the whole page: two items that look the same and will not stack.
This lesson continues normalising: one type, one format and one unit per column, and one row per real thing.
flowchart LR
subgraph A ["<b>Part 1</b>: the owners column"]
direction TB
A1["'10,000,000 .. 20,000,000'<br>text, a range"] --> A2["<b>owners_min</b><br>10000000, a number"]
end
subgraph B ["<b>Part 2</b>: repeated names"]
direction TB
B1["74 names appear<br>more than once"] --> B2["True duplicate,<br>apparent duplicate<br>or grey zone?"]
end
A --> B
The space that is not a space¶
owners reads 10,000,000 .. 20,000,000. We want the first number. The tool is SPLIT_PART(text, separator, n): it cuts a text at a separator and returns piece number n. The separator looks obvious: space, two dots, space.
SELECT owners, SPLIT_PART(owners, ' .. ', 1) AS first_part
FROM games WHERE game = 'Half-Life 2';
| owners | first_part |
|---|---|
| 10,000,000 .. 20,000,000 | 10,000,000 .. 20,000,000 |
Nothing was cut. first_part is the whole text again. Check how many rows contain "space, two dots, space" at all:
SELECT COUNT(*) FROM games WHERE owners LIKE '% .. %'; -- 0
Zero. Not one row. You can see the spaces on your screen, and the database says they are not there.
Take the fingerprint¶
A character code is the number a computer stores for each character. The letter, digit or space you see is only how that number is drawn on screen.
ASCII(character) shows the code. Take the character that sits just before the dots, and compare it with a space typed on your keyboard:
SELECT ASCII(SUBSTR(owners, POSITION('..' IN owners) - 1, 1)) AS mystery,
ASCII(' ') AS normal_space
FROM games WHERE game = 'Half-Life 2';
| mystery | normal_space |
|---|---|
| 160 | 32 |
Your keyboard types character 32. The file holds character 160.
A non-breaking space is a character that looks exactly like a space but tells a program "do not break the line here". Its code is 160 (in the Unicode standard,
U+00A0; in a web page it is written ).
This table was collected from a website, Steam Spy, and the page's decoration came along with the data. Same look, different item: they do not stack, so the separator never matched.
| Ordinary space | Non-breaking space | |
|---|---|---|
| Code | 32 | 160 |
| On screen | A gap | The same gap |
| Typed with | The space bar | Usually copied from a web page or a document |
Removed by TRIM |
Yes | No |
Matches ' ' in a query |
Yes | No |
A hidden character is any character you cannot tell apart by eye: a non-breaking space, a tab, a line break stuck at the end of a value.
Careful: TRIM does not remove all spaces
TRIM only removes the ordinary space, code 32, and only at the two ends. Code 160 survives it untouched. When two values look equal and the database insists they are different, stop staring at the screen and ask for the codes.
From a text range to a number¶
Now build the number, step by step. Each failure teaches one thing.
Cut at what you can trust. The two dots are ordinary characters. Cut there, and measure the piece:
SELECT SPLIT_PART(owners, '..', 1) AS part,
LENGTH(SPLIT_PART(owners, '..', 1)) AS len,
LENGTH('10,000,000') AS expected
FROM games WHERE game = 'Half-Life 2';
| part | len | expected |
|---|---|---|
| 10,000,000 | 11 | 10 |
Eleven characters where you count ten. The extra one is the non-breaking space, still glued to the end.
Fail on purpose. Use the tools of the previous lesson: TRIM the space, REPLACE the commas, cast.
SELECT REPLACE(TRIM(SPLIT_PART(owners, '..', 1)), ',', '')::bigint FROM games;
ERROR: invalid input syntax for type bigint: "10000000 "
Look inside the quotes, after the digits: there is still a gap. TRIM did not remove it. A loud error, luckily.
Keep only the digits. REGEXP_REPLACE(text, pattern, new, 'g') replaces every piece of the text that matches a pattern. The pattern [^0-9] means "anything that is not a digit": commas, spaces of any kind, and whatever strange character turns up next year.
SELECT REGEXP_REPLACE(SPLIT_PART(owners, '..', 1), '[^0-9]', '', 'g')::bigint AS owners_min,
COUNT(*) AS n
FROM games GROUP BY owners_min ORDER BY owners_min;
| owners_min | n |
|---|---|
| 0 | 17923 |
| 20000 | 3126 |
| 50000 | 1733 |
| 100000 | 1408 |
| 200000 | 1329 |
| 500000 | 546 |
| 1000000 | 325 |
| 2000000 | 211 |
| 5000000 | 50 |
| 10000000 | 30 |
| 20000000 | 3 |
| 50000000 | 3 |
| 100000000 | 1 |
Thirteen values, sorted as numbers, from 0 to 100,000,000. And the ranking that was nonsense in the audit now makes sense: sorted by owners_min from high to low, Dota 2 is on top with 100,000,000, followed by three games at 50,000,000 (Counter-Strike: Global Offensive, Team Fortress 2 and PLAYERUNKNOWN'S BATTLEGROUNDS).
| Piece of the expression | What it does |
|---|---|
SPLIT_PART(owners, '..', 1) |
Keeps what is before the two dots |
REGEXP_REPLACE(..., '[^0-9]', '', 'g') |
Deletes everything that is not a digit |
'g' |
"Global": every match, not only the first one |
::bigint |
Casts the digits to a big whole number |
Careful: do not forget the 'g'
Without 'g', only the first non-digit is removed. The cast then fails with invalid input syntax for type bigint: "10000,000 ": one comma gone, the other one and the hidden space still there.
A number needs its unit¶
A unit is what a number counts or measures: dollars, minutes, people, kilometres. A number without its unit can be read in several ways, and at least one of them is wrong.
So what is the unit of owners_min? It is tempting to say "people who own the game". It is not.
| You might read | What it really is |
|---|---|
| "10,000,000 people own Half-Life 2" | "At least 10,000,000, according to an estimate" |
| "0 people own this game" | "Somewhere between 0 and 20,000" |
| An exact count | The lower bound of an estimated range |
17,923 games have owners_min = 0. That does not mean nobody bought them. It means "between 0 and 20,000". We named the column owners_min, and not owners, for exactly that reason: the name carries the unit.
The same applies to the rest of the table. price is in US dollars, not euros. Nothing in the column says so: you only know because the description of the dataset says it. Write it down.
A unit mistake that cost a spacecraft
On 23 September 1999, NASA lost contact with the Mars Climate Orbiter as it arrived at Mars. The investigation found the cause in a file of numbers. Software from the builder, Lockheed Martin, reported the thrusters' impulse in pound-force seconds. The navigation software at NASA's Jet Propulsion Laboratory read those same numbers as newton-seconds, the metric unit. No value was missing and no query failed. The spacecraft, which had cost about 125 million dollars, came in far too low and was destroyed.
Every number was "correct". Only the unit was wrong, and nobody had written it next to the number.
Careful: we could have made this dirt ourselves
Cleaning can create dirt. If we had called the new column owners and put it in a chart titled "Players per game", we would have turned an honest range into a false count. Name the column for what it is, and say it in the report.
True, apparent and grey: three kinds of "duplicate"¶
Now the 74 names that appear more than once. Do we delete the copies? Before you answer, there are three different cases.
A true duplicate is a row identical to another one in every column: the same record twice. An apparent duplicate only shares some columns, like the name, and is really a different thing.
| Case | In games |
What to do |
|---|---|---|
| True duplicate | Every column identical: the same row twice. Typical cause: a file loaded twice | Remove the copies. Nothing is lost |
| Apparent duplicate | Same name, different developer and date: Dark Matter is three different games |
Keep them all. They only look alike |
| Grey zone | Same name and developer, two dates: Penumbra Overture, 2007 and 2009. A re-release? |
A decision. Ours: keep, and write it down |
Of the 74 repeated names, 32 have different developers and 42 have the same developer with different dates. Not one pair shares both the name and the date.
Does games have true duplicates?¶
SELECT DISTINCT * keeps one copy of each fully identical row. Count before and after:
SELECT COUNT(*) FROM games; -- 26688
SELECT COUNT(*) FROM (SELECT DISTINCT * FROM games) AS t; -- 26688
Same number. games has no true duplicates. The 74 repeated names are all apparent duplicates or grey zone.
The accident: a file uploaded twice¶
Who has never pressed Upload twice? You can simulate it without touching the table. UNION ALL glues two results one under the other, copies included:
WITH twice AS (SELECT * FROM games UNION ALL SELECT * FROM games)
SELECT COUNT(*) FROM twice; -- 53376
53,376 rows: every game twice. Now DISTINCT earns its place:
WITH twice AS (SELECT * FROM games UNION ALL SELECT * FROM games)
SELECT COUNT(*) FROM (SELECT DISTINCT * FROM twice) AS t; -- 26688
Back to 26,688. The copies were true duplicates, so removing them lost nothing.
Careful: DISTINCT depends on the columns you select
DISTINCT only merges rows that are identical in every selected column. SELECT DISTINCT * asks "is the whole row repeated?". SELECT DISTINCT game asks "which names exist?" and returns 26,611 rows, merging the three Dark Matter into one. Two very different questions with the same keyword.
Removing duplicates with a key¶
A key is the column, or group of columns, that says "this is the same thing". Two rows with the same key are the same real object.
Sometimes copies are not perfectly identical (one has an extra space, one was updated later), so DISTINCT * does not catch them. Then you choose a key and number the rows inside each group. ROW_NUMBER() does the numbering: 1, 2, 3... restarting in every group.
WITH twice AS (SELECT * FROM games UNION ALL SELECT * FROM games),
numbered AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY game, release_date, developer ORDER BY number) AS rn
FROM twice
)
SELECT rn, COUNT(*) FROM numbered GROUP BY rn ORDER BY rn;
| rn | count |
|---|---|
| 1 | 26688 |
| 2 | 26688 |
PARTITION BY names the key: name, date and developer together. Every game got a row number 1 and a copy with row number 2. Keep WHERE rn = 1 and the copies are gone.
flowchart TB
Q{"Is the whole row<br>identical to another?"}
Q -- "Yes" --> T["<b>True duplicate</b><br>remove the copy:<br>DISTINCT or rn = 1"]
Q -- "No" --> K{"Do the key columns<br>all match?"}
K -- "No: other developer, other date" --> A["<b>Apparent duplicate</b><br>a different thing: keep it"]
K -- "Some do, some do not" --> G["<b>Grey zone</b><br>decide, and write it down"]
The careless key¶
Now the mistake everybody is tempted to make: "a game is its name". Use PARTITION BY game alone, on the original table:
WITH numbered AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY game ORDER BY number) AS rn
FROM games
)
SELECT rn, COUNT(*) FROM numbered GROUP BY rn ORDER BY rn;
| rn | count |
|---|---|
| 1 | 26611 |
| 2 | 75 |
| 3 | 2 |
Keeping only rn = 1 leaves 26,611 rows. That "clean-up" would delete 77 rows, and none of them is a copy. Two of the three Dark Matter games are among them, and so is the second Penumbra Overture. No error. No warning. The table just gets smaller, and it looks tidier than before.
| Key | Rows kept | Rows lost | Verdict |
|---|---|---|---|
The whole row (DISTINCT *) |
26,688 | 0 | Correct: there were no true duplicates |
game, release_date, developer |
26,688 | 0 | Correct: this combination is unique in games |
game alone |
26,611 | 77 | Wrong: deletes real games that share a name |
How do I know my key is right?
Prove it. Group by the key and ask for groups with more than one row: GROUP BY game, release_date, developer HAVING COUNT(*) > 1. On games it returns nothing, so the key identifies each row. If it returns rows, look at them before deciding anything.
Try it: owners_min and the duplicate hunt¶
Open SQL → SQL Lab, database Dades classe, schema public. Run one statement at a time: select it, then Run.
Step 1 — Find the culprit. Run the SPLIT_PART with ' .. ', the LIKE count and the ASCII query. You should get the whole text back, then 0, then 160 and 32.
Step 2 — Build owners_min. Run the measuring query (11 against 10), the query that fails on purpose, and the REGEXP_REPLACE one. You should see 13 rows sorted as numbers.
Step 3 — Add it to the clean query. Open games_step1 under SQL → Saved Queries and click its name. In the WITH block, replace the plain owners by the REGEXP_REPLACE expression AS owners_min, and rename the block step2. Check it with SELECT game, owners_min FROM step2 ORDER BY owners_min DESC LIMIT 4;. Dota 2 must be on top.
Step 4 — Save it under a new name. Press Save, change the name to games_step2 and choose Save as new. Choosing Update would overwrite games_step1.
Step 5 — Hunt duplicates. Run the two DISTINCT * counts, the twice queries, and finally the careless key. You should be able to say how many real rows PARTITION BY game would delete, and name one of them.
The separator ' .. ' matched for me. Why?
You probably copied the value of owners from the result grid and pasted it into your query. The non-breaking spaces came along in the paste, so your separator contains code 160 too. It works, but nobody reading your query can see why. Cutting at '..' is the version you can trust.
Key takeaways¶
- Every character is stored as a character code. A non-breaking space (code 160) looks like a space (code 32) and is a different character:
TRIMand' 'do not touch it. SPLIT_PARTcuts a text at a separator;REGEXP_REPLACE(..., '[^0-9]', '', 'g')keeps only the digits; then you cast.- A number needs its unit.
owners_minis the lower bound of an estimate, not a count:0means "between 0 and 20,000". - A true duplicate is the whole row repeated. An apparent duplicate only shares a name. In between there is a grey zone that needs a written decision.
gameshas no true duplicates:DISTINCT *returns the same 26,688 rows.- A key says what makes two rows the same thing. With
ROW_NUMBER() OVER (PARTITION BY key), the copies are the rows withrnabove 1. - A careless key deletes real data without any error: one row per game name would lose 77 rows.
Quick check¶
1. SPLIT_PART(owners, ' .. ', 1) returns the whole text, uncut. Why?
- Because
SPLIT_PARTdoes not work on text columns - Because the two dots are special characters in SQL
- Because the spaces around the dots are non-breaking spaces, code 160, not the ordinary space you typed
- Because the column is empty for that game
Your keyboard types the space with character code 32. The file holds code 160, the non-breaking space, which came from the web page the data was collected from. They look the same and are different characters, so the separator never matches.
2. Which statement about TRIM is true?
- It removes every space in a text
- It removes ordinary spaces at the two ends only
- It removes any character that is not a digit
- It removes non-breaking spaces but keeps ordinary ones
TRIM cuts the ordinary space, code 32, at the start and end of a text. It leaves spaces in the middle alone, and it does not recognise code 160. To drop everything that is not a digit you need REGEXP_REPLACE with the pattern [^0-9].
3. What does the 'g' do in REGEXP_REPLACE(part, '[^0-9]', '', 'g')?
- It makes the result a number
- It ignores capital letters
- It replaces every match, not only the first one
- It groups the result like
GROUP BY
'g' means global. Without it only the first non-digit is removed, so 10,000,000 becomes 10000,000 and the cast to bigint fails with an error.
4. A game has owners_min = 0. What does that tell you?
- Nobody owns the game
- The value is missing
- The game has somewhere between 0 and 20,000 owners, according to an estimate
- The game is free to play
owners_min is the lower bound of a range, and the range is an estimate. Its unit is not "people who own the game". Reading it as an exact count would be dirt that we created ourselves.
5. SELECT COUNT(*) FROM games and SELECT COUNT(*) FROM (SELECT DISTINCT * FROM games) AS t both return 26,688. What does that prove?
- That no full row is repeated: the table has no true duplicates
- That no game name is repeated
- That the column
numberis a key - That
DISTINCTdoes not work on big tables
DISTINCT * merges only rows identical in every column. Equal counts mean there is no true duplicate. It says nothing about names: 74 names are still shared by different games.
6. Two rows are called Penumbra Overture, both by Frictional Games, one dated 2007 and one 2009. What is this?
- A true duplicate: delete one of them
- An apparent duplicate: clearly two unrelated games
- A grey zone: it needs a decision, and the decision must be written down
- An outlier
Same name and same developer, but two dates: maybe a re-release, and the data does not say. It is not a true duplicate (the rows differ) and not obviously a different game. In the class table the decision is to keep both and record it.
7. A colleague wants to remove duplicates from a table of students by keeping one row per first name. Pick the two things you would tell them.
- First check whether any full row is repeated, with
DISTINCT * - One row per first name is safe as long as the query gives no error
- A first name is not a key: different people share it, so real students would be deleted without any warning
- Use
TRIMon the names and the duplicates will disappear
This is the careless key again. On games, one row per name deletes 77 real rows and no error appears. A key must identify one real thing; prove it with a GROUP BY ... HAVING COUNT(*) > 1 that returns nothing.
Every column now has one type, one format and a unit you can name. But 199 different prices and 13 owner values are still hard to read at a glance. Next: Categorisation, where numbers become labels like Common, Rare and Epic.