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.

  • Documents & Office
  • $csv-processing
  • 195 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- csv-processing

Installs to ~/.agents/skills/csv-processing/ for every project on this machine.

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

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.

~/.agents/skills/csv-processing/SKILL.md

--- name: csv-processing description: Profiles, cleans, splits, deduplicates and converts large or messy CSV and TSV files with DuckDB, polars, pandas and the Python csv module - detects… license: MIT ---

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 / needEngineWhy
Under ~1 GB, already a pandas projectpandas with explicit dtypeFamiliar; fine at this size
Any size, SQL-shaped work (filter, join, group, dedupe)DuckDBStreams from disk, parallel, great CSV sniffer, rejects table
Any size, dataframe-style pipelinespolars scan_csv (lazy)Streaming engine, low memory
No dependencies allowed, simple row logiccsv module, streamingAlways available
One-off shell inspectionqsv, mlr (Miller), csvkit if installedFast 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 Decimal or integer cents; do not sum floats for anything that must reconcile to the cent. DuckDB: DECIMAL(18,2).
  • Dates: pass a format. 03/04/2025 is 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 of low_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-8 for systems; utf-8-sig (BOM) when the consumer is Excel on Windows, otherwise accented characters show as mojibake.
  • Always newline="" with the csv module, 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 .xlsx with text-typed columns instead.

Gotchas

  • cp1252 versus latin-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 csv module’s default field limit; raise it with csv.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.py on 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.yamlgenerated from this page

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.py209 lines, 7.9 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem