CSV Cleaning skill for Claude Code

From mojibake and shifted columns to clean UTF-8. Claude preserves the raw file, runs a bundled profiler that detects encoding, BOM, delimiter, ragged rows, ambiguous dates and European number formats, then repairs structure and parses every column explicitly. Duplicates are removed by a stated rule, bad lines go to a rejects file, and a cleaning log records rows in and out for each step.

  • namecsv-cleaning
  • size206 lines · ≈2,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Cleans messy CSV and TSV files — detects encodings, BOMs, delimiters, ragged rows, and header problems with a bundled profiler, then repairs structure, parses dates and locale-formatted numbers explicitly, normalizes text, deduplicates by rule, validates, and writes UTF-8 or Parquet output with a cleaning log. Use when a CSV will not load, shows garbled characters, has shifted columns, mixed date formats, European decimals, duplicates, Excel-mangled IDs, or is too large to open.

Try asking

  • This vendor CSV shows garbled characters and the columns are shifted. Clean it up
  • Half the dates in this file are DD/MM and half are MM/DD. Sort it out
  • Dedupe this 6 GB customer export and give me a clean Parquet file

Use it when

  • A CSV or TSV fails to load or loads with wrong columns
  • Text shows garbled characters or a BOM in the first header
  • Dates, decimals, or IDs are in inconsistent or locale-specific formats
  • A file is too big to open in a spreadsheet

Skip it when

  • The data is already clean and you want insights from it — use pandas-analysis instead
  • You need recurring automated validation in a pipeline — use data-quality-checks instead

How to check the result

The profiler reports no structural problems on the output, every parsed column has zero unexplained unparseable values, and the cleaning log row counts add up from input to output plus rejects.

The common mistake it prevents

Opening and re-saving the file in Excel to fix it, which strips leading zeros, converts long IDs to scientific notation and rewrites dates.

~/.claude/skills/csv-cleaning/SKILL.md

--- name: csv-cleaning description: Cleans messy CSV and TSV files — detects encodings, BOMs, delimiters, ragged rows, and header problems with a bundled profiler, then… license: MIT ---

A CSV is clean when it decodes as UTF-8, every row has the header’s field count, each column has one deliberate type (dates and numbers parsed with explicit formats), the rules for nulls and duplicates are written down, and a log shows how many rows each step changed. The original file is never modified.

Ground rules

  • Copy the raw file to data/raw/ (read-only) and write results elsewhere. Never edit in place.
  • Profile before parsing: encoding, delimiter, row shape, per-column patterns.
  • Read everything as text first; convert types explicitly, column by column.
  • Never guess ambiguous dates (03/04/2026). Confirm the format from the source, documentation, or a value where the day exceeds 12.
  • Every row dropped or changed is counted and the reason logged; rejected rows go to a rejects file, not the void.
  • Never open-and-save the file in Excel as a “fix”; it destroys leading zeros, long IDs, and dates.

Workflow

- [ ] 1. Preserve the raw file; record size, row count, checksum
- [ ] 2. Profile with the bundled script
- [ ] 3. Fix structure: encoding, delimiter, quoting, header, ragged rows
- [ ] 4. Parse types explicitly (dates, numbers, booleans, nulls)
- [ ] 5. Normalize text and categorical values
- [ ] 6. Deduplicate by a written rule
- [ ] 7. Validate and write output + cleaning log + rejects

1–2. Preserve and profile

mkdir -p data/raw data/clean
cp export.csv data/raw/ && chmod a-w data/raw/export.csv
shasum -a 256 data/raw/export.csv; wc -l data/raw/export.csv; file data/raw/export.csv
python3 scripts/csv_profile.py data/raw/export.csv            # add --max-rows 500000 for a fast first look

The profiler reports encoding and BOM, line endings, the delimiter that gives the most consistent field count, ragged rows with line numbers, exact duplicates, and per column: null share, distinct count, inferred types, ambiguous date formats, decimal commas, thousands separators, whitespace issues, and candidate keys. It exits 1 on structural problems.

3. Fix structure

ProblemFix
Garbled é / ’ (UTF-8 read as cp1252)Read with encoding="utf-8"; if text was double-encoded, repair with ftfy or re-export
UnicodeDecodeErrorTry cp1252 (Windows/Excel exports), then latin-1; convert once: iconv -f CP1252 -t UTF-8 in.csv > out.csv
BOM in first header (id)encoding="utf-8-sig"
Semicolon delimiter (European Excel)sep=";" plus decimal=","
Ragged rowsFind the line numbers; usually unquoted delimiters or embedded newlines. Fix quoting at the source, or route bad lines to rejects
Junk rows above header / totals at bottomskiprows=, skipfooter= (python engine) or filter after load
Duplicate or blank header namesRename explicitly before loading further
Mixed line endings / stray \rOpen with newline="" (csv module) and strip \r from values

pandas, reading defensively:

import pandas as pd

rejects = []
def keep_bad(line: list[str]) -> None:
    rejects.append(line)
    return None                                # drop from main frame, keep for review

df = pd.read_csv(
    "data/raw/export.csv",
    sep=";", encoding="utf-8-sig", dtype=str, keep_default_na=False,
    na_values=["", "NA", "N/A", "null", "-"],
    on_bad_lines=keep_bad, engine="python",
)
print(len(df), "rows;", len(rejects), "rejected lines")

DuckDB for large files, with a rejects table:

CREATE TABLE raw AS
SELECT * FROM read_csv('data/raw/export.csv',
    delim = ';', header = true, all_varchar = true,
    store_rejects = true, rejects_table = 'csv_rejects');
SELECT count(*) FROM raw;
SELECT line, error_type, csv_line FROM csv_rejects LIMIT 20;

4. Parse types explicitly

def parse_eu_number(s: pd.Series) -> pd.Series:
    cleaned = s.str.replace(r"[€$£\s]", "", regex=True).str.replace(".", "", regex=False).str.replace(",", ".", regex=False)
    return pd.to_numeric(cleaned, errors="coerce")

out = pd.DataFrame({
    "customer_id": df["customer_id"].str.strip(),                         # keep as string: leading zeros
    "amount": parse_eu_number(df["amount"]),
    "order_date": pd.to_datetime(df["order_date"], format="%d/%m/%Y", errors="coerce"),
    "active": df["active"].str.strip().str.lower().map({"yes": True, "y": True, "true": True, "1": True,
                                                        "no": False, "n": False, "false": False, "0": False}),
})
for col in ["amount", "order_date", "active"]:
    failed = out[col].isna() & df[col].ne("")
    print(col, "unparseable:", int(failed.sum()), df.loc[failed, col].head(5).tolist())
Column kindRule
IDs, ZIP/postal codes, phone numbersAlways strings; never numeric (leading zeros, long IDs lose precision)
Datesformat= always; if two formats are mixed, parse each pattern separately and combine
Excel serial dates (e.g. 45567)pd.to_datetime(x, unit="D", origin="1899-12-30")
MoneyDecimal or integer cents; strip currency symbols; know the decimal separator
PercentStrip %, divide by 100, state it
BooleansExplicit mapping; unmapped values become null and are counted
Timestamps with zonesParse with offset, convert to UTC, keep original zone column if needed

After parsing, every column’s “unparseable” count must be zero or explained.

5. Normalize text

import unicodedata
def norm_text(s: pd.Series) -> pd.Series:
    return (s.fillna("")
             .map(lambda v: unicodedata.normalize("NFC", v))
             .str.replace(r"\s+", " ", regex=True).str.strip())

out["country"] = norm_text(df["country"]).str.upper().replace({"UK": "GB", "U.S.": "US", "USA": "US"})
out["email"] = norm_text(df["email"]).str.lower()

Map categorical variants with an explicit dictionary and print value_counts() of anything left unmapped.

6. Deduplicate by rule

  1. Exact duplicates: drop, count.
  2. Key duplicates (same order_id, different values): decide the rule with the user — latest by updated_at, most complete row, or flag for review. Write it in the log.
before = len(out)
out = out.drop_duplicates()
exact = before - len(out)
out = out.sort_values("updated_at").drop_duplicates(subset=["order_id"], keep="last")
by_key = before - exact - len(out)

7. Validate and write

assert out["order_id"].is_unique
assert out["amount"].dropna().ge(0).all()
assert out["order_date"].dropna().between("2020-01-01", pd.Timestamp.today()).all()

out.to_parquet("data/clean/orders.parquet", index=False)
out.to_csv("data/clean/orders.csv", index=False, encoding="utf-8", date_format="%Y-%m-%d")
pd.DataFrame(rejects).to_csv("data/clean/orders_rejects.csv", index=False, header=False)

When the output will be opened in a spreadsheet, neutralize formula injection: prefix cells beginning with =, +, -, @, tab, or carriage return with '.

CLI helpers (if installed)

ToolUse
iconvConvert encodings
file -I (macOS) / file -i (Linux)Charset hint
qsv / xsvFast stats, select, dedupe, split on huge files
mlr (Miller)Format conversion and field transforms in streams
csvkit (csvclean, csvstat)Quick validation and stats
DuckDB CLISQL over CSV of any size, with reject tables

Gotchas

  • wc -l counts physical lines; quoted fields with newlines make it disagree with the row count.
  • pd.read_csv default NA list turns the string "NA" (Namibia, North America) into null; use keep_default_na=False and an explicit na_values.
  • Sniffers misdetect delimiters in files with decimal commas; confirm with field counts.
  • Excel turns 1-2 into a date, 00123 into 123, and 16+ digit IDs into 1.23E+15. The damage is in the file once saved; get a fresh export.
  • Trailing delimiters create an empty unnamed last column.
  • Sorting or deduping before fixing whitespace and case leaves near-duplicates.

Output

**Input:** data/raw/export.csv (cp1252, ';' delimited, CRLF, 412,907 lines, sha256 3f2a...)
**Cleaning log:**
| Step | Rows in | Rows out | Note |
|---|---|---|---|
| Load (ragged lines to rejects) | 412,906 | 412,871 | 35 lines with unquoted ';' in address |
| Exact duplicates | 412,871 | 410,002 | 2,869 removed |
| Duplicate order_id (keep latest updated_at) | 410,002 | 409,950 | 52 removed |
| Unparseable dates | 409,950 | 409,950 | 0 (format %d/%m/%Y confirmed with vendor) |
**Output:** data/clean/orders.parquet, data/clean/orders.csv (UTF-8), data/clean/orders_rejects.csv

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/csv_profile.py 248 lines · 9.7 KB
#!/usr/bin/env python3
"""Profile a messy CSV/TSV before cleaning it (stdlib only, streams large files).

Usage:
  python3 scripts/csv_profile.py data.csv
  python3 scripts/csv_profile.py data.csv --delimiter ';' --encoding cp1252 --max-rows 500000

Reports: encoding guess and BOM, line endings, delimiter, header issues, ragged rows
(with line numbers), exact duplicate rows, and per column: empty share, distinct count,
inferred type, ambiguous date formats, whitespace problems, and sample values.
Exit code 1 if structural problems were found (undecodable bytes, ragged rows,
duplicate/empty headers), 0 otherwise, 2 on usage errors.
"""
import argparse
import csv
import hashlib
import re
import sys
from collections import Counter

csv.field_size_limit(min(sys.maxsize, 2**31 - 1))

NULL_TOKENS = {"", "na", "n/a", "null", "none", "nan", "-", "--", "?"}
PATTERNS = [
    ("int", re.compile(r"^[+-]?\d+$")),
    ("decimal", re.compile(r"^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$")),
    ("decimal_comma", re.compile(r"^[+-]?\d{1,3}(\.\d{3})*,\d+$|^[+-]?\d+,\d+$")),
    ("thousands_sep", re.compile(r"^[+-]?\d{1,3}(,\d{3})+(\.\d+)?$")),
    ("date_iso", re.compile(r"^\d{4}-\d{2}-\d{2}$")),
    ("datetime_iso", re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$")),
    ("date_slash", re.compile(r"^\d{1,2}/\d{1,2}/\d{2,4}$")),
    ("date_dot", re.compile(r"^\d{1,2}\.\d{1,2}\.\d{2,4}$")),
    ("bool", re.compile(r"^(true|false|yes|no|y|n|t|f)$", re.I)),
    ("currency", re.compile(r"^[$€£¥]\s?[\d.,]+$|^[\d.,]+\s?[$€£¥]$")),
    ("percent", re.compile(r"^[+-]?[\d.,]+\s?%$")),
]


def detect_encoding(path, sample_bytes=2_000_000):
    with open(path, "rb") as fh:
        raw = fh.read(sample_bytes)
    bom = None
    if raw.startswith(b"\xef\xbb\xbf"):
        bom = "UTF-8 BOM"
    elif raw.startswith((b"\xff\xfe", b"\xfe\xff")):
        bom = "UTF-16 BOM"
        return "utf-16", bom, raw
    for enc in ("utf-8-sig", "cp1252", "latin-1"):
        try:
            raw.decode(enc)
            return enc, bom, raw
        except UnicodeDecodeError:
            continue
    return "latin-1", bom, raw


def line_endings(raw):
    crlf = raw.count(b"\r\n")
    lf = raw.count(b"\n") - crlf
    cr = raw.count(b"\r") - crlf
    kinds = [k for k, n in (("CRLF", crlf), ("LF", lf), ("CR", cr)) if n]
    return "+".join(kinds) or "none", len(kinds) > 1


def guess_delimiter(text, candidates=",;\t|", max_lines=200):
    """Pick the delimiter that yields the most consistent field count (>1) across lines."""
    lines = text.splitlines()[:max_lines]
    if len(lines) > 1:
        lines = lines[:-1]  # last sampled line may be truncated
    best, best_score = ",", (-1.0, 0)
    for d in candidates:
        counts = Counter(len(r) for r in csv.reader(lines, delimiter=d) if r)
        if not counts:
            continue
        mode, freq = counts.most_common(1)[0]
        if mode < 2:
            continue
        score = (freq / sum(counts.values()), mode)
        if score > best_score:
            best, best_score = d, score
    return best


def classify(value):
    for name, pat in PATTERNS:
        if pat.match(value):
            return name
    return "text"


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("path")
    ap.add_argument("--delimiter")
    ap.add_argument("--encoding")
    ap.add_argument("--max-rows", type=int, default=0, help="stop after N data rows (0 = all)")
    ap.add_argument("--samples", type=int, default=5)
    a = ap.parse_args()

    try:
        enc_guess, bom, raw = detect_encoding(a.path)
    except OSError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    encoding = a.encoding or enc_guess
    endings, mixed_endings = line_endings(raw)

    sample_text = raw.decode(encoding, errors="replace")[:200_000]
    delimiter = a.delimiter or guess_delimiter(sample_text)
    problems = []

    print(f"file: {a.path}")
    print(f"encoding: {encoding}{' (forced)' if a.encoding else ' (guessed)'}" + (f", {bom}" if bom else ""))
    print(f"line endings: {endings}" + ("  <-- MIXED" if mixed_endings else ""))
    print(f"delimiter: {delimiter!r}")

    decode_errors = 0
    with open(a.path, "r", encoding=encoding, errors="strict" if encoding != "latin-1" else "replace", newline="") as fh:
        def safe_lines(f):
            nonlocal decode_errors
            while True:
                try:
                    line = f.readline()
                except UnicodeDecodeError:
                    decode_errors += 1
                    return
                if not line:
                    return
                yield line

        reader = csv.reader(safe_lines(fh), delimiter=delimiter)
        try:
            header = next(reader)
        except StopIteration:
            print("error: empty file", file=sys.stderr)
            return 1
        header = [h.strip() for h in header]
        dupes = [h for h, n in Counter(header).items() if n > 1]
        empties = [i for i, h in enumerate(header) if not h]
        print(f"columns ({len(header)}): {header}")
        if dupes:
            problems.append(f"duplicate header names: {dupes}")
        if empties:
            problems.append(f"empty header names at positions {empties}")
        if any(re.match(r"^\d+(\.\d+)?$", h) for h in header if h):
            problems.append("numeric header names: file may have no header row")

        ncol = len(header)
        stats = [
            {"empty": 0, "types": Counter(), "distinct": set(), "overflow": False, "ws": 0,
             "maxlen": 0, "samples": [], "dmy_hint": 0, "mdy_hint": 0}
            for _ in header
        ]
        ragged = Counter()
        ragged_lines = []
        seen_hashes = set()
        dup_rows = 0
        rows = 0
        for row in reader:
            if a.max_rows and rows >= a.max_rows:
                break
            rows += 1
            if len(row) != ncol:
                ragged[len(row)] += 1
                if len(ragged_lines) < 10:
                    ragged_lines.append((reader.line_num, len(row)))
            h = hashlib.blake2b("\x1f".join(row).encode("utf-8", "replace"), digest_size=12).digest()
            if h in seen_hashes:
                dup_rows += 1
            else:
                seen_hashes.add(h)
            for i, v in enumerate(row[:ncol]):
                s = stats[i]
                if v != v.strip():
                    s["ws"] += 1
                vs = v.strip()
                s["maxlen"] = max(s["maxlen"], len(vs))
                if vs.lower() in NULL_TOKENS:
                    s["empty"] += 1
                    continue
                t = classify(vs)
                s["types"][t] += 1
                if t in ("date_slash", "date_dot"):
                    first, second = re.split(r"[/.]", vs)[:2]
                    if int(first) > 12:
                        s["dmy_hint"] += 1
                    elif int(second) > 12:
                        s["mdy_hint"] += 1
                if not s["overflow"]:
                    s["distinct"].add(vs)
                    if len(s["distinct"]) > 100_000:
                        s["overflow"] = True
                if len(s["samples"]) < a.samples and vs not in s["samples"]:
                    s["samples"].append(vs)

    print(f"data rows: {rows:,}" + (" (truncated by --max-rows)" if a.max_rows and rows >= a.max_rows else ""))
    if decode_errors:
        problems.append(f"undecodable bytes with encoding {encoding}; try --encoding cp1252 or latin-1")
    if ragged:
        detail = ", ".join(f"{n:,} rows with {k} fields" for k, n in sorted(ragged.items()))
        problems.append(f"ragged rows (expected {ncol} fields): {detail}; first at lines {ragged_lines}")
    print(f"exact duplicate rows: {dup_rows:,}")
    print()

    for name, s in zip(header, stats):
        filled = rows - s["empty"]
        total_typed = sum(s["types"].values())
        top = s["types"].most_common(3)
        type_str = ", ".join(f"{t} {n / total_typed:.0%}" for t, n in top) if total_typed else "all empty"
        distinct = ">100k" if s["overflow"] else f"{len(s['distinct']):,}"
        print(f"[{name}]")
        print(f"  empty/null: {s['empty']:,} ({(s['empty'] / rows if rows else 0):.1%})  distinct: {distinct}  max length: {s['maxlen']}")
        print(f"  types: {type_str}")
        notes = []
        if len(s["types"]) > 1 and top and top[0][1] / max(total_typed, 1) < 0.99:
            notes.append("mixed types")
        if s["ws"]:
            notes.append(f"{s['ws']:,} values with leading/trailing whitespace")
        if s["dmy_hint"] and s["mdy_hint"]:
            notes.append("dates match both DD/MM and MM/DD: inconsistent formats")
        elif s["dmy_hint"]:
            notes.append("dates look DD/MM/YYYY")
        elif s["mdy_hint"]:
            notes.append("dates look MM/DD/YYYY")
        elif s["types"].get("date_slash") or s["types"].get("date_dot"):
            notes.append("slash/dot dates AMBIGUOUS (no day > 12 seen): confirm format with the source")
        if s["types"].get("decimal_comma"):
            notes.append("decimal commas present (European number format)")
        if s["types"].get("thousands_sep"):
            notes.append("thousands separators present")
        if not s["overflow"] and filled and len(s["distinct"]) == filled and filled == rows:
            notes.append("unique and complete: candidate key")
        if notes:
            print(f"  notes: {'; '.join(notes)}")
        print(f"  samples: {s['samples']}")

    print()
    if problems:
        print("STRUCTURAL PROBLEMS:")
        for p in problems:
            print(f"  - {p}")
        return 1
    print("No structural problems found.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc