CSV Processing skill for Codex
Big, messy CSVs cleaned without losing a digit. Codex profiles a CSV before touching it, using a bundled standard-library script that detects encoding, delimiter, ragged rows, leading-zero IDs, locale numbers and ambiguous dates. It then picks DuckDB, polars, pandas or the csv module by size, declares types explicitly, captures rejected rows instead of dropping them, and reconciles row counts and totals at the end.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- csv-processing Installs to ~/.agents/skills/csv-processing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install csv-processing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project csv-processing Installs to .agents/skills/csv-processing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/csv-processing.zipunzip -o csv-processing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) csv-processing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $csv-processing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Profiles, cleans, splits, deduplicates and converts large or messy CSV and TSV files with DuckDB, polars, pandas and the Python csv module - detects encoding, delimiter and ragged rows, keeps leading-zero IDs intact, parses locale numbers and dates explicitly, and streams files bigger than memory to Parquet, JSON Lines or chunks. Use when the user hands over a CSV that will not load, is too big for Excel or pandas, needs cleaning before import, or must be split, merged or converted.
Use it when
- A CSV fails to load, shows garbled characters or has misaligned columns
- The file is too large for Excel or for pandas in memory
- Data must be cleaned or deduplicated before an import
- A CSV needs splitting, merging or converting to Parquet or JSON Lines
Use something else when
- The output must be a formatted Excel workbook: use xlsx-spreadsheets
- You are designing a recurring ingestion pipeline rather than handling a file: use etl-pipeline-design
How to check the result
Input rows must equal output rows plus rejected and deliberately removed rows, one numeric column total must match on both sides, and a re-profile of the output must show no ragged rows.
The mistake it prevents
Letting type inference read ZIP codes, SKUs or account numbers as integers, which strips leading zeros and rounds long digits.
Done well means: the file was profiled before it was transformed, no values were silently changed (IDs keep leading zeros, 18-digit numbers keep every digit, dates keep their meaning), every rejected row is accounted for, and output row counts reconcile with input row counts.
Workflow
- [ ] 1. Profile: python3 scripts/csv_profile.py data.csv
- [ ] 2. Decide types explicitly (IDs/ZIPs as text, money as decimal, dates with a format)
- [ ] 3. Pick the engine by size (table below)
- [ ] 4. Transform with rejects captured, never dropped silently
- [ ] 5. Write output; reconcile counts and one numeric total
- [ ] 6. Report what changed, what was rejected and why
scripts/csv_profile.py is standard-library only (works with the network off). It reports
encoding, delimiter, line endings, per-column type, nulls, distinct counts and sample
values, and flags ragged rows, duplicate headers, leading zeros, >15-digit integers,
decimal commas, ambiguous slash dates and formula-injection text. It exits 1 on structural
problems. Add --limit 200000 for a quick look at huge files.
Pick the engine
| Size / need | Engine | Why |
|---|---|---|
| Under ~1 GB, already a pandas project | pandas with explicit dtype | Familiar; fine at this size |
| Any size, SQL-shaped work (filter, join, group, dedupe) | DuckDB | Streams from disk, parallel, great CSV sniffer, rejects table |
| Any size, dataframe-style pipelines | polars scan_csv (lazy) | Streaming engine, low memory |
| No dependencies allowed, simple row logic | csv module, streaming | Always available |
| One-off shell inspection | qsv, mlr (Miller), csvkit if installed | Fast CLI; check availability first |
Read AGENTS.md for the project’s stack. Installing DuckDB or polars needs network; ask
for approval in a sandbox, or fall back to the csv module.
Reading correctly
import pandas as pd
df = pd.read_csv(
"orders.csv",
sep=";",
encoding="utf-8-sig", # strips an Excel BOM if present
dtype={"order_id": "string", "zip": "string", "sku": "string"},
decimal=",", thousands=".", # only when the profile showed locale numbers
na_values=["", "NA", "n/a"], keep_default_na=False,
parse_dates=["order_date"], date_format="%d.%m.%Y",
)
-- DuckDB: explicit options, rejects captured instead of failing or dropping
CREATE TABLE orders AS
SELECT * FROM read_csv('orders.csv',
delim = ';', header = true, decimal_separator = ',', thousands = '.',
dateformat = '%d.%m.%Y', nullstr = ['', 'NA'],
types = {'order_id': 'VARCHAR', 'zip': 'VARCHAR', 'net': 'DECIMAL(18,2)'},
store_rejects = true);
SELECT line, column_name, error_type, csv_line FROM reject_errors LIMIT 20;
import polars as pl
lf = pl.scan_csv("orders.csv", separator=";", schema_overrides={"zip": pl.String},
infer_schema_length=50_000, null_values=["", "NA"])
(lf.filter(pl.col("status") == "shipped")
.group_by("region").agg(pl.col("net").sum())
.collect(engine="streaming"))
Rules:
- Read identifier-like columns as strings: order IDs, ZIP/postcodes, phone numbers, SKUs, account numbers. Numeric inference drops leading zeros and rounds long digits.
- Money: parse to
Decimalor integer cents; do not sum floats for anything that must reconcile to the cent. DuckDB:DECIMAL(18,2). - Dates: pass a format.
03/04/2025is March or April depending on the source; ask if the profile flags it and nothing else disambiguates (a day value above 12 settles it). - Mixed-type column warning in pandas (
DtypeWarning) means inference differed between chunks: set the dtype explicitly instead oflow_memory=False.
Cleaning recipe
import re
def snake(name: str) -> str:
name = re.sub(r"[^0-9a-zA-Z]+", "_", name.strip()).strip("_").lower()
return name or "unnamed"
df.columns = [snake(c) for c in df.columns]
text_cols = df.select_dtypes(include=["string", "object"]).columns
df[text_cols] = df[text_cols].apply(lambda s: s.str.strip().str.replace(" ", " ", regex=False))
before = len(df)
df = df.drop_duplicates(subset=["order_id"], keep="last")
print(f"dedupe removed {before - len(df)} rows")
Keep a raw copy; write the cleaned file to a new path. Log every rule applied with the number of rows it touched.
Splitting and converting
Stream; never load a 20 GB file to split it.
import csv
from pathlib import Path
def split_csv(src: str, rows_per_file: int, out_dir: str = "parts",
delimiter: str = ",", encoding: str = "utf-8-sig") -> int:
Path(out_dir).mkdir(exist_ok=True)
with open(src, newline="", encoding=encoding) as fh:
reader = csv.reader(fh, delimiter=delimiter)
header = next(reader)
part, out, writer, n = 0, None, None, 0
for n, row in enumerate(reader):
if n % rows_per_file == 0:
if out:
out.close()
part += 1
out = open(Path(out_dir) / f"part-{part:04d}.csv", "w", newline="", encoding="utf-8")
writer = csv.writer(out, delimiter=delimiter)
writer.writerow(header)
writer.writerow(row)
if out:
out.close()
return part
This counts records, not lines, so quoted fields with embedded newlines stay intact
(split -l and wc -l get those wrong).
-- DuckDB conversions
COPY (FROM read_csv('orders.csv')) TO 'orders.parquet' (FORMAT parquet, COMPRESSION zstd);
COPY (FROM read_csv('orders.csv')) TO 'by_region' (FORMAT parquet, PARTITION_BY (region));
COPY (FROM read_csv('orders.csv')) TO 'orders.jsonl' (FORMAT json);
COPY (SELECT * FROM orders WHERE region = 'EU') TO 'eu.csv' (HEADER, DELIMITER ',');
Converting to Excel: max 1,048,576 rows per sheet; for anything formatted, use
$xlsx-spreadsheets.
Writing CSV for other people
- Encoding:
utf-8for systems;utf-8-sig(BOM) when the consumer is Excel on Windows, otherwise accented characters show as mojibake. - Always
newline=""with thecsvmodule, or Windows output gets blank lines between rows. - Quote minimally (
csv.QUOTE_MINIMAL) and never hand-join with",". - Values starting with
=,+,-,@from user input can execute as formulas when opened in a spreadsheet. For exports meant for spreadsheets, prefix such text with a single quote or a leading space, and document it. - Excel will still reformat IDs and dates on open. If the user must open it in Excel and
keep IDs, deliver
.xlsxwith text-typed columns instead.
Gotchas
cp1252versuslatin-1: Windows exports with curly quotes or the euro sign are cp1252; decoding as latin-1 “works” but produces wrong characters.- A trailing delimiter on every line creates an empty extra column; drop it after checking it is empty for all rows.
- Header repeated mid-file (concatenated exports): filter rows equal to the header.
- Huge single fields (embedded JSON, base64) exceed the
csvmodule’s default field limit; raise it withcsv.field_size_limit. - Sorting or deduplicating a file bigger than memory: use DuckDB or polars, not pandas.
Verification
- Input data rows = output rows + rejected rows + documented removals (dedupe, filters).
- One numeric column’s total matches between input and output (use Decimal or DuckDB).
- Re-run
csv_profile.pyon the output: no ragged rows, ID columns still have their leading zeros, types as intended. - For splits: sum of part row counts equals the source count, and every part has the header.
Report
**Input:** orders.csv - cp1252, ';' delimited, 2,418,331 data rows, 14 columns
**Output:** orders.parquet (zstd) + rejects.csv (37 rows: wrong field count)
**Types:** order_id, zip as text; net as DECIMAL(18,2); order_date parsed as %d.%m.%Y
**Cleaning:** trimmed whitespace (61,204 cells), removed 912 duplicate order_ids (kept latest)
**Reconciled:** 2,418,331 = 2,417,382 + 37 + 912; sum(net) 18,204,551.20 both sides What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "CSV Processing"
short_description: "Big, messy CSVs cleaned without losing a digit"
default_prompt: "$csv-processing This 8 GB CSV crashes pandas - filter it to EU orders and save as Parquet" scripts/csv_profile.py
#!/usr/bin/env python3
"""Profile a CSV/TSV file with the standard library only, streaming row by row.
Detects encoding (BOM, UTF-8, falls back to cp1252), delimiter, quoting and line
endings, then reports per column: inferred type, null-ish count, distinct estimate,
max length and sample values. Flags ragged rows, duplicate or blank headers, IDs with
leading zeros, long digit strings Excel would corrupt, decimal commas, mixed date
formats, and values starting with = + - @ (spreadsheet formula injection).
Usage:
python3 csv_profile.py data.csv
python3 csv_profile.py data.csv --limit 200000 # profile only the first N rows
python3 csv_profile.py data.csv --delimiter ';' --encoding latin-1
Exit codes: 0 clean, 1 structural problems (ragged rows, bad header), 2 unreadable.
"""
from __future__ import annotations
import argparse
import codecs
import csv
import re
import sys
from collections import Counter
NULLS = {"", "na", "n/a", "null", "none", "nan", "-", "--", "#n/a", "nil"}
INT = re.compile(r"^[+-]?\d+$")
FLOAT = re.compile(r"^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$")
THOUSANDS = re.compile(r"^[+-]?\d{1,3}(,\d{3})+(\.\d+)?$")
DEC_COMMA = re.compile(r"^[+-]?\d+,\d+$")
DATE_PATTERNS = {
"iso": re.compile(r"^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$"),
"slash": re.compile(r"^\d{1,2}/\d{1,2}/\d{4}$"),
"dotted": re.compile(r"^\d{1,2}\.\d{1,2}\.\d{4}$"),
}
BOOL = {"true", "false", "yes", "no"}
csv.field_size_limit(min(sys.maxsize, 2**31 - 1))
def detect_encoding(path: str, forced: str | None) -> str:
if forced:
return forced
with open(path, "rb") as fh:
head = fh.read(4)
if head.startswith(codecs.BOM_UTF8):
return "utf-8-sig"
if head.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
return "utf-16"
fh.seek(0)
dec = codecs.getincrementaldecoder("utf-8")()
try:
while chunk := fh.read(1 << 20):
dec.decode(chunk)
dec.decode(b"", final=True)
return "utf-8"
except UnicodeDecodeError:
return "cp1252"
def classify(v: str) -> str:
s = v.strip()
if s.lower() in NULLS:
return "null"
if INT.match(s):
return "int"
if FLOAT.match(s):
return "float"
if THOUSANDS.match(s):
return "number_with_thousands_sep"
if DEC_COMMA.match(s):
return "decimal_comma"
for name, pat in DATE_PATTERNS.items():
if pat.match(s):
return f"date:{name}"
if s.lower() in BOOL:
return "bool"
return "text"
def main() -> int:
ap = argparse.ArgumentParser(description="Profile a CSV file (stdlib only).")
ap.add_argument("path")
ap.add_argument("--limit", type=int, default=0, help="max data rows to read (0 = all)")
ap.add_argument("--delimiter")
ap.add_argument("--encoding")
a = ap.parse_args()
try:
enc = detect_encoding(a.path, a.encoding)
with open(a.path, "rb") as fh:
raw = fh.read(65536)
crlf = raw.count(b"\r\n")
lf = raw.count(b"\n") - crlf
sample = raw.decode(enc.replace("-sig", ""), errors="replace").lstrip("")
if a.delimiter:
delim = a.delimiter.encode().decode("unicode_escape")
else:
try:
delim = csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
except csv.Error:
delim = ","
fh = open(a.path, newline="", encoding=enc, errors="replace")
except OSError as exc:
print(f"ERROR: {exc}")
return 2
problems: list[str] = []
notes: list[str] = []
with fh:
reader = csv.reader(fh, delimiter=delim)
try:
header = next(reader)
except StopIteration:
print("ERROR: file is empty")
return 2
width = len(header)
dupes = [h for h, n in Counter(header).items() if n > 1]
blanks = [i for i, h in enumerate(header) if not h.strip()]
if dupes:
problems.append(f"duplicate header names: {dupes}")
if blanks:
problems.append(f"blank header names at column index {blanks}")
if any(h != h.strip() for h in header):
notes.append("some header names have leading/trailing spaces")
types = [Counter() for _ in header]
distinct = [set() for _ in header]
maxlen = [0] * width
samples = [[] for _ in header]
lead_zero = [0] * width
long_digits = [0] * width
inject = [0] * width
ragged = Counter()
ragged_first = []
rows = 0
for row in reader:
rows += 1
if len(row) != width:
ragged[len(row)] += 1
if len(ragged_first) < 5:
ragged_first.append(reader.line_num)
for i, v in enumerate(row[:width]):
t = classify(v)
types[i][t] += 1
if len(distinct[i]) < 10001:
distinct[i].add(v)
maxlen[i] = max(maxlen[i], len(v))
if t != "null" and len(samples[i]) < 3 and v not in samples[i]:
samples[i].append(v)
s = v.strip()
if t == "int" and len(s) > 1 and s[0] == "0":
lead_zero[i] += 1
if t == "int" and len(s.lstrip("+-")) > 15:
long_digits[i] += 1
if t == "text" and s[:1] in ("=", "+", "-", "@"):
inject[i] += 1
if a.limit and rows >= a.limit:
notes.append(f"stopped after --limit {a.limit} rows")
break
ending = "CRLF" if crlf > lf else "LF"
print(f"File: {a.path}")
print(f"Encoding: {enc} Delimiter: {delim!r} Line endings: {ending} Columns: {width} Data rows: {rows}")
if ragged:
problems.append(
f"{sum(ragged.values())} rows with wrong field count {dict(ragged)} "
f"(expected {width}); first at physical lines {ragged_first}"
)
print(f"\n{'column':28} {'type':26} {'nulls':>7} {'distinct':>9} {'maxlen':>7} samples")
for i, name in enumerate(header):
c = types[i]
nulls = c.pop("null", 0)
if not c:
kind = "empty"
elif len(c) == 1:
kind = next(iter(c))
elif set(c) <= {"int", "float"}:
kind = "float"
else:
top = c.most_common(2)
kind = f"mixed({top[0][0]}>{top[1][0]})"
d = len(distinct[i])
dtxt = ">10000" if d > 10000 else str(d)
print(f"{name[:28]:28} {kind[:26]:26} {nulls:>7} {dtxt:>9} {maxlen[i]:>7} {samples[i]}")
if lead_zero[i]:
notes.append(f"'{name}': {lead_zero[i]} values with leading zeros - keep as text (IDs, ZIP codes)")
if long_digits[i]:
notes.append(f"'{name}': {long_digits[i]} integers longer than 15 digits - Excel will round them")
if inject[i]:
notes.append(f"'{name}': {inject[i]} text values start with = + - @ - escape before opening in a spreadsheet")
if "decimal_comma" in c or "number_with_thousands_sep" in c:
notes.append(f"'{name}': locale-formatted numbers - parse explicitly, do not cast directly")
date_kinds = [k for k in c if k.startswith("date:")]
if len(date_kinds) > 1 or (date_kinds and len(c) > 1):
notes.append(f"'{name}': mixed date formats or dates mixed with other values {dict(c)}")
if "date:slash" in c:
notes.append(f"'{name}': slash dates (03/04/2025) are ambiguous: day-first or month-first - confirm with the source")
print("\nProblems:" if problems else "\nProblems: none")
for p in problems:
print(f" - {p}")
print("Notes:" if notes else "Notes: none")
for n in notes:
print(f" - {n}")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.