CSV & Excel Data Ingest skill for Codex

Every row loads typed or is quarantined. Codex sniffs each incoming file with a bundled standard-library script, reads every column as text, then casts against an explicit schema so nothing is guessed. Rows that fail checks go to a quarantine with a reason, loads abort past a failure threshold, and file checksums make reloads idempotent. Works with pandas, Polars, DuckDB and openpyxl.

  • Data & Analytics
  • $csv-data-ingest
  • 192 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

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

Installs to ~/.agents/skills/csv-data-ingest/ 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-data-ingest, 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

Loads external CSV, TSV and Excel files into pipelines and warehouses without silent corruption - detects encoding, BOM, delimiter and ragged rows, reads everything as text first, applies an explicit schema with pandas, Polars, DuckDB or openpyxl, validates with pandera or plain assertions, quarantines bad rows, and keeps loads idempotent. Use when a partner or customer file must be imported regularly, a CSV breaks with UnicodeDecodeError or wrong column counts, leading zeros or dates get mangled, or Excel sheets must be ingested reliably.

Use it when

  • A recurring external CSV or Excel file must load into a pipeline or warehouse
  • Imports fail with UnicodeDecodeError or wrong column counts
  • Leading zeros, dates or decimals are mangled during import
  • Bad rows need to be captured and reported back to the provider

Use something else when

  • You only need a one-off reshape, merge or conversion of CSV files: use csv-processing
  • The data loaded fine but its values are messy: use data-cleaning

How to check the result

Rows read equal rows loaded plus quarantined, a second load of the same file changes nothing, and spot-checked rows match the raw file including accents and leading zeros.

The mistake it prevents

Letting the reader infer types, so ZIP codes lose leading zeros and the value NA for Namibia becomes a null.

~/.agents/skills/csv-data-ingest/SKILL.md

--- name: csv-data-ingest description: Loads external CSV, TSV and Excel files into pipelines and warehouses without silent corruption - detects encoding, BOM, delimiter and ragged rows,… license: MIT ---

A good ingest never guesses silently: the file’s encoding, delimiter and schema are declared, every row either loads with the declared types or lands in a quarantine table with a reason, the same file loaded twice produces the same result, and each load records its source, row counts and checksum.

Inspect the file before writing code

python3 scripts/sniff_csv.py data/incoming/partner_2025-10.csv
file -I data/incoming/partner_2025-10.csv        # macOS; GNU: file -i
head -c 600 data/incoming/partner_2025-10.csv | xxd | head   # BOM, CRLF, odd bytes
wc -l data/incoming/partner_2025-10.csv

scripts/sniff_csv.py (standard library only) reports encoding or BOM, line endings, delimiter, header detection, ragged rows with line numbers, and columns that must stay text (leading zeros, scientific-notation IDs, European decimals, null-like tokens). It exits 1 when it finds problems. Run it on every new source and on any file that fails.

Then read AGENTS.md for where raw files land, which library the pipeline uses, and the target (warehouse table, Parquet, Postgres). Ask the data provider, through the user, for a spec if none exists: column names, types, encoding, delimiter, date format, null token.

Decisions

QuestionDefaultEscape hatch
EncodingUTF-8 (utf-8-sig if a BOM is present)cp1252 for Windows exports with é failing as UTF-8; confirm with the source
Initial readAll columns as stringsTyped read only when the file has a trusted, stable spec
LibraryPolars or DuckDB for large files; pandas when the codebase uses itopenpyxl for cell-level Excel access
NullsExplicit list per source ("", "NULL")Never rely on library defaults that treat "NA" as null
Bad rowsQuarantine with reason, continueFail the whole load when quarantine exceeds a threshold (e.g. 1%)
Re-loadsReplace the partition or batch by file idNever blind-append

Reading as text, then casting

import pandas as pd

raw = pd.read_csv(
    path,
    dtype=str,
    encoding="utf-8-sig",
    sep=";",
    keep_default_na=False,          # "NA", "null" stay strings until we decide
    na_values=[""],
    on_bad_lines="warn",            # or a callable (engine="python") to capture bad rows
    skipinitialspace=True,
)
raw.columns = raw.columns.str.strip().str.lower().str.replace(r"\W+", "_", regex=True)
import polars as pl

raw = pl.read_csv(path, infer_schema=False, separator=";", encoding="utf8-lossy",
                  null_values=[""], truncate_ragged_lines=False)

infer_schema=False (older Polars: infer_schema_length=0) reads all columns as strings. utf8-lossy replaces bad bytes, so check for the replacement character afterwards; for non-UTF-8 files, transcode first (iconv -f CP1252 -t UTF-8).

-- DuckDB: inspect what it would infer, then load as text and cast deliberately
select * from sniff_csv('data/incoming/partner.csv');
create table stg as
select * from read_csv('data/incoming/partner.csv', all_varchar = true, header = true,
                       delim = ';', store_rejects = true);
select * from reject_errors;     -- rows DuckDB could not parse, with line and reason

Explicit schema and validation

import pandera.pandas as pa   # pandera >= 0.24; older: import pandera as pa

schema = pa.DataFrameSchema(
    {
        "customer_id": pa.Column(str, pa.Check.str_matches(r"^C\d{6}$"), unique=True),
        "zip": pa.Column(str, pa.Check.str_length(5, 5)),
        "amount": pa.Column(float, pa.Check.ge(0)),
        "order_date": pa.Column("datetime64[ns]"),
        "country": pa.Column(str, pa.Check.isin(["DE", "FR", "NL", "US"])),
    },
    strict=True,     # unexpected columns fail: providers add columns without warning
    coerce=False,
)

typed = raw.assign(
    amount=pd.to_numeric(raw["amount"].str.replace(".", "", regex=False).str.replace(",", ".", regex=False),
                         errors="coerce"),
    order_date=pd.to_datetime(raw["order_date"], format="%d.%m.%Y", errors="coerce"),
)
try:
    schema.validate(typed, lazy=True)
except pa.errors.SchemaErrors as err:
    failures = err.failure_cases      # column, check, index, failure_case

Without pandera (not installed, no network), write the same checks as boolean masks and collect failing indexes. Install needs network and possibly approval in the sandbox.

Quarantine pattern

bad_mask = typed["amount"].isna() & raw["amount"].ne("")         # present but unparseable
bad_mask |= typed["order_date"].isna()
bad_mask |= ~typed["customer_id"].str.match(r"^C\d{6}$", na=False)

quarantine = raw[bad_mask].assign(_reason="type or format check failed", _file=path.name)
good = typed[~bad_mask]

if len(quarantine) / max(len(raw), 1) > 0.01:
    raise RuntimeError(f"{len(quarantine)} of {len(raw)} rows failed checks; load aborted")

Store quarantined rows with the raw strings, file name, line number and reason so the provider can fix them.

Excel

sheets = pd.read_excel(path, sheet_name=None, dtype=str, engine="openpyxl", header=0)
for name, df in sheets.items():
    print(name, df.shape)
  • .xlsx needs openpyxl; .xls needs xlrd; .xlsb needs pyxlsb; Polars read_excel uses the calamine engine by default and is much faster on large sheets.
  • Real Excel dates come back as datetimes; dates typed as text stay text. Serial numbers (45567) convert with pd.to_datetime(x, unit="D", origin="1899-12-30").
  • Merged header cells, notes above the table and totals rows are common: locate the header row explicitly (header=3, skiprows, usecols="A:H") and drop totals by rule.
  • Formulas: openpyxl returns cached values only if the file was saved by Excel (load_workbook(path, data_only=True)); files generated by scripts may have none.

Idempotent loads

