CSV Cleaner

Turn a messy spreadsheet export into a tidy one. A real command-line tool that reads a CSV, cleans it, and writes the result to a new file.

Beginner 45–60 minutes

About this project

Exported data is almost always grubby: stray spaces around values, blank rows, inconsistent casing. A CSV cleaner reads that mess, tidies every field, and writes a fresh, clean copy — without ever touching the original. It’s the kind of small standard-library automation that quietly saves people hours.

Why it’s worth building: this is the classic “read a file, transform it, write a file” pipeline — the backbone of data work and automation. We’ll also make it a proper tool: instead of hard-coding filenames, it takes them from the command line, so you can point it at any CSV and reuse it for real.

Build it step by step

We’ll build the pipeline in pieces — read, clean, write — then wrap it in a command-line front door so it behaves like a tool you’d actually keep around. First, make a messy file to test against.

Step 0 — A messy file to clean

Create a file called data.csv next to your script with these exact contents — note the rogue spaces and the blank line. Real exports look just like this.

Example · data.csv
name, city
  Ada , Lagos 

 Linus , Helsinki 
Our test data: padded fields and an empty row, on purpose.

Step 1 — Read the rows

The csv module understands the comma-separated format so you don’t have to split lines by hand (which breaks the moment a value contains a comma). Opening with newline="" is the documented way to let csv handle line endings correctly across operating systems. We wrap the read in a read_rows function so every later step can reuse it.

Example
import csv


def read_rows(path):
    """Read every row of a CSV into a list of lists."""
    with open(path, newline="", encoding="utf-8") as f:
        return list(csv.reader(f))


for row in read_rows("data.csv"):
    print(row)
Output
['name', ' city']
['  Ada ', ' Lagos ']
[]
[' Linus ', ' Helsinki ']
Each row comes back as a list of strings — spaces and blanks intact.

Step 2 — Clean each field, drop empty rows

Two problems, two fixes. .strip() removes the padding from every field; a check with any() drops rows where nothing is left. Wrapping it in a function means the rest of the program just says clean_rows(rows) and trusts it.

Example
def clean_rows(rows):
    """Trim whitespace from each field and drop fully-empty rows."""
    cleaned = []
    for row in rows:
        trimmed = [field.strip() for field in row]
        if any(trimmed):          # keep the row only if something remains
            cleaned.append(trimmed)
    return cleaned
A comprehension trims the fields; any() decides if the row is worth keeping.

Step 3 — Write a clean copy

Cleaning is useless if you can’t save the result. csv.writer turns lists back into proper CSV lines — quoting anything that needs it. We write to a new file so the original mess is never overwritten.

Example
def write_rows(rows, path):
    with open(path, "w", newline="", encoding="utf-8") as f:
        csv.writer(f).writerows(rows)


# try it end to end:
rows = read_rows("data.csv")          # from Step 1, wrapped in a function
write_rows(clean_rows(rows), "data.clean.csv")
print("Wrote data.clean.csv")
Output
Wrote data.clean.csv
writerows writes the whole cleaned table in one call.

Step 4 — Make it a command-line tool

Hard-coded filenames make a script a one-trick pony. Reading the command-line arguments in sys.argv lets you run python csv_cleaner.py messy.csv against any file. We derive a sensible default output name with pathlib, and catch a missing file instead of dumping a traceback.

Example
import sys
from pathlib import Path

in_path = Path(sys.argv[1])
out_path = in_path.with_name(in_path.stem + ".clean.csv")

try:
    rows = read_rows(in_path)
except FileNotFoundError:
    print(f"No such file: {in_path}")
    raise SystemExit(1)

print(f"Reading {in_path}, writing {out_path}")
One argument turns a fixed script into a reusable tool.

The finished tool

Here’s the complete command-line cleaner: read → clean → write, wrapped in functions, driven by a command-line argument, with a friendly error and a summary line. This is a script you can genuinely keep in a tools folder and use.

Example · csv_cleaner.py
import csv
import sys
from pathlib import Path


def read_rows(path):
    """Read every row of a CSV into a list of lists."""
    with open(path, newline="", encoding="utf-8") as f:
        return list(csv.reader(f))


def clean_rows(rows):
    """Trim whitespace from each field and drop fully-empty rows."""
    cleaned = []
    for row in rows:
        trimmed = [field.strip() for field in row]
        if any(trimmed):
            cleaned.append(trimmed)
    return cleaned


def write_rows(rows, path):
    with open(path, "w", newline="", encoding="utf-8") as f:
        csv.writer(f).writerows(rows)


def main():
    if len(sys.argv) < 2:
        print("Usage: python csv_cleaner.py <input.csv> [output.csv]")
        raise SystemExit(1)

    in_path = Path(sys.argv[1])
    if len(sys.argv) > 2:
        out_path = Path(sys.argv[2])
    else:
        out_path = in_path.with_name(in_path.stem + ".clean.csv")

    try:
        rows = read_rows(in_path)
    except FileNotFoundError:
        print(f"No such file: {in_path}")
        raise SystemExit(1)

    cleaned = clean_rows(rows)
    write_rows(cleaned, out_path)
    print(f"Read {len(rows)} rows, wrote {len(cleaned)} clean rows to {out_path}")


if __name__ == "__main__":
    main()
Output
$ python csv_cleaner.py data.csv
Read 4 rows, wrote 3 clean rows to data.clean.csv
Run it on data.csv and you get data.clean.csv — original untouched.

Keep going — make it your own

You have a working tool. Each idea below turns it into something you’d reach for on real data — and introduces one more genuinely useful technique.

Remove duplicate rows. A set remembers what it’s already seen, so you can skip repeats in one pass. Lists can’t go in a set, but a tuple of the row can.

Example
def drop_duplicates(rows):
    seen = set()
    unique = []
    for row in rows:
        key = tuple(row)        # tuples are hashable; lists are not
        if key not in seen:
            seen.add(key)
            unique.append(row)
    return unique
A set turns “have I seen this row?” into an instant check.

Clean by column with DictReader. Reading rows as dictionaries lets you target specific fields — title-case names, lowercase emails — instead of guessing positions. Far more readable than row[0], row[1].

Example
import csv

with open("data.csv", newline="", encoding="utf-8") as f:
    for record in csv.DictReader(f):
        # headers can be padded too, so strip the keys as well as the values
        clean = {key.strip(): value.strip() for key, value in record.items()}
        print(f"{clean['name'].title()} - {clean['city']}")
Output
Ada - Lagos
Linus - Helsinki
DictReader keys each row by its header; a dict comprehension trims both.

Clean a whole folder at once. Use pathlib’s glob to find every CSV in a folder, and write the results into a cleaned/ subfolder you create automatically. This is real automated file management — point it at a directory and walk away.

Example
from pathlib import Path

out_dir = Path("cleaned")
out_dir.mkdir(exist_ok=True)              # create the output folder once

for csv_path in Path("data").glob("*.csv"):
    cleaned = clean_rows(read_rows(csv_path))
    write_rows(cleaned, out_dir / csv_path.name)
    print(f"Cleaned {csv_path.name} -> cleaned/{csv_path.name}")
glob finds the files; mkdir prepares the destination; the loop does the rest.

Give it real flags with argparse. Once a tool grows options, argparse (also standard library) handles --output, --dedupe, and a built-in --help for you — the professional step up from reading sys.argv by hand.

Example
import argparse

parser = argparse.ArgumentParser(description="Clean a CSV file.")
parser.add_argument("input", help="path to the messy CSV")
parser.add_argument("-o", "--output", help="where to write the clean copy")
parser.add_argument("--dedupe", action="store_true", help="drop duplicate rows")
args = parser.parse_args()

print(args.input, args.output, args.dedupe)
argparse gives you --help and friendly errors for free.

Mini exercise (medium)

Write the cleaner’s core: clean_rows(rows) that trims whitespace from every field and drops rows that are entirely empty — working on an in-memory table (no file needed).

Do it here. Finish the code below and press Run to try your answer right in the browser — no setup needed.

def clean_rows(rows):
    cleaned = []
    for row in rows:
        # TODO: strip each field; keep the row only if something remains
        cleaned.append(row)
    return cleaned

messy = [["  Ada ", " Lagos "], ["", ""], [" Linus ", " Helsinki "]]
print(clean_rows(messy))

Where to go next

You’ve built a real automation tool — the same pattern behind report generators and data pipelines. To package it up properly (folders, a virtual environment, a README), see Organizing a Python Project.