Skip to content

Cleaning: types and formats

Exercise cleaning-types-formats-annex.pdf · PDF

Quick question: which is bigger, 10 or 9? You did not even have to think. Now ask a database the same thing, with one small change:

SELECT '10' < '9' AS as_text, 10 < 9 AS as_number;
as_text as_number
true false

With quotes, 10 is smaller than 9. Without quotes, it is not. Same digits, opposite answers. The quotes turn a number into text, and text is compared like words in a dictionary: 1 comes before 9, end of story. This is exactly why games could not sort its own release dates. On this page you fix it.

What you will learn

  • Explain what goes wrong when a number or a date is stored as text: sorting, comparing, arithmetic
  • Convert a value to another type with CAST(x AS type) or x::type, and read the error when it cannot be done
  • Remove stray spaces with TRIM and repair decimal and thousands separators with REPLACE
  • Turn text dates written in two formats into one real DATE column with TO_DATE
  • Tell a loud error from a silent error, and say which one is worse
  • Avoid the trap: "the query ran without an error" does not mean the conversion is right

The furnace: from ore to ingots

In the data quality audit you opened the chest and only looked. Now you light the furnace. In a crafting game, ore looks like metal, but you cannot craft with it until it is smelted into an ingot. '19.99' looks like a number. It is not one yet.

A data type is the kind of value a column holds. It decides which operations make sense: you can add two numbers, you can subtract two dates, you can only glue two texts together.

Type in PostgreSQL Holds Example You can
text Any characters 'Nov 16, 2004' Search inside it, glue it, sort it alphabetically
integer, bigint Whole numbers 42 Add, average, sort by size
numeric(8,2) Exact decimals 19.99 Do money arithmetic without rounding surprises
date A day in the calendar 2004-11-16 Sort by time, extract the year, count days between two

Normalising a column means giving it one type and one format, so that every value is written in the same way.

We keep the furnace analogy for the whole page: text is the ore, typed values are the ingots.

Casting: lighting the furnace

Casting is converting a value from one type to another. The value either comes out in the new type, or the query stops with an error.

PostgreSQL has two spellings for it. They do exactly the same thing:

SELECT '19.99'::numeric + 1, CAST('42' AS integer) * 2;
First column Second column
20.99 84
Spelling Reads as Note
CAST(x AS type) "smelt x into this type" Standard SQL, works in every database
x::type The same, shorter PostgreSQL shorthand

When the furnace cannot swallow a value, it says so:

SELECT '1,234.50'::numeric;
ERROR:  invalid input syntax for type numeric: "1,234.50"

That comma is the problem. Hold on to this error: we will come back to whether it is good news or bad news.

Careful: CAST does not change the table

A cast changes the result of this query. The table games stays exactly as dirty as it was. That is why you will save your cleaning query at the end of the page: the query is the repair, and the clean table gets built from it later.

Trimming: spaces you cannot see

Trimming is cutting the spaces off both ends of a text.

A space is a character like any other. For you, ' Valve ' and 'Valve' are the same publisher. For a database they are two different ones:

SELECT ' Valve ' = 'Valve' AS as_typed,
       TRIM(' Valve ') = 'Valve' AS trimmed;
as_typed trimmed
false true

One stray space in a file and your GROUP BY publisher shows the same company twice, with the games split between the two rows. TRIM(x) removes the spaces at the start and at the end. It does not touch the ones in the middle, so 'Half-Life 2' keeps its space.

In games, TRIM(game) changes 0 rows: the names are already tidy. We keep it in the clean query anyway. It costs nothing and it protects you the day somebody uploads a file that is not so tidy.

Separators: decoration for humans

A decimal separator marks where the whole part of a number ends. A thousands separator only groups the digits so that people can read them.

Here is the catch: countries disagree on which symbol does which job.

Written as Where Thousands separator Decimal separator
1,234.50 English-speaking countries comma point
1.234,50 Spain and most of Europe point comma
1234.50 What the furnace accepts none point