- [ ] Land the original file unchanged (object storage or data/raw/) with its sha256
- [ ] Record a load row: file name, sha256, received_at, rows read/loaded/quarantined
- [ ] Skip a file whose sha256 was already loaded successfully
- [ ] Load into staging, validate, then swap or merge into the target in one transaction
- [ ] Re-processing a corrected file replaces that file's rows (delete by _file_id, insert)

Deleting or replacing rows in a production table is destructive; confirm the target and scope with the user before the first run against shared data.

Gotchas

  • "NA", "NULL", "None" as legitimate values (country code Namibia, a surname) are nulled by pandas defaults. keep_default_na=False and an explicit list fix it.
  • Leading zeros in ZIP codes, phone numbers and account IDs disappear when read as numbers. Keep them as strings.
  • Embedded newlines inside quoted fields make wc -l disagree with parsed row counts; that is expected, ragged rows are not.
  • A trailing delimiter on every line creates an empty extra column; drop it explicitly.
  • Files exported “as CSV” from Excel on European locales use ; and , decimals.
  • Date formats 03/04/2025 are ambiguous; get the format from the spec, never infer it from the first rows.
  • Large files: stream (pd.read_csv(chunksize=...), Polars scan_csv, DuckDB) instead of loading 10 GB into memory.

Verification

  • rows_read == rows_loaded + rows_quarantined, recorded per file.
  • Spot-check five loaded rows against the raw file by line number, including one with accents and one with leading zeros.
  • Load the same file twice; target row count is unchanged the second time.
  • Column sums of key numeric fields match a control total from the provider when one is supplied.

Load report template

**File:** partner_2025-10.csv (sha256 3f9a...c21, 18.2 MB, received 2025-10-02)
**Detected:** cp1252, ';' delimiter, CRLF, header row; transcoded to UTF-8
**Rows:** read 241,330; loaded 240,912; quarantined 418 (amount unparseable 301, bad customer_id 117)
**Target:** staging.partner_orders -> raw.partner_orders (replaced file_id 2025-10)
**Checks:** amount sum 4,812,330.17 matches provider control total; second load idempotent
**Provider follow-up:** quarantine file data/quarantine/partner_2025-10.csv

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 & Excel Data Ingest"
  short_description: "Every row loads typed or is quarantined"
  default_prompt: "$csv-data-ingest Write a loader for the partner CSV we get every month - it keeps breaking on accents"
scripts/sniff_csv.py132 lines, 5.0 KB
#!/usr/bin/env python3
"""Inspect a delimited text file before loading it: encoding, BOM, line endings,
delimiter, header, ragged rows and columns that must stay text.

Usage: python3 sniff_csv.py <file> [--sample-bytes 2000000] [--encoding ENC]
Exit codes: 0 looks loadable, 1 problems found (ragged rows, undecodable bytes), 2 usage error.
"""
from __future__ import annotations

import argparse
import codecs
import collections
import csv
import io
import re
import sys

BOMS = [
    (codecs.BOM_UTF32_LE, "utf-32"), (codecs.BOM_UTF32_BE, "utf-32"),
    (codecs.BOM_UTF8, "utf-8-sig"), (codecs.BOM_UTF16_LE, "utf-16"), (codecs.BOM_UTF16_BE, "utf-16"),
]
CANDIDATES = ["utf-8", "cp1252", "latin-1"]
LEADING_ZERO = re.compile(r"^0\d+$")
SCI = re.compile(r"^\d(\.\d+)?E\+\d+$", re.I)
NULLISH = {"", "na", "n/a", "null", "none", "nan", "-", "#n/a"}


