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.
- name
csv-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.
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
| Problem | Fix |
|---|---|
Garbled é / ’ (UTF-8 read as cp1252) | Read with encoding="utf-8"; if text was double-encoded, repair with ftfy or re-export |
UnicodeDecodeError | Try 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 rows | Find 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 bottom | skiprows=, skipfooter= (python engine) or filter after load |
| Duplicate or blank header names | Rename explicitly before loading further |
Mixed line endings / stray \r | Open 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 kind | Rule |
|---|---|
| IDs, ZIP/postal codes, phone numbers | Always strings; never numeric (leading zeros, long IDs lose precision) |
| Dates | format= 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") |
| Money | Decimal or integer cents; strip currency symbols; know the decimal separator |
| Percent | Strip %, divide by 100, state it |
| Booleans | Explicit mapping; unmapped values become null and are counted |
| Timestamps with zones | Parse 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
- Exact duplicates: drop, count.
- Key duplicates (same
order_id, different values): decide the rule with the user — latest byupdated_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)
| Tool | Use |
|---|---|
iconv | Convert encodings |
file -I (macOS) / file -i (Linux) | Charset hint |
qsv / xsv | Fast stats, select, dedupe, split on huge files |
mlr (Miller) | Format conversion and field transforms in streams |
csvkit (csvclean, csvstat) | Quick validation and stats |
| DuckDB CLI | SQL over CSV of any size, with reject tables |
Gotchas
wc -lcounts physical lines; quoted fields with newlines make it disagree with the row count.pd.read_csvdefault NA list turns the string"NA"(Namibia, North America) into null; usekeep_default_na=Falseand an explicitna_values.- Sniffers misdetect delimiters in files with decimal commas; confirm with field counts.
- Excel turns
1-2into a date,00123into123, and 16+ digit IDs into1.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
#!/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.