Both lines are the same number: one thousand two hundred and thirty-four and a half. The furnace only takes the third form. So you remove the decoration first, with REPLACE(text, old, new), which swaps every old for new:

SELECT REPLACE('1,234.50', ',', '')::numeric;                      -- 1234.50
SELECT REPLACE(REPLACE('1.234,50', '.', ''), ',', '.')::numeric;   -- 1234.50

The Spanish one needs two steps, and the order matters: first delete the points (thousands), then turn the comma into a point (decimals). Do it the other way round and you delete the decimal point you just created.

Careful: is that a decimal or a thousand?

1.234 is "one point two three four" in London and "one thousand two hundred and thirty-four" in Palma. The value itself cannot tell you. You have to know where the file comes from before you choose which REPLACE to write.

Dates: one value, many spellings

Dates are the worst offenders, because every country and every website writes them differently. TO_DATE(text, pattern) reads a date written the way the pattern says:

Piece of the pattern Means Matches
Mon Month as three letters Nov
DD Day of the month 16
MM Month as a number 11
YYYY Year with four digits 2004

Most dates in games look like Nov 16, 2004, so the pattern is 'Mon DD, YYYY'. Try the lazy way first, on the whole column:

SELECT release_date::date FROM games;
ERROR:  invalid input syntax for type date: "Jun 2009"
SELECT TO_DATE(release_date, 'Mon DD, YYYY') FROM games;
ERROR:  date/time field value out of range: "Jun 2009"

Both lines break on the same kind of value: a date with no day. The audit already warned us that 42 rows had another shape. Before converting, count the shapes exactly.

Count the shapes

Two new tools. CASE is a fork in the road: each row takes the first branch whose condition is true (it gets its own lesson in Categorisation). And ~ asks "does this text match this pattern?".

SELECT CASE WHEN release_date ~ '^[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4}$' THEN 'full'
            WHEN release_date ~ '^[A-Z][a-z]{2} [0-9]{4}$'             THEN 'month only'
            ELSE 'other' END AS shape,
       COUNT(*) AS n
FROM games GROUP BY shape ORDER BY n DESC;
shape n
full 26646
month only 41
other 1

You do not need to write these patterns from memory, only to read them:

Piece Means
^ ... $ From the very start of the text to the very end
[A-Z][a-z]{2} One capital letter and two small ones: Nov
[0-9]{1,2} One or two digits: the day
[0-9]{4} Exactly four digits: the year

The single row in other is 8 Oct, 2014, the game Hazard Ops: day first, month second, the opposite of all the others.

Build the converter

Same fork, but now each road smelts its own shape. There is no ELSE: whatever fits neither road becomes NULL.

WITH step1 AS (
  SELECT number, TRIM(game) AS game,
    CASE WHEN release_date ~ '^[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4}$'
           THEN TO_DATE(release_date, 'Mon DD, YYYY')
         WHEN release_date ~ '^[A-Z][a-z]{2} [0-9]{4}$'
           THEN TO_DATE(release_date, 'Mon YYYY')
    END AS release_date,
    price::numeric(8,2) AS price,
    owners, developer, publisher, average_playtime, median_playtime, metascore
  FROM games
  WHERE game IS NOT NULL
)
SELECT COUNT(*), MIN(release_date), MAX(release_date),
       COUNT(*) - COUNT(release_date) AS no_date
FROM step1;
count min max no_date
26685 2004-01-11 2018-12-31 1

Three things are new here:

Piece What it does
WITH step1 AS (...) Gives a query a name, so the SELECT below can use its result as if it were a table
price::numeric(8,2) An exact decimal with two decimals: the right type for money
WHERE game IS NOT NULL Drops the 3 rows without a name, as the inventory decided

The last SELECT is a check row: how many rows, the oldest date, the newest date, how many have no date. It reads 26,685 games, from 2004 to 2018, and exactly one without a date. That one is Hazard Ops.

Why numeric for money?

The raw price column has the type double precision, which stores decimals approximately. Add them all up and the tiny errors show: SUM(price) gives 211089.76999994315. With SUM(price::numeric(8,2)) you get 211089.77. Money is counted in exact cents, so it gets an exact type.

Use it

With a real date, a question that was impossible on the raw table takes one line. Keep the WITH block and change only the final SELECT:

SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*) AS n
FROM step1 GROUP BY year ORDER BY year;
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#4051b5"}}}}%%
xychart-beta
    title "Games released per year in the class table"
    x-axis [2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018]
    y-axis "Games" 0 --> 9000
    bar [53, 58, 112, 143, 183, 334, 340, 364, 426, 556, 1352, 2544, 4503, 6871, 8845]

53 games in 2004, 8,845 in 2018. That curve was hidden inside a text column all along.

Loud errors and silent errors

Back to the furnace refusing '1,234.50'. Good news or bad news?

A loud error stops the query and tells you which value it could not convert. A silent error gives you a wrong value and no warning at all.

Good news. A loud error is the easy kind: you see it, you fix it. Now meet the other kind. Take the 4th of November 2026, written the Spanish way:

SELECT '04/11/2026'::date;                    -- 2026-04-11
SELECT TO_DATE('04/11/2026', 'DD/MM/YYYY');   -- 2026-11-04

The first line returned the 11th of April. No error. PostgreSQL had to guess, and it guessed month first, the American way. The second line was told the format, so it got it right.

flowchart TB
    T["<b>Text value</b><br>'04/11/2026'"] --> G{"Did you tell<br>the format?"}
    G -- "No: '04/11/2026'::date" --> S["<b>Silent error</b><br>2026-04-11, no warning"]
    G -- "Yes: TO_DATE(..., 'DD/MM/YYYY')" --> OK["<b>Correct</b><br>2026-11-04"]
    S --> CH["Ends up in a chart.<br>Somebody decides with it."]
Loud error Silent error
What happens The query stops The query runs
What you get A message naming the bad value A wrong value that looks fine
Example '1,234.50'::numeric '04/11/2026'::date gives 11 April
How to catch it You cannot miss it Only by checking: MIN, MAX, count of NULL

The rule: when you convert, you tell the furnace the format. You never let it guess. And you always check the result with a check row, even when nothing complained.

Careful: a NULL date is not a failed conversion

For one row, Hazard Ops, we chose NULL on purpose. We know it is one row and we wrote it down. A known, counted NULL is honest; an invented date is not.

The same honesty applies to the 41 month-only dates. TO_DATE('Oct 2010', 'Mon YYYY') returns 2010-10-01: the 1st of the month. Nobody released 41 games on the 1st. We chose that day, and the final report must say so.

Try it: release_date becomes a real date

Open SQL → SQL Lab, database Dades classe, schema public. Run one statement at a time: select it, then Run.

Step 1 — Try the lazy way. Run the two lines that fail (release_date::date and TO_DATE with one pattern). You should get two error messages, both naming a value such as "Jun 2009".

Step 2 — Count the shapes. Run the CASE query. You should see full 26,646, month only 41 and other 1. Copy the patterns from this page instead of retyping them: one missing space costs five minutes.

Step 3 — Build the converter. Run the WITH step1 query. The check row must read 26,685 rows, oldest 2004-01-11, newest 2018-12-31, no_date 1. If no_date is 42 or 26,685, a pattern was mistyped.

Step 4 — Use it and save it. Change the final SELECT to the games-per-year query. Then press Save, next to Run, and name the query games_step1. It appears under SQL → Saved Queries. The next lesson starts from it.

The query fails near ')' or near 'SELECT'

After a WITH block there must be no semicolon before the final SELECT: the block and the SELECT are one single statement. An error near ) usually means a stray ;. An error near SELECT usually means the closing ) of the block is missing.

