Mutate data without destroying the evidence — dedup, dtype coercion, missing-value decisions, outlier flagging, category and unit normalisation, joins, pivots and aggregations to a new grain, and columns the source does not contain — appending a row to run/changelog.jsonl for every operation, whether what you write is one parquet file, one part per table, parquet parts of a log too large to load, a saved warehouse query, or cleaned files plus their index frame. The source is never edited in p...
Scanned 9/5/2026
Install to Claude Code
npx -y skills add pymc-labs/daimon --skill data-cleaning --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Data Cleaning?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/pymc-labs-data-cleaning)More formats (shields.io, HTML) on the badges page.
---
name: data-cleaning
description: Mutate data without destroying the evidence — dedup, dtype coercion, missing-value decisions, outlier flagging, category and unit normalisation, joins, pivots and aggregations to a new grain, and columns the source does not contain — appending a row to run/changelog.jsonl for every operation, whether what you write is one parquet file, one part per table, parquet parts of a log too large to load, a saved warehouse query, or cleaned files plus their index frame. The source is never edited in place, and under pandas 3 copy-on-write a chained assignment like df[mask]["col"] = 0 silently changes nothing. Use when a validation check failed, when the grain has duplicates, wrong dtypes or unparseable dates, when a source is too large or raw to query repeatedly and must be materialised once as typed parquet parts, when a collection needs an index frame derived, when a metric or label no column holds must be defined, or when asked to clean, dedup, impute, join, reshape or fix a dataset.
---
# Data cleaning
Cleaned data with no record of what changed is worse than the dirty data it came
from: nobody can separate a real 40% collapse in orders from a dedup on the wrong
key. This is the only stage that mutates data, and it runs only if something does.
**Applies to** one flat export, twelve related tables, a 4 GB log you never load,
a warehouse table queried in place, 500 emails in a folder. **Clean in the medium
the data lives in** — a parquet file, filtered parts, a saved query, or cleaned
items plus their index frame, and deriving that frame or a column no source holds
is a mutation like any other. **Enter here** when a cell, a row count or the grain
changes, **stop here** when the cleaned data is the deliverable.
## Never edit the source, and never mutate without a log row
**The source is the one thing you cannot recreate.** Re-read it as text and coerce
as `data-ingestion` did, write beside `run/raw/` and never into it, and clean data
queried in place with a saved `SELECT`, never an `UPDATE`.
**Every operation that changes a cell, a row count or the grain appends one row.**
The rationalization is always "this one is obvious", and a whitespace strip that
collapsed two distinct SKUs leaves no trace at all. Scale the trail to the data,
never the checks: one small source states each mutation inline in its reply.
```python
import json, os, pandas as pd
df = pd.read_csv("run/raw/orders.csv", dtype="str", keep_default_na=False, na_values=[""])
df["qty"] = pd.to_numeric(df["qty"], errors="coerce").astype("Int64")
def log_change(op, column, rows_affected, rule, before, after):
"""The ONLY writer for run/changelog.jsonl. Copy it verbatim; these six keys
are the contract, not a suggested shape. Renaming one to `detail`, `rows_in`
or `rows_out` breaks eda-storytelling's citation check, which keys on `op`."""
os.makedirs("run", exist_ok=True)
row = {"op": op, "column": column, "rule": rule, "rows_affected": int(rows_affected),
"before": int(before), "after": int(after)}
assert set(row) == {"op", "column", "rule", "rows_affected", "before", "after"}
with open("run/changelog.jsonl", "a") as fh:
fh.write(json.dumps(row) + "\n")
```
## Write every edit through `.loc`
Under pandas 3 copy-on-write, chained assignment only warns, leaves the frame
unchanged, and makes the log claim an edit that never happened. Same for
`inplace=True` on a slice.
```python
# ❌ SILENT NO-OP — warns, and qty is still -1 afterwards
df[df["qty"] < 0]["qty"] = 0
# ✅ one step, and the mask gives you the count to log
mask = df["qty"] < 0
df.loc[mask, "qty"] = 0
log_change("clip_to_zero", "qty", mask.sum(), "negative qty is impossible", len(df), len(df))
```
## Every treatment has a default, and a case where the default is wrong
| Problem | Default treatment | When not to |
|---|---|---|
| Duplicate on the grain | sort by a tiebreaker, keep one, log the rule | the copies disagree on a column you care about — that is a source bug |
| Wrong dtype from read | `astype`, or `pd.to_datetime(errors="coerce")` | over ~1% fails to parse — a read bug, see the `data-ingestion` skill |
| Outliers | flag in a new boolean column, from quantiles taken per group when one frame pools several populations | the value is impossible, not merely extreme — then it is a coercion |
| Free-text categories | map through an explicit dict, assert nothing is unmapped | the long tail is the finding, not noise |
| Mixed units, or a scale pointing the other way | convert to one unit and one direction, rename the column to carry it (`q7_reversed`) | the unit is not recoverable per row — stop and ask |
## Normalise and coerce before you deduplicate
Both change what counts as equal. Normalise through an explicit dict, asserting
`set(key.dropna()) - set(MAPPING)` is empty first — a `.fillna(original)` fallback
is how "Canada " and "CA" reach the report as two categories.
```python
parsed = pd.to_datetime(df["ordered_at"], errors="coerce", format="ISO8601")
lost = df["ordered_at"].notna() & parsed.isna() # errors="coerce" nulls silently
print(df.loc[lost, "ordered_at"].head().tolist()) # look before you accept it
df["ordered_at"] = parsed
log_change("to_datetime", "ordered_at", lost.sum(), "ISO8601, bad -> NaT", len(df), len(df))
```
Then deduplicate — but only where the grain admits it, and the grain says so far
more often than the data does.
**No key, no dedup. This is a gate, not a preference.** You may deduplicate only
on a key the user stated or `run/manifest.json` recorded as the grain. Where the
grain is an index — a sensor stream, an event log, a survey — identical rows are
*data*: one device reporting twice in the same second is two readings, and
`df.drop_duplicates(` deletes the second one along with the sampling rate. That
the rows are byte-identical is not evidence they are copies; it is what a
repeated measurement looks like.
Deciding they were "re-ingested duplicates rather than genuine repeat readings"
is not a call available from the data — it needs a key, or the user. Without one:
do not drop them, report the count as a finding, and lead with the number
computed over every row. Offering both a raw and a deduplicated figure is not a
substitute for asking; the headline is the raw one until someone says otherwise.
Where a key does exist, take a tiebreaker, then `df.drop_duplicates(`, and log
the key you used in the `rule`.
## Leave a gap rather than invent a value
| Option | Use when | The trap |
|---|---|---|
| Leave it — the default | almost always; pandas skips nulls in `mean`, `sum`, `groupby` | a later join or `groupby` drops those rows without saying so |
| Drop rows | the null makes the row unusable for this question | bare `dropna()` is `how="any"` over every column and halves a wide frame |
| Impute | you have a stated reason, you log the method, and you add a `<col>_imputed` boolean so EDA can split on it | "fill missing revenue with the column mean so the totals work" is the banned move — a mean into a column whose distribution you then report shrinks the variance and manufactures precision |
## Flag outliers; a dropped outlier is a deleted finding
An extreme value is only wrong if it is impossible: a 90,000 order is a fact until
someone proves it a typo, and the biggest customer is what a blind 3-sigma filter
deletes first. Flag it — `(df[c] < q1 - 3*(q3-q1)) | (df[c] > q3 + 3*(q3-q1))`,
from `df[c].quantile([0.25, 0.75])` or `df.groupby(circuit)[c]` when one frame
pools several populations, into a `<col>_outlier` boolean, `before` and `after`
equal. Impossible values are the other case: coerce or drop those.
## What you write, and who reads it
Only the data changes shape; the log never does. Downstream resolves the data
positionally — `run/clean.parquet`, else `run/clean/`, else `run/clean.sql`.
| Data | Cleaned artifact |
|---|---|
| One frame | `run/clean.parquet` |
| Related tables, extracted | one parquet per table under `run/clean/`, plus `joined.parquet` when a join is the analysis grain |
| Too big to load | `run/clean/part-*.parquet`, from `duckdb.sql("COPY (SELECT …) TO 'run/clean' (FORMAT parquet, PER_THREAD_OUTPUT true, FILENAME_PATTERN 'part-{i}')")` — bare `TO 'run/clean'` writes one CSV *file* called `clean` |
| Queried in place | `run/clean.sql`, the SELECT that produces the grain — including the join when several tables are queried together — plus a bounded `run/clean_sample.parquet` |
| Files, not rows | cleaned items under `run/clean/items/`, index frame at `run/clean.parquet` — deriving that frame is the mutation, so this stage runs for a collection even when no cell changes |
`run/changelog.jsonl` holds one object per transformation in applied order —
`{"op", "column", "rows_affected", "rule", "before", "after"}` — ending in a
terminal `{"op": "write_clean", "path": …, "format": …}` row that tells the next
stage where the data is. `rule` says *why*, and the new grain when one changes:
"keep earliest `created_at` per `order_id`", not "removed duplicates". Anything
that changes the grain lands here alone — a `df.merge(`, a `df.pivot_table(` whose
`aggfunc` and `fill_value` you chose, an aggregation of events to sessions —
proved not to have fanned out by `validate="1:m"`, or in SQL by an unchanged
`count(*)` and `count(distinct …)` either side; then re-validate on the new grain.
A metric no column holds — churn, retention, a per-item label — is a definition
you take from the user and log as the `rule`.
Re-run `data-validation` on what you wrote; one treatment routinely breaks another
check. Then hand it to `exploratory-data-analysis` or to modeling, the log going
with it for `eda-storytelling`. You may be the last stage: then end the reply
with the literal `Stages: … · skipped: …` line that skill defines — and on a `pass` with nothing to
treat, say so and pass the source on with the manifest's dtypes.
## Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| An edit "ran" but the values are unchanged | chained assignment — pandas 3 only warns with `ChainedAssignmentError` | one step: `df.loc[mask, col] = value` |
| `TypeError: Invalid value '0' for dtype 'str'` | assigning a number into a column ingestion read as text | coerce the column first, or assign the string |
| Row count fell further than the log accounts for | bare `dropna()` (`how="any"` over all columns), or a merge that dropped non-matches | subset the columns; log `before`/`after` around every mutation |
| Row count *rose* after a later join, or a reshape averaged duplicate rows and left absent cells `NaN` | the dedup key was not the grain, so the join went many-to-many; `df.pivot_table(` defaults to `aggfunc="mean"` and fills nothing | pass `validate="1:m"` to `df.merge(`, and `aggfunc="sum"` with `fill_value=0` deliberately; re-check the grain |
| `run/changelog.jsonl` holds operations from an earlier attempt | `log_change` appends, and the file survives a re-run | delete `run/changelog.jsonl` when you restart the run, not at the end |
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!