One customer showed up in the data as four
You group revenue by city and Seoul comes back four times: "Seoul", "seoul", "SEOUL", and "Seoul " with a trailing space. To the machine these are four different strings, and GROUP BY dutifully returns four rows.
Member A · Seoul · 12,000
Member A · seoul · 12,000 ← the same person, entered twice
Member B · SEOUL · 8,000
Member C · "Seoul " (trailing space) · 5,000
Two steps fix this: de-duplicate and normalise the spelling. Run them in one order and then the other, and the answers part company.
| Order of steps | Rows dropped as duplicates | Rows left | Seoul revenue |
|---|---|---|---|
| De-duplicate → normalise | 0 | 4 | 37,000 |
| Normalise → de-duplicate | 1 | 3 | 25,000 |
Suppose you had run de-duplication first. It would have caught nothing. Two rows for the same person still differ if the city is spelled differently, so they are not identical rows. Normalise the spelling afterwards and those rows become identical — but de-duplication has already been and gone, so they stay in the table permanently.
Data cleaning is this kind of work. Not one idea in it is hard, but getting the order wrong fails silently and leaves no trace. There are simply a few more rows than there should be.
The key question
If I run this step now, will the next step be able to see what it needs to see?
What you are fixing, and what you are not
The two get named in one breath, which blurs them. Cleaning restores data to what it should have been all along. Preprocessing changes it into something a model can use.
| Cleaning | Preprocessing | |
|---|---|---|
| Goal | Correct what is wrong | Reshape into a usable form |
| Examples | De-duplication, type casting, spelling | Scaling, encoding, derived features |
| Judgement | There is a right answer | Depends on the model |
| Learns parameters | No | Yes — means, maxima, category stats |
| Relative to the train/test split | Fine before it | Must come after it |
That last row is where real projects go wrong. Fixing a typo using the whole dataset harms nothing. But the moment you compute a mean in order to fill blanks, that mean has to come from the training rows alone.
Duplicates
the same row twiceExact copies, and copies that only match after cleaning.
Types
numbers stored as textDates, numbers and booleans arriving as strings.
Consistency
one value, four spellingsWhitespace, casing and naming variants of one category.
Sentinels
missing in disguise999 and −1 are values to the computer, blanks to you.
Open the file and fix it by hand, and the work evaporates. Nobody knows what you changed, next month's extract needs the whole job doing again, and when a result looks odd there is nothing to trace back through.
Why the row counts are worth logging
Print the surviving row count at every step and the question "you said fifty thousand, why is this forty-eight?" takes one line to answer. Without that log, the same question eats a day.
Get the order wrong and the duplicates survive
Cleaning steps look independent, which is why nobody thinks about ordering them. In fact each one creates the state the next one needs in order to see anything.
How order changes the answer
Normalise → de-duplicate: 12 rows → 8 rows
De-duplicate → normalise: 12 rows → 11 rows
Same two steps, different result. In the second case three pairs survive and get counted as separate customers in every aggregate from then on.
For the same reason, type casting comes first almost always. While a date is text, 2025-10-01 sorts before 2025-2-01, and every duration calculation and range filter downstream is wrong.
An age column averaging 100 because of a few 999s gets noticed. The dangerous case is the less extreme one: a handful of −1s in "purchase count" pulls the mean down just slightly, and nobody ever looks twice.
| Value | Where it shows up | Originally meant |
|---|---|---|
| 999, 9999, 99999 | Age, count, code fields | "Not recorded", from fixed-width forms |
| −1 | Counts and durations | "Unknown", chosen because counts cannot be negative |
| 0 | Anywhere | Sometimes a real zero, sometimes an unfilled default |
| 1900-01-01, 1970-01-01 | Date fields | The epoch, or a database default |
| "N/A", "NULL", "-" | Text fields | Missing, typed by hand in three ways |
| 99.9, 9.99 | Measurements | An out-of-range flag from an instrument |
Finding them is easy. For each numeric column, look at the minimum, the maximum and the most frequent values. If 999 is a genuine measurement there will be one of it; if it is a sentinel there will be dozens, all identical.
Cleaning on the full dataset is broadly safe. Anything that learns a parameter is not. These steps extract a number from the data and remember it, and if that number carries information from the test rows, you have leakage.
| Step | What it memorises | If fitted on everything |
|---|---|---|
| Mean imputation | The column mean | The test set mean bleeds into training |
| Standardisation | Mean and standard deviation | Same problem |
| Min-max scaling | The minimum and maximum | Test-set extremes leak in |
| Target encoding | Target mean per category | The worst case — the answer leaks directly |
| Feature selection | Which columns survive | You picked them by looking at test performance |
| Outlier thresholds | Column quantiles | The test rows helped set the cut-off |
The symptom never varies
Leakage makes validation scores better than the real thing. Performance drops once it ships. Which is why a model that comes in better than expected is not cause for celebration but cause for an audit.
Seven steps, each in the only place it works
The sequence below is not a preference. Each step sits where it does because it needs what the previous step produced.
| # | Step | Why it goes here |
|---|---|---|
| 1 | Fix types | Nothing downstream can compare values it cannot parse |
| 2 | Normalise text | Turns near-duplicates into exact ones |
| 3 | Convert sentinels to missing | 999 must stop being a number before any statistic runs |
| 4 | Drop duplicates | Only now do duplicates actually look identical |
| 5 | Apply range rules | Impossible values are easier to spot in a clean frame |
| 6 | Split train and test | Everything after this point must be fitted on train alone |
| 7 | Impute, scale, encode | These learn parameters, so they come after the split |
Step 6 is the dividing line. Above it the work has a right answer, so the full dataset is fine. Below it the work learns numbers from the data, so it may only ever see the training rows.
Five quite different situations hide under the phrase "drop duplicates". Delete indiscriminately and you lose real records.
| Kind of duplicate | How to find it | What to do |
|---|---|---|
| Every column identical | A plain duplicate check | Safe to drop — but find out why it happened |
| Same key, different values | Group by the key, count > 1 | Decide which record wins, and record the rule |
| Same entity, different spelling | Normalise first, then check again | Fuzzy match only if you must, and review it |
| Duplicated by a join | Row count jumped after a merge | The join key is not unique — fix the join |
| Legitimately repeated | Same person, two real purchases | Keep both — this is not a duplicate |
The last row is the one to remember. If the same customer paid the same amount twice on the same day, those are two transactions, not a duplicate. Dropping them because the rows match deletes revenue.
Strings that look identical to a reader are frequently different to a machine. Working through these in order clears up most of it.
| Step | Applies to | Example |
|---|---|---|
| Trim leading and trailing space | Nearly every text column | "NY " → "NY" |
| Collapse repeated spaces | Names and addresses | "New York" → "New York" |
| Fold case | Codes and categories | "PRO" → "Pro" |
| Unicode normalisation | Accents and composed characters | "José" in two encodings |
| Apply an alias table | Places and company names | "NYC" → "New York" |
| Unify missing markers | "", "N/A", "unknown" | All become null |
Fuzzy matching is a last resort
Merging similar values by edit distance is powerful and fails silently."Manchester" and "Winchester" are close. Exhaust the rules you can write down first, then take whatever is left as a list for a human to reviewrather than a merge to run automatically.
Rules that come from the domain — "age is between 0 and 120" — cost one line of code and then catch violations in next month's extract automatically. Kept in your head, they get missed every time.
How 50,000 rows became 47,904
Here is the log from cleaning a subscription membership table. Recording the row count at every step means any later question about the numbers has an answer.
| Step | Rows left | Change | Note |
|---|---|---|---|
| Loaded raw | 50,000 | — | Straight from the CSV |
| Cast three date columns | 50,000 | 0 | 12 unparseable values set to null |
| Normalised text | 50,000 | 0 | plan: 11 distinct values → 4 |
| −1 and 9999 → missing | 50,000 | 0 | 812 in tenure, 47 in age |
| Dropped exact duplicates | 48,317 | −1,683 | The export job ran twice |
| Resolved key duplicates | 48,102 | −215 | Kept the most recent record |
| Removed range violations | 48,061 | −41 | age > 120, tenure < 0 |
| Dropped rows with no label | 47,904 | −157 | Churn status unknown |
The total loss is 2,096 rows, or 4.2%. But 1,683 of those came from an export job that ran twice — which is not a data problem, it is a pipeline problem. Deleting the rows and moving on is the wrong response; telling the data team is the right one.
The lesson here
A cleaning log is not a record of what you patched. It is a report on what is broken upstream. Those 1,683 duplicates will be back next month, and every month after, until somebody fixes the cause.
In the same table, 41 rows had a negative tenure_days. It looked like an obvious error and was about to be deleted. Asking why turned up a reason.
Where the negatives came from
Signup date was account creation time; first payment was the processor's authorisation time.
For pre-order customers the payment cleared before the account existed.
So the 41 rows were not errors, they were pre-order customers — and the segment with the lowest churn rate in the whole table. Deleting them would have thrown out the best customers in the business.
The whole difference is between "this looks wrong, delete it" and "this looks wrong, find out why". Odd values are usually information about how the data came to exist.
Misconception 1
❌ Identical rows can always be dropped.
If the same customer paid the same amount on the same day twice, both are real. On a table with no unique transaction ID, identical rows can be perfectly correct — so establish what the table's key is before deleting anything.
Misconception 2
❌ Fill the blanks with the mean and get started.
Mean imputation shrinks variance and distorts correlations, and the mean it uses has to come from the training rows only. The question that comes before filling anything is "why is this blank?"
Misconception 3
❌ Cleaning is a one-off task.
The data arrives again next month. Fix it by hand and you will fix it by hand every month — and slightly differently each time. That is the real argument for writing it as code.
Misconception 4
❌ Might as well scale while you are cleaning.
Scaling learns a mean and a standard deviation from the data. Do it before the split and the test rows contribute to those numbers, which makes validation look better than the model actually is.
Run the same steps in different orders
Twelve rows. The four steps are applied in the order you click them, and the badge on each button shows its position. Turn all four on and the surviving row count still depends on the sequence.
Click the steps in the order you would run them. The number on each button is its place in the pipeline.
Rows
12
Distinct cities
10
Mean spend
192.8
| name | city | joined | spend |
|---|---|---|---|
| Kim Minjun | Seoul | 2025-03-04 | 120 |
| kim minjun␣ | seoul | 2025-03-04 | 120 |
| Lee Soyeon | Busan | 2025-1-9 | 340 |
| Lee Soyeon | busan␣ | 2025-1-9 | 340 |
| Park Jiho | SEOUL | 2025-07-21 | -1 |
| Choi Eunji | Incheon | 2025-11-2 | 85 |
| Choi Eunji | Incheon | 2025-11-2 | 85 |
| Jung Haerin | Seoul␣ | 2025-05-13 | 210 |
| Yoon Doyun | Daegu | 2025-08-30 | -1 |
| Kang Sena | BUSAN | 2025-02-17 | 460 |
| Kang Sena␣ | Busan | 2025-02-17 | 460 |
| Oh Taemin | Incheon␣ | 2025-12-1 | 95 |
Twelve rows as delivered. Three of them are the same three people entered twice, but no two of those rows are byte-identical yet.
Cleaning steps do not commute. The order is part of the method.
Learning points
De-duplication recognises identical rows only — near-matches are invisible to it.
Which is why normalising the spelling has to come strictly before it.
Getting the order wrong raises no error. There are just a few more rows than there should be.
Order matters
The steps do not commute
Never edit in place
Cleaning is code, not handiwork
Ask before you fix
A defect is also information
The question is not "what should I fix?"
It is "will the next step be able to see what it needs after this one runs?" Cleaning is not a checklist of tasks but an ordered pipeline.
Cleaning is not a thing you do once before analysing. It is code that reruns every time the data refreshes. In SKARI you can check the following alongside.
Duplicates and quality
Duplicate Check, Key Validation, Range Rules
Text tidying
Trim, Case Fold, Category Mapping
Missing and outliers
Missing Map, Sentinel Scan, Outlier Fence
Preprocessing
Scaling, Encoding, Train/Test Split
Once this clicks, you can answer questions like these.