Load whatever the request points at — one CSV or Excel export, twelve related tables, a folder of 500 emails, a 4 GB log you never load, a warehouse table you query in place — and record every source in run/manifest.json with the grain one row represents, the axis it is ordered by, its row count, columns and dtypes. Read as text and coerce on purpose — pd.read_csv inference turns order_id 00123 into the integer 123, and a paged pull that stops early looks identical to a complete one. Use when...
Scanned 9/5/2026
Install to Claude Code
npx -y skills add pymc-labs/daimon --skill data-ingestion --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Data Ingestion?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/pymc-labs-data-ingestion)More formats (shields.io, HTML) on the badges page.
---
name: data-ingestion
description: Load whatever the request points at — one CSV or Excel export, twelve related tables, a folder of 500 emails, a 4 GB log you never load, a warehouse table you query in place — and record every source in run/manifest.json with the grain one row represents, the axis it is ordered by, its row count, columns and dtypes. Read as text and coerce on purpose — pd.read_csv inference turns order_id 00123 into the integer 123, and a paged pull that stops early looks identical to a complete one. Use when a user attaches a file or a folder, points you at a database, warehouse or API, or asks for analysis of data you have not loaded yet.
---
# Data ingestion
A load that "worked" is the most expensive failure in an analysis. Nothing
raises, a frame comes back, and four stages later a number is wrong because
`order_id` lost its leading zeros, a `Total` row became a data row, or the API
returned page one of forty. You decide the shape everything downstream assumes;
`run/manifest.json` is where you say so out loud.
**Applies to** whatever the request points at: one flat export, twelve related
tables, a folder of 500 emails, a 4 GB log you never load, a warehouse table you
query in place. **One entry per source** — every table, directory of parts and
warehouse query gets its own key in `run/manifest.json`, with a recorded `query`
and a verified count where the bytes are not yours to keep. **Enter here** when
data is not loaded and recorded yet; **stop here** when "what did I just get"
was the question; already-typed data skips the text read, not the entry.
## Read as text, then coerce on purpose
**Type inference is where downstream bugs are born.** `pd.read_csv` with no
`dtype=` reads `00123` as the integer `123`, deleting the zeros that make it an
identifier, and turns a column holding one `N/A` into `float64`. Read as string,
coerce only what you compute on, and count the casualties:
```python
import pandas as pd
text = pd.read_csv("run/raw/orders.csv", dtype="str",
keep_default_na=False, na_values=[""])
df = text.copy()
df["qty"] = pd.to_numeric(text["qty"], errors="coerce").astype("Int64")
df["created_at"] = pd.to_datetime(text["created_at"], format="ISO8601",
errors="coerce", utc=True)
for col in ("qty", "created_at"):
lost = text[col].notna() & df[col].isna()
if lost.any():
print(col, int(lost.sum()), "unparseable:",
text.loc[lost, col].head(3).tolist())
```
`keep_default_na=False` keeps `NA`, `None` and `null` as literal text, not
someone else's idea of missing; `errors="coerce"` makes a bad value `NaT`/`<NA>`
and the loop stops it being silent. An ID you can do arithmetic on is a bug.
## Size and sniff the source before you load it
```bash
ls -l run/raw/orders.csv && head -c 400 run/raw/orders.csv # .gz: zcat f.gz | head -c 400
```
That shows the real delimiter and the header depth; `ls -R` and `unzip -l` do
the same for a folder or an archive, and `file-handling` owns never reading a
whole data file to find out. Past a few GB nothing loads, and decompressing to
make it load fills the disk: leave the `.gz` alone, count from it with
`duckdb.sql("SELECT count(*) FROM 'log.csv.gz'").df()`, and fold a chunked
`pd.read_csv` in fixed memory.
## Every source has one trap, and this is it
| Source | How to read it | The trap |
|---|---|---|
| CSV / TSV, or a table pasted into the message | `pd.read_csv(..., dtype="str")`; a spreadsheet paste is tab-separated, so `sep="\t"` | European exports: `sep=";"`, `decimal=","`, `thousands="."` — the default read gives one column, or numbers as text; a survey's second header row of question wording, or a pasted `Total` row, arrives as data — peek first, as with Excel |
| Excel `.xlsx` | `pd.ExcelFile`, then `pd.read_excel(header=n)` | it is a formatted report, not a table — title rows, spacer rows, a `Total` row that becomes data |
| JSON / JSONL | one document is `json.load(f)` then `pd.json_normalize(doc["features"], sep="__")`; JSONL that fits in memory is `json.loads` per line into `pd.json_normalize(recs, sep="__")`, and past that project only the columns you need with `duckdb.read_json(path, columns={...})` — a `.gz` reads in place | `pd.read_json(lines=True)` "succeeds" and hands you a column of dicts; nested lists survive normalization as Python lists, so hold a geometry or a tag list out of the frame; a list comprehension of `json.loads` over a 4 GB log OOMs before it raises, and an auto-inferred schema silently drops keys absent from its sample |
| SQL file: `.sqlite`, or a `.sql` dump | `sqlite3` or duckdb, then query it | `SELECT *` on a view, or on a table with soft-deleted rows still in it; a dump is DDL + INSERTs in a vendor dialect, and regexing INSERT lines drops any row with an escaped quote |
| Warehouse or live database | query in place: `pd.read_sql` on a bounded SELECT, `count(*)` for the row count; only duckdb and pyarrow ship here, so `pip install sqlalchemy` plus the vendor driver (`psycopg2-binary`, `snowflake-sqlalchemy`, `sqlalchemy-bigquery` — a bare `postgresql://` DSN resolves to psycopg2, not psycopg 3) and take the DSN from an agent env credential, never from chat — `workspace-setup` owns adding one | extracting the table to get a frame — you cannot, and the manifest wants the `query` and the DSN with its credentials stripped, not a `sha256` |
| Paged API | the loop below | a short final page and an early stop look exactly like completion |
| Many parts: a folder, a zip, 12 sheets in one workbook, a 12-table export | same-shaped parts stack into one entry — `pd.read_excel(xl, sheet_name=None)`, then `pd.concat(parts, names=["plate"]).reset_index(level="plate")`, because the part's name is data; compare the parts' columns and dtypes before you stack, and differently-shaped tables get one entry each with the join left to `data-cleaning` | the part name discarded, so no row knows which sheet it came from; part 007 read with different dtypes than its siblings; or a join done in whatever cell needed it and logged nowhere |
| A collection: 500 emails, images, JSON documents | leave the items where they are and build an index frame — one row per item, carrying `path`, an item id and the attributes you pulled out; a collection can be one document rather than many files, and then a GeoJSON feature's own id stands in for the `path` | forcing the corpus itself into a frame. Decoding the domain — image internals, audio, protein sequences — is the analysis's job, not ingestion's |
An Excel sheet is a report, not a table — peek before you choose `header=`:
```python
import pandas as pd
xl = pd.ExcelFile("run/raw/report.xlsx") # xl.sheet_names lists every tab
print(pd.read_excel(xl, sheet_name="Q3 Summary", header=None, nrows=8).to_string())
df = pd.read_excel(xl, sheet_name="Q3 Summary", header=3, dtype="str")
df = df[df["Region"].notna() & (df["Region"] != "Total")]
```
## A paged pull is incomplete until you have proved it complete
**The worst outcome here is a partial pull that looks whole** — half the rows
analyze cleanly and every conclusion is wrong. Assert the count you got against
the source's own total, and never read a rate limit as the end of the data:
```python
import os, time, requests
url = "https://api.example.com/v1/orders?page=1&per_page=100"
headers = {"Authorization": f"Bearer {os.environ['API_TOKEN']}"}
rows, body = [], None
while url:
r = requests.get(url, headers=headers, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
body = r.json()
rows.extend(body["data"])
url = body.get("next_page_url")
assert len(rows) == body["meta"]["total"], f"partial pull: {len(rows)} rows"
```
If no total is reported, say completeness is unverified. A 404 stops the run,
and a missing DSN or token is requested through `workspace-setup` rather than
pasted into chat — never invent a plausible frame.
## What you write: `run/raw/` and `run/manifest.json`
A chat attachment is a signed URL: `curl -sS "<url>" -o run/raw/<name>`, so
`run/raw/` holds the bytes as retrieved — there is none for a warehouse table
you query in place. Later stages write beside it; none edit what you wrote.
```python
import datetime, hashlib, json, pathlib
src = pathlib.Path("run/raw/orders.csv")
pathlib.Path("run/manifest.json").write_text(json.dumps({"orders": {
"source": "https://api.example.com/v1/orders",
"retrieved_at": datetime.datetime.now(datetime.UTC).isoformat(),
"grain": "one row per order line, ordered by created_at",
"rows": len(df), # or a duckdb count(*), when nothing was loaded
"cols": list(df.columns), # just the projection, when that is all you read
"dtypes_as_read": {c: str(t) for c, t in df.dtypes.items()},
"sha256": hashlib.file_digest(src.open("rb"), "sha256").hexdigest(),
}}, indent=2))
```
Required per entry: `source`, `retrieved_at`, `grain`, `rows`, `cols`,
`dtypes_as_read`, and either a `sha256` — for a directory of items, the item
count and the decode failures you verified instead — or, bytes not yours, the
`query` plus a count checked against the source's own total. `grain` is the
sentence "one row of this represents ___, ordered by ___": per (store, week), by
week. Ask when the source will not say. Name the ordering column too: validation
and EDA bucket along the axis you name, and a composite grain has more than one.
Hand the manifest to `data-validation`; you asserted only that the data is
loaded and counted, so if the answer ships from here, end the reply with the
literal `Stages: … · skipped: …` line `eda-storytelling` defines. A 40-row paste needs no `run/`; the manifest is one sentence in your reply.
## Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| `UnicodeDecodeError`, or names arrive as `é` / `id` | the export is cp1252, or has a UTF-8 BOM | `encoding="cp1252"` or `encoding="utf-8-sig"`, chosen from the peek |
| `df["Region"]` raises `KeyError` though the header looks right | trailing whitespace in the header cells | `df.columns = df.columns.str.strip()` before anything else |
| Dates all land in the first 12 days of each month | `d/m` vs `m/d` resolved per value | pass an explicit `format=`, and count the `NaT`s the coerce produced |
| A date column holds `45231` | Excel serial dates | `pd.to_datetime(s.astype("Int64"), unit="D", origin="1899-12-30")` |
| `df.dtypes == object` selects no columns | pandas 3 reads text as `str` dtype, not `object` | `pd.api.types.is_string_dtype(s)`, or `df.select_dtypes("str")` |
| Two pulls minutes apart return different row counts | a live source paginating over a moving table | sort by a stable key server-side; `retrieved_at` in the manifest is what dates the answer |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!