Cleaning data in a spreadsheet
An export almost never arrives ready to analyse. This lesson takes the messy East city sales export from Mangrove Market and turns it into a table you can trust: the raw file kept untouched, every change written down, and the row counts and totals checked before anyone reads a number.
By the end you can
- Keep the raw export untouched and do every change in a working copy with helper columns
- Reshape a table so that it is tidy: one row per observation, one column per variable
- Find and fix numbers stored as text, dates read the wrong way, stray spaces and inconsistent labels
- Find duplicates with COUNTIFS before you remove them, and add store details with XLOOKUP
- Keep a cleaning log and prove the result with row counts and a total that matches another source
The everyday picture
A grocery delivery arrives at a restaurant kitchen. Before anyone cooks, the chef checks the crates against the delivery note. Two crates of tomatoes are listed once but delivered twice. One box is labelled "toms", another "Tomatoes", another "TOMATO". A bag of onions has no label at all. The chef counts, sorts, relabels and sets aside what is wrong.
Two habits make this work. The chef keeps the supplier's delivery note in a drawer, unchanged, so any question later can be checked against it. And the chef writes on the note what was sent back and why. If the numbers at the end of the night don't add up, the trail is there.
Cleaning data is the same job. The raw export is the delivery note: you never write on it. The working copy is the kitchen. The cleaning log is the note of what you changed. And counting the crates is the row count you check before and after every step.
Raw stays raw, and the table should be tidy
In Asking a question data can answer you wrote an analysis plan for "East city sales are down. Why?" The plan named a dataset: daily sales by store and category for East city's 54 stores, for the 8 weeks to Sunday 18 October 2026. Tomás, Mangrove's data analyst, now sends you that export as a CSV file. Before you look at a single number, you set up your workbook in three tabs.
- raw: the export, exactly as it arrived. You never type in it, sort it or delete from it. If you break something later, you start again from here.
- work: helper columns with formulas that read from raw and produce clean values next to the original ones, so you can compare them row by row.
- clean: the final table, pasted as values from work, plus a log tab that lists every change.
Why so careful? Because a spreadsheet does not remember what you did. If you overwrite a column and save, the old values are gone, and nobody, including you next week, can tell what the file said before. A formula next to the original keeps both.
The clean table should be tidy. Hadley Wickham's 2014 paper Tidy Data gives three rules: each variable is a column, each observation is a row, and each type of thing you observe is its own table. For our question, one observation is one store's sales of one category on one day. That is the table's grain, the word you met in da-01. The variables are store, date, category, sales and transactions.
Many files are built for reading on screen. A store manager's weekly report has a column per week, total rows and merged headers. A pivot table or a formula cannot use it: the week, a variable, is hidden in the column headers, and the total rows are a different kind of thing mixed in with the observations.
Turning columns into rows is called unpivoting. In Excel, Power Query has an Unpivot Columns command; in any tool you can also rebuild the table with formulas, and later in this course you will do it in a few lines of Python (The same analysis in Python). Our East export is already long, one row per store, date and category, but it has a grand total row at the bottom. A total row breaks tidiness the same way: sum the sales column and you count every sale twice.
Quick check
You need to fix a wrong store name in the export. Where do you make the change?
The raw export is your evidence of what the system sent. Fixing it in place, in the sheet or in the file, destroys that evidence and leaves no trail. Change values downstream and write each change in the log.
Types: numbers stored as text, and dates read the wrong way
Every cell has a data type. A number can be added, a date can be grouped by week, and text can only be compared and searched. The most common cleaning problem is a value that looks like a number or a date but is stored as text. Formulas then skip it without any warning.
Numbers stored as text. SUM and AVERAGE add only real numbers. If a range holds "USD 1204.50" as text, SUM leaves that cell out and still shows a confident total. Four quick ways to spot the problem:
- Alignment: with default formatting, numbers sit on the right of the cell and text on the left.
- Excel marks many numbers stored as text with a small green triangle in the corner of the cell.
=ISNUMBER(E2)returns TRUE for a real number and FALSE for text.- Compare
=COUNT(E2:E24666), which counts numbers only, with=COUNTA(E2:E24666), which counts every non-empty cell. The difference is the number of text cells in the column.
The fix is a helper column that removes the extra characters and converts the rest: =VALUE(SUBSTITUTE(TRIM(E2),"USD ","")). TRIM removes spaces at the start and end and turns runs of spaces inside the text into one. SUBSTITUTE swaps one piece of text for another, here "USD " for nothing. VALUE turns the text that is left into a number.
Watch the thousands separator and the decimal mark. One thousand two hundred and four dollars fifty is written 1,204.50 in some countries and 1.204,50 in others. Spreadsheets read numbers using the locale of your computer or file, so the same CSV can give numbers on one laptop and text on another. Before you convert, look at a few raw values and decide which convention the export uses.
Dates. A spreadsheet stores a real date as a number of days, which is why you can subtract two dates or group them by week. In Google Sheets, and in Excel with its default date system, 18 October 2026 is stored as 46313. Text that only looks like a date gets none of this.
The trap is regional date order. Mangrove's sales system writes dates day first: 03/10/2026 means 3 October. When you open a CSV, the spreadsheet reads each date using your locale. On a laptop set to month first, 03/10/2026 becomes 10 March 2026: a real date, in the wrong month, with nothing to warn you. And 18/10/2026 cannot be read at all (there is no month 18), so it stays as text.
Don't repair flipped dates cell by cell: you cannot tell a flipped 10 March from a real one. Go back to the raw file and import it again so that the date column arrives as text. In Google Sheets, File > Import has a "Convert text to numbers, dates, and formulas" box that you can untick. In Excel, Data > From Text/CSV opens a preview; choose Transform Data and change the date column's type to Text before you load it. Then build the date yourself from its parts:
=DATE(VALUE(RIGHT(C2,4)), VALUE(MID(C2,4,2)), VALUE(LEFT(C2,2)))
year = last 4 characters, month = characters 4-5, day = first 2The lasting fix is upstream. Ask for exports with ISO 8601 dates, written 2026-10-18. Year, month, day can only be read one way.
Quick check
In the sales column, =COUNTA gives 24,649 and =COUNT gives 19,273. What does that tell you?
COUNTA counts every non-empty cell and COUNT only numbers, so the gap is text in a column that should hold numbers. Empty cells are counted by neither, so the gap cannot be blanks. Duplicates would be counted by both.
Spaces, labels, duplicates and blanks
Invisible characters. "Bakery" and "Bakery " (with a space at the end) look the same on screen, but to a formula or a pivot table they are two different labels. TRIM removes the extra spaces. CLEAN removes non-printing characters such as line breaks that sometimes come along from other systems. Excel's TRIM does not remove the non-breaking space that web pages use; =SUBSTITUTE(A2,CHAR(160)," ") turns it into a normal space first.
Inconsistent labels. The same category can arrive as "Fresh produce", "fresh produce", "Fresh Produce " and "Produce". LOWER, UPPER and PROPER fix the case, but they cannot know that "Produce" and "Fruit & veg" mean the same thing. For that, build a small mapping table: one column with every raw label you found (lower case, trimmed), one with the standard name. Then look each row up in it. A row whose label is missing from the map shows "UNMAPPED", so new spellings cannot slip through.
This matters for the analysis. If fresh produce sales are split across four labels, a pivot table shows four small rows instead of one big one, and the drop you are looking for is spread thin.
Lookups from master tables. The export has store IDs but no city and no store type. Those live in a master table: Mangrove's site table, with one row per store or DC. XLOOKUP finds a value in one column and returns the matching value from another:
=XLOOKUP(A2, stores!$A$2:$A$400, stores!$C$2:$C$400, "NOT FOUND")
look for the store ID in A2 in the master's ID column,
return the city from the same row, or NOT FOUND if there is no match
Older versions of Excel: =VLOOKUP(A2, stores!$A$2:$C$400, 3, FALSE)The dollar signs fix the ranges so they don't move when you copy the formula down 24,665 rows. The fourth argument is what XLOOKUP returns when nothing matches; without it you get #N/A. Count the misses with =COUNTIF(E2:E24666,"NOT FOUND"). A lookup also has a type trap: the number 207 and the text "207" do not match. If one side has IDs stored as text, every row for those stores comes back NOT FOUND.
Duplicates. A duplicate is two rows for the same observation: here, the same store, date and category. Exports get them when a store's data is uploaded twice. Find them before you delete anything. A helper column counts how many rows share the row's key:
=COUNTIFS($A$2:$A$24666, A2, $B$2:$B$24666, B2, $C$2:$C$24666, C2)
how many rows have this store, this date and this category?
1 = fine, 2 or more = the key appears more than onceFilter that column to values above 1 and look at the rows. If the copies are identical, they are true duplicates. If the key matches but the sales differ, it's a conflict: one of the two numbers is wrong, and you need to ask which before you keep either. Only then use Remove Duplicates (Data > Remove Duplicates in Excel, Data > Data cleanup > Remove duplicates in Google Sheets), choosing the key columns. Both keep the first row of each group, so the conflicts must be settled first.
Blanks and zeros. A blank and a zero mean different things. Zero says "we know the store sold nothing": it was closed. A blank says "we don't know": the upload failed. AVERAGE skips blank cells but counts zeros, so filling blanks with 0 drags averages down and makes a missing day look like a terrible one. Keep blanks blank, add a note column that says why, and count them with =COUNTBLANK(D2:D24666).
Quick check
A store's sales are blank for one day because the till upload failed. Its manager asks you to "put a zero in so the average works". What do you do?
A zero claims the store sold nothing, and the average would treat a lost upload as a disastrous day. AVERAGE already skips blanks. Copying another day invents data, and dropping the store loses 55 good days to fix one bad one.
The cleaning log and the checks that prove it
A cleaning log is a list of every change: what was wrong, how many rows it touched, what you did, and the row count after. It takes ten minutes. When the head of stores asks "are you sure?", the log is your answer, and when the same export comes next month, it is your recipe.
Three checks turn "I cleaned it" into "it is right":
- Row counts before and after every step. Write down the count after each change and explain every difference. If you removed 24 duplicates, the count must fall by exactly 24.
- The expected shape. You know how many rows a complete table should have: stores × days × categories. Every date should have the same number of rows, and so should every store.
=COUNTIFSper date or per store shows any gap. - Reconcile to another source. To reconcile is to compare your total with a number from somewhere else, such as finance's weekly sales report, and explain the gap. A small, explained gap is fine. An unexplained one means a step is still wrong.
Fixing types and labels does not change the row count, so check those steps another way: after the fix, the count of text cells, UNMAPPED labels and NOT FOUND lookups should all be zero, and each category should have the same number of rows.
Do it
Clean a sales export into a tidy table with a log and checks. Use the East export or any export from your own work.
Protect the raw data
Import the CSV into a tab called raw, with the date column as text. Write down the row count straight away:
=COUNTA(raw!A2:A24666). Never edit this tab.Profile every column
For each column, check the type (COUNT against COUNTA), the blanks (COUNTBLANK), the distinct values of label columns, and the smallest and largest date. Write every surprise in the log before you fix anything.
Build helper columns in the work tab
One formula per column, each reading from raw. Fill them down to the last row.
OutputA store_id =VALUE(TRIM(raw!A2)) B date =DATE(VALUE(RIGHT(raw!C2,4)), VALUE(MID(raw!C2,4,2)), VALUE(LEFT(raw!C2,2))) C category =XLOOKUP(LOWER(TRIM(CLEAN(raw!D2))), map!$A$2:$A$20, map!$B$2:$B$20, "UNMAPPED") D sales =IF(TRIM(raw!E2)="", "", VALUE(SUBSTITUTE(TRIM(raw!E2), "USD ", ""))) E city =XLOOKUP(A2, stores!$A$2:$A$400, stores!$C$2:$C$400, "NOT FOUND") F dup_count =COUNTIFS($A$2:$A$24666, A2, $B$2:$B$24666, B2, $C$2:$C$24666, C2)The TOTAL row gives an error in column A, because "TOTAL" is not a number. That is useful: it flags the row for removal.
Settle duplicates and out-of-scope rows
Filter dup_count above 1 and compare the copies. Filter city for anything other than East and for NOT FOUND. Fix the cause of each, then copy the work tab and paste it as values into the clean tab, run Remove Duplicates on store_id, date and category, and delete the rows that are out of scope.
Check and log
Fill in the log and the checks. The analysis starts only when every check passes or has a written explanation.
Markdown# Cleaning log: East city sales, 24 Aug to 18 Oct 2026 Source: east_sales export from Tomás, received Tue 20 Oct 2026, 24,665 data rows Raw file kept unchanged in tab "raw" | Step | Problem | Rows | Fix | Rows after | |------|------------------------------|--------|------------------------------|------------| | 1 | Grand total row | 1 | removed | 24,664 | | 2 | Sales as text "USD 1204.50" | 5,376 | SUBSTITUTE + VALUE | 24,664 | | ... | | | | | ## Checks - Expected rows: 54 stores x 56 days x 8 categories = 24,192 Actual: 24,192 - Rows per date: all 432 Rows per category: all 3,024 - Text cells in sales: 0 UNMAPPED: 0 NOT FOUND: 0 Blanks: 16 (explained) - Total sales: $42.33M Finance report: $42.36M Gap: $27k (0.06%), blank days ## Open questions - Stores 233 and 247: sales for the missing days requested again from store systems
Bring it to your team
Ask these before anyone builds a chart or a pivot table from a new export.
- Where is the raw file, and has anyone edited it?
- What is one row of this table, and how many rows should there be?
- Does our total match finance's number for the same period, and if not, what explains the gap?
- How were the dates read, and does every date have the same number of rows?
- Which rows did we remove, and is each removal in the log?
- Can the export send ISO dates and plain numbers next time?
Red flags
- A cleaned file with no raw copy and no log.
- Blanks filled with zeros "to make the formulas work".
- Duplicates removed before anyone looked at them.
- A total that has never been compared with another source.
- A category or store list that is longer than the business actually has.
Practice
Clean and total the export in SQL
The same cleaning works in SQL. The table raw_export(row_no, store_id, sale_date, category, sales) holds a small piece of the East export, every column stored as text, the way it arrived. The table site(id, name, city, kind) is the store master. Clean the export and return one row per category for East city stores only, with the columns category (trimmed, in initcap form such as Fresh Produce), row_count (rows after removing duplicates) and sales_cents (total sales in integer cents). Watch for spaces, mixed case, a $ sign, thousands commas, a duplicated row, a West store and a TOTAL row. A duplicate is a row with the same store, date, category and sales after cleaning; the same sales on a different day is not a duplicate.
CREATE TABLE site (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL, kind text NOT NULL); INSERT INTO site VALUES (201, 'Riverside', 'East', 'store'), (202, 'Harbour Road', 'East', 'store'), (203, 'Old Market', 'East', 'store'), (318, 'Hill Gate', 'West', 'store'); CREATE TABLE raw_export (row_no int PRIMARY KEY, store_id text, sale_date text, category text, sales text); INSERT INTO raw_export VALUES (1, '201', '2026-10-12', 'Fresh produce', '1,204.50'), (2, '201', '2026-10-12', 'Bakery', '310.00'), (3, '201', '2026-10-12', 'Fresh produce', '1,204.50'), (4, '202 ', '2026-10-12', ' fresh produce', '980.25'), (5, '202', '2026-10-12', 'BAKERY ', '295.75'), (6, '203', '2026-10-12', 'Fresh Produce', '1,050.00'), (7, '203', '2026-10-12', 'Bakery', '$402.10'), (8, '318', '2026-10-12', 'Fresh produce', '2,400.00'), (9, '202', '2026-10-12', 'fresh produce ', '980.25'), (10, '201', '2026-10-13', 'Bakery', '310.00'), (11, 'TOTAL', '', '', '9,137.35');
-- Clean the export, then total East city sales per category
WITH cleaned AS (
SELECT store_id, -- TODO: trim(store_id)
sale_date,
category, -- TODO: initcap(trim(category))
sales -- TODO: drop ',' and '$' with replace(), then ::numeric
FROM raw_export -- TODO: SELECT DISTINCT, so cleaned duplicates collapse to one row
)
SELECT category,
count(*) AS row_count,
0 AS sales_cents -- TODO: sum(sales * 100)::int
FROM cleaned -- TODO: JOIN site ON site.id::text = cleaned.store_id, keep city = 'East'
GROUP BY category;
[{"category": "Bakery", "row_count": 4, "sales_cents": 131785}, {"category": "Fresh Produce", "row_count": 3, "sales_cents": 323475}]
Show a solution
WITH cleaned AS (
SELECT DISTINCT -- after cleaning, copies are identical
trim(store_id) AS store_id, -- '202 ' becomes '202'
sale_date,
initcap(trim(category)) AS category, -- ' fresh produce' becomes 'Fresh Produce'
replace(replace(trim(sales), ',', ''), '$', '')::numeric AS sales -- '1,204.50' becomes 1204.50
FROM raw_export
)
SELECT c.category,
count(*) AS row_count,
sum(c.sales * 100)::int AS sales_cents -- money as whole cents
FROM cleaned c
JOIN site s ON s.id::text = c.store_id -- compare as text: 'TOTAL' is not a number
WHERE s.city = 'East' -- drops store 318 (West)
GROUP BY c.category;The order matters: clean first, then remove duplicates. Row 9 only becomes a copy of row 4 after trimming. Row 10 has the same sales as row 2 but on another day, so it stays. The join to the store master does two jobs at once: it drops the TOTAL row, which matches no store, and the West store. Comparing s.id::text with the text ID avoids an error, because 'TOTAL'::int would fail.
Find the problems and write the log
A store manager sends you this extract. Your laptop reads dates month first. The full file should have 8 categories per store per day. List every problem you can see, say how you would find it across 24,000 rows with a formula, and write the first four lines of your cleaning log.
store_id store_name date category sales transactions
207 Lakeview 05/10/2026 Fresh produce 1,180.40 212
207 Lakeview 05/10/2026 Fresh Produce 1,180.40 212
207 Lakeview 14/10/2026 Dairy USD 640.00 150
"212" North Gate 14/10/2026 Produce 905.10 171
219 Canal Street 07/09/2026 Bakery 0 0
233 Mill Lane 12/10/2026 Bakery
TOTAL 412,880.55- You spot the duplicate on row 2 (same store, date and sales once the label is cleaned) and name COUNTIFS on the key as the way to find all of them
- You explain that 05/10/2026 will turn into 10 May and 14/10/2026 will stay text, and that the fix is to re-import the dates as text and rebuild them
- You catch the text sales "USD 640.00" (COUNT against COUNTA, or ISNUMBER) and the store ID stored as text that will break the lookup
- You map "Produce" to Fresh produce with a mapping table, keep the zero for Canal Street (closed) apart from the blank for Mill Lane (unknown), and remove the TOTAL row
- Your log lines each have the problem, the number of rows, the fix and the row count after
Show a model answer
Problems: row 2 duplicates row 1 (the labels differ only in case); 05/10/2026 will become 10 May and 14/10/2026 will stay as text on a month-first laptop; "USD 640.00" is text and SUM will skip it; store "212" is text and XLOOKUP against numeric IDs will return NOT FOUND; "Produce" is another spelling of Fresh produce; Canal Street's 0 is a real zero if the store was closed, but Mill Lane's blank is unknown and must stay blank; the TOTAL row would double the sum.
Formulas to find them in the full file: COUNTIFS on store, date and category for duplicates; COUNT against COUNTA and ISNUMBER for text numbers; a mapping table with "UNMAPPED" for new labels; XLOOKUP with "NOT FOUND" for IDs; COUNTBLANK for blanks; COUNTIFS per date to show missing days.
Step Problem Rows Fix Rows after
1 TOTAL row 1 removed n - 1
2 Dates day first, read wrongly all re-imported as text, DATE() n - 1
3 Sales as text "USD ..." ? SUBSTITUTE + VALUE, COUNT = COUNTA n - 1
4 Duplicate store-date-category ? checked copies, Remove Duplicates n - 1 - dupsCheck yourself
After cleaning, your East table has 24,190 rows. You expected 54 stores × 56 days × 8 categories = 24,192. What is the best next step?
The expected shape tells you exactly how many rows should exist, so a gap of two points to a specific store and day. COUNTIFS per date or store finds it in a minute. Removing duplicates again would only lower the count. Adding zeros invents data, and "too small to matter" is a guess until you know what the rows are.
A pivot of the East data by date shows 24 real dates, all on the 9th or 10th of a month from January to December 2026, plus 32 entries that are text, such as 18/10/2026. What happened?
This is the fingerprint of regional date order. 01/09/2026 to 12/09/2026 were read as 9 January to 9 December, and the October days as the 10th of each month; day 13 and later cannot be a month, so they stay text. Refreshing the pivot won't change the cell types, and missing sales or closed stores cannot move dates into other months.
COUNTIFS shows 48 rows with a count of 2 on store, date and category. In 8 of those pairs, the two rows have different sales values. What should you do?
Identical copies are true duplicates and are safe to remove. Pairs with different values are conflicts: Remove Duplicates would keep whichever came first, which may be the wrong one. Keeping the higher value or averaging are guesses dressed up as rules.
Flashcards
Key takeaways
- Keep the raw export untouched; clean in helper columns and a separate clean tab, and log every change.
- Make the table tidy: one row per observation at a clear grain, one column per variable, no total rows.
- Check types first: numbers stored as text and dates read in the wrong order break formulas without any warning.
- Fix labels with TRIM, CLEAN and a mapping table, add details from master tables with XLOOKUP, and look at duplicates with COUNTIFS before removing them.
- A cleaned table is ready when its row count matches the expected shape and its total reconciles to another source.
Sources and further reading
- Tidy Data, Hadley Wickham, Journal of Statistical Software, 2014
- Data Organization in Spreadsheets, Karl Broman and Kara Woo, The American Statistician, 2018
- Top ten ways to clean your data, Microsoft Support
- XLOOKUP function, Microsoft Support