Key takeaways

  • A data type decides what you can do with a column. A number or a date stored as text sorts like a dictionary: '10' < '9' is true.
  • Casting (CAST(x AS type) or x::type) converts a value or stops with an error. It changes the query result, never the table.
  • TRIM cuts spaces at both ends; REPLACE removes thousands separators and fixes the decimal separator before a cast.
  • TO_DATE(text, pattern) reads a date in the format you declare. Different shapes need different patterns, chosen with CASE.
  • A loud error stops you. A silent error ('04/11/2026'::date giving 11 April) is worse: nothing warns you.
  • Always verify a conversion with a check row: count, MIN, MAX, number of NULL.
  • Every cleaning choice (one NULL date, 41 dates set to the 1st) is a decision to write down.

Quick check

1. Why does SELECT '10' < '9' return true?
  • Because PostgreSQL has a bug with two-digit numbers
  • Because both values are text, and text is compared character by character
  • Because the quotes turn the numbers into dates
  • Because < means "longer than" for text

Inside quotes the values are text, and text sorts like a dictionary: the character 1 comes before 9, so '10' is "smaller". Without quotes they are numbers and 10 < 9 is false. The type decides, not the look.

2. What is the difference between CAST('42' AS integer) and '42'::integer?
  • CAST changes the table and :: only changes the result
  • :: is faster but less exact
  • None: they are two spellings of the same conversion
  • CAST works on text and :: only on numbers

Both are casting. CAST(x AS type) is standard SQL and x::type is PostgreSQL shorthand. Neither one changes the table: a cast only changes the result of the query it is written in.

3. A column preu holds text like 1.250,75. Which expression turns it into a number?
  • preu::numeric
  • REPLACE(preu, ',', '')::numeric
  • REPLACE(REPLACE(preu, '.', ''), ',', '.')::numeric
  • TRIM(preu)::numeric

It is written the Spanish way: the point is the thousands separator and the comma is the decimal separator. First delete the points, then turn the comma into a point: 1250.75. Deleting only the comma would give 1.25075, a completely different number, with no error.

4. SELECT '04/11/2026'::date returns 2026-04-11. What kind of problem is this?
  • A loud error: the query stopped
  • A silent error: a wrong value and no warning
  • No problem: 4 November and 11 April are the same date
  • A missing value

PostgreSQL had to guess the format and read the month first. The query ran, so nothing told you the date was wrong. That is a silent error, the dangerous kind. TO_DATE('04/11/2026', 'DD/MM/YYYY') declares the format and returns 4 November.

5. After the conversion, the check row says no_date = 1. What does that mean?
  • The conversion failed and must be redone
  • One game was deleted from the table
  • One date fits neither pattern and was left as NULL on purpose
  • One game was released on the 1st of the month

The CASE has no ELSE, so a value that matches no pattern becomes NULL. That is the single row written 8 Oct, 2014. It is a known, counted NULL: a decision written down, not an accident.

6. Why is price cast to numeric(8,2) instead of being left as double precision?
  • Because numeric columns take less space on disk
  • Because double precision cannot hold decimals
  • Because numeric stores decimals exactly, and money must add up to the cent
  • Because double precision sorts alphabetically

double precision stores decimals approximately: adding all the prices gives 211089.76999994315. With numeric(8,2) the same sum is 211089.77. For money you want the exact type.

7. A CSV from a Spanish town hall has a column data with text like 31/12/2025 and 04/11/2025. Pick the two statements that are true.
  • TO_DATE(data, 'DD/MM/YYYY') converts both values correctly
  • data::date is safer because it adapts to each row
  • With data::date, 31/12/2025 gives an error but 04/11/2025 silently becomes 11 April
  • The values cannot be converted because they contain slashes

Declare the format and both values come out right. Let the database guess and it reads the month first: month 31 does not exist, so that row fails loudly, while 04/11 passes silently as the wrong date. The loud row is the lucky one: it warns you about all the others.

Dates and prices are now real values. But owners is still a text range, and 74 game names appear more than once. Next: Cleaning: units and duplicates, where two things that look identical turn out not to be.