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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- csv-data-ingest Installs to ~/.agents/skills/csv-data-ingest/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install csv-data-ingest 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-data-ingest Installs to .agents/skills/csv-data-ingest/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/csv-data-ingest.zipunzip -o csv-data-ingest.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-data-ingest 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-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.
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
| Question | Default | Escape hatch |
|---|---|---|
| Encoding | UTF-8 (utf-8-sig if a BOM is present) | cp1252 for Windows exports with é failing as UTF-8; confirm with the source |
| Initial read | All columns as strings | Typed read only when the file has a trusted, stable spec |
| Library | Polars or DuckDB for large files; pandas when the codebase uses it | openpyxl for cell-level Excel access |
| Nulls | Explicit list per source ("", "NULL") | Never rely on library defaults that treat "NA" as null |
| Bad rows | Quarantine with reason, continue | Fail the whole load when quarantine exceeds a threshold (e.g. 1%) |
| Re-loads | Replace the partition or batch by file id | Never 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)
.xlsxneeds openpyxl;.xlsneeds xlrd;.xlsbneeds pyxlsb; Polarsread_exceluses 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 withpd.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=Falseand 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 -ldisagree 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/2025are ambiguous; get the format from the spec, never infer it from the first rows. - Large files: stream (
pd.read_csv(chunksize=...), Polarsscan_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.yaml
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.py
#!/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.