def detect_encoding(raw: bytes, forced: str | None) -> tuple[str, str]:
    if forced:
        return forced, "forced by --encoding"
    for bom, enc in BOMS:
        if raw.startswith(bom):
            return enc, f"BOM found ({enc})"
    for enc in CANDIDATES:
        try:
            raw.decode(enc)
        except UnicodeDecodeError:
            continue
        if enc == "utf-8":
            return enc, "valid UTF-8"
        if enc == "cp1252":
            return enc, "not valid UTF-8; decodes as cp1252 (Windows Western) - confirm with the source"
        return enc, "fallback latin-1 (every byte decodes; may be wrong)"
    return "latin-1", "fallback"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("path")
    ap.add_argument("--sample-bytes", type=int, default=2_000_000)
    ap.add_argument("--encoding")
    a = ap.parse_args()
    try:
        with open(a.path, "rb") as f:
            raw = f.read(a.sample_bytes)
    except OSError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if not raw:
        print("error: file is empty", file=sys.stderr)
        return 2
    problems = 0
    enc, why = detect_encoding(raw, a.encoding)
    # avoid cutting a multibyte char at the sample boundary
    text = raw.decode(enc, errors="replace")
    if len(raw) == a.sample_bytes:
        text = text.rsplit("\n", 1)[0]
    bad = text.count("�")
    print(f"encoding: {enc} ({why})")
    if bad:
        print(f"  WARNING: {bad} undecodable characters in sample")
        problems += 1
    if "Ã" in text and re.search(r"Ã[\x80-\xbf©¨¼¶]", text):
        print("  WARNING: looks like mojibake (UTF-8 bytes previously decoded as Latin-1)")
    crlf = text.count("\r\n")
    lf = text.count("\n") - crlf
    print(f"line endings: CRLF={crlf} LF={lf}")

    try:
        dialect = csv.Sniffer().sniff(text[:100_000], delimiters=",;\t|")
        delim = dialect.delimiter
    except csv.Error:
        delim = max(",;\t|", key=lambda d: text[:20_000].count(d))
        print("  sniffer failed; guessed delimiter by frequency")
    try:
        has_header = csv.Sniffer().has_header(text[:100_000])
    except (csv.Error, ValueError):
        has_header = True  # Sniffer could not decide; most exports have one
    print(f"delimiter: {delim!r}  header detected: {has_header}")

    rows = list(csv.reader(io.StringIO(text), delimiter=delim))
    if not rows:
        print("no rows parsed")
        return 1
    header = rows[0]
    body = rows[1:] if has_header else rows
    widths = collections.Counter(len(r) for r in body if r)
    expected = len(header)
    print(f"columns in first row: {expected}; data rows sampled: {len(body)}")
    print(f"row widths: {dict(widths.most_common(5))}")
    ragged = [i + 2 for i, r in enumerate(body) if r and len(r) != expected]
    if ragged:
        problems += 1
        print(f"  WARNING: {len(ragged)} ragged rows, first at lines {ragged[:5]} (unquoted delimiters or embedded newlines?)")
    if has_header:
        dupes = [h for h, c in collections.Counter(header).items() if c > 1]
        if dupes:
            print(f"  WARNING: duplicate header names: {dupes}")
        if any(h != h.strip() for h in header):
            print("  WARNING: header names with leading/trailing spaces")

    print("column notes:")
    for j, name in enumerate(header if has_header else [f"col{k}" for k in range(expected)]):
        vals = [r[j].strip() for r in body if len(r) > j]
        notes = []
        if any(LEADING_ZERO.match(v) for v in vals):
            notes.append("leading zeros -> read as text")
        if any(SCI.match(v) for v in vals):
            notes.append("scientific notation (Excel-mangled IDs?)")
        nullish = collections.Counter(v for v in vals if v.lower() in NULLISH)
        if nullish:
            notes.append(f"null-like tokens {dict(nullish.most_common(4))}")
        if any(re.match(r"^-?\d{1,3}(\.\d{3})+,\d+$", v) for v in vals):
            notes.append("European number format (1.234,56)")
        if notes:
            print(f"  {name!r}: " + "; ".join(notes))
    print("RESULT:", "problems found" if problems else "looks loadable")
    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