Pandas Analysis skill for Claude Code

Every row accounted for, every number reproducible. Claude loads data with explicit dtypes, profiles nulls, keys and ranges before touching anything, and logs row counts through every filter and join. Merges use validate and indicator so duplicate keys cannot inflate totals, aggregations use named groupby, and the analysis ends with assertions, a cross-check against an independent number and a findings note with caveats.

  • namepandas-analysis
  • size202 lines · ≈1,900 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Performs reproducible data analysis with pandas — explicit dtypes on load, profiling, cleaning, validated merges, named groupby aggregations, reshaping, time series resampling, vectorized transforms, and memory and performance fixes — ending in assertions and a short findings summary. Use when analyzing a DataFrame, loading CSV, Excel, or Parquet into pandas, when a merge changes row counts unexpectedly, when groupby results look wrong, or when pandas code is slow or runs out of memory.

Try asking

  • Analyze this orders CSV and give me monthly revenue by region
  • After merging customers into orders my totals doubled. What went wrong?
  • This pandas script takes 20 minutes and 12 GB of RAM. Speed it up

Use it when

  • You are exploring or summarizing tabular data in pandas
  • A merge or groupby produces suspicious totals
  • pandas code is slow or runs out of memory
  • An analysis must be rerunnable by someone else

Skip it when

  • The data is far larger than memory or you prefer lazy execution — use polars-dataframes instead
  • The file itself is malformed with bad encodings or delimiters — use csv-cleaning instead

How to check the result

The script runs top to bottom from raw files, row counts are explained at each step, assertions pass, and one headline figure matches an independent source.

The common mistake it prevents

Left-joining onto a table with duplicate keys, which silently multiplies rows and inflates every sum computed afterwards.

~/.claude/skills/pandas-analysis/SKILL.md

--- name: pandas-analysis description: Performs reproducible data analysis with pandas — explicit dtypes on load, profiling, cleaning, validated merges, named groupby… license: MIT ---

An analysis is done when every number in the conclusion can be regenerated by re-running the code top to bottom, row counts are accounted for at every join and filter, dtypes are deliberate, and assertions guard the assumptions the result depends on.

Ground rules

  • Look before transforming: shape, dtypes, nulls, duplicates, key uniqueness.
  • Specify dtypes and date parsing on load; never trust inference on messy data.
  • Track row counts after every filter and merge; explain any change.
  • Vectorize. iterrows/apply(axis=1) are a last resort.
  • Write results to files (Parquet/CSV) from code, not by copying printed output.
  • Never overwrite the raw input file.

Workflow

- [ ] 1. Load with explicit dtypes, record raw shape
- [ ] 2. Profile: dtypes, nulls, cardinality, duplicates, ranges
- [ ] 3. Clean: types, missing values, outliers, duplicates (log each step's count)
- [ ] 4. Join with validate= and indicator=, check counts
- [ ] 5. Aggregate/reshape to answer the question
- [ ] 6. Assert invariants, sanity-check totals against a source
- [ ] 7. Save outputs, summarize findings with caveats

1. Load

import pandas as pd

df = pd.read_csv(
    "orders.csv",
    usecols=["order_id", "customer_id", "created_at", "amount", "currency", "status"],
    dtype={"order_id": "string", "customer_id": "string", "currency": "category", "status": "category"},
    parse_dates=["created_at"],
    na_values=["", "NA", "null", "-"],
)
print(df.shape)
SourceLoader notes
CSVdtype=, usecols=, parse_dates=; dtype_backend="pyarrow" for lower memory
Parquetpd.read_parquet(path, columns=[...], filters=[...]) reads only what you need
Excelpd.read_excel(path, sheet_name=..., dtype=...); check merged header rows
SQLpd.read_sql(query, engine, params=...); aggregate in SQL when data is large
Large CSVchunksize= and aggregate per chunk, or switch to Polars/DuckDB

Messy files (encodings, delimiters, broken rows) belong to csv-cleaning first.

2. Profile

df.info(memory_usage="deep")
df.describe(include="all").T
df.isna().mean().sort_values(ascending=False).head(20)     # null share per column
df.nunique().sort_values()
df["status"].value_counts(dropna=False)
df.duplicated(subset=["order_id"]).sum()                    # key uniqueness
df["created_at"].agg(["min", "max"])

3. Clean

before = len(df)
df = (
    df
    .assign(
        amount=lambda d: pd.to_numeric(d["amount"], errors="coerce"),
        created_at=lambda d: pd.to_datetime(d["created_at"], utc=True),
        status=lambda d: d["status"].astype("string").str.strip().str.lower().astype("category"),
    )
    .drop_duplicates(subset=["order_id"], keep="last")
    .query("status != 'test'")
)
print(f"cleaning removed {before - len(df)} rows")
bad_amount = df["amount"].isna().sum()   # rows that failed numeric conversion

Missing-value decisions:

SituationAction
Missing means zero (no purchases)fillna(0) and say so
Missing at random, small shareDrop rows, report the count
Missing in key/join columnsInvestigate source; never fill silently
Integer column with nullsUse nullable Int64, not float
Category with nullsKeep as explicit “unknown” if it matters for counts

4. Merge safely

merged = orders.merge(
    customers, on="customer_id", how="left",
    validate="many_to_one",      # raises if customers has duplicate keys
    indicator=True,
)
print(merged["_merge"].value_counts())
assert len(merged) == len(orders), "left join changed row count: duplicate keys on the right"
unmatched = merged.loc[merged["_merge"] == "left_only", "customer_id"].unique()

Normalize key dtypes and formatting (strip, case, leading zeros) on both sides before merging; "00123" vs 123 silently fails to match.

5. Aggregate and reshape

summary = (
    merged
    .groupby(["region", pd.Grouper(key="created_at", freq="MS")], observed=True, dropna=False)
    .agg(
        orders=("order_id", "nunique"),
        revenue=("amount", "sum"),
        avg_order=("amount", "mean"),
        customers=("customer_id", "nunique"),
    )
    .reset_index()
)

# share within group without collapsing rows
merged["share_of_customer_spend"] = merged["amount"] / merged.groupby("customer_id")["amount"].transform("sum")

wide = summary.pivot_table(index="created_at", columns="region", values="revenue", aggfunc="sum", fill_value=0)
long = wide.reset_index().melt(id_vars="created_at", var_name="region", value_name="revenue")

# time series: sort first, then resample/rolling
daily = merged.set_index("created_at").sort_index()["amount"].resample("D").sum()
rolling_7d = daily.rolling(7, min_periods=7).mean()
NeedUse
One row per groupgroupby().agg(name=(col, func))
Keep row count, add group statgroupby().transform()
Filter groupsgroupby().filter(lambda g: len(g) >= 5) or transform + mask
Top N per groupsort_values().groupby().head(N)
RatiosAggregate numerator and denominator separately, then divide (never average ratios)

6. Assert and sanity-check

import math

assert df["order_id"].is_unique
assert (df["amount"].dropna() >= 0).all(), "negative amounts present"
total_raw = df["amount"].sum()
total_summary = summary["revenue"].sum()
assert math.isclose(total_raw, total_summary, rel_tol=1e-9), (total_raw, total_summary)

Cross-check one headline number against an independent source (dashboard, finance report, a SQL query). If it differs, explain why before reporting.

Performance and memory

SymptomFix
apply(axis=1) over rowsVectorized column ops, np.where, np.select, .map(dict)
String columns using GBscategory for low cardinality; string[pyarrow] otherwise
Repeated pd.concat in a loopCollect frames in a list, concat once
Huge CSV re-parsed every runConvert once to Parquet
Doesn’t fit in memoryRead only needed columns, chunk, or move to Polars/DuckDB
Slow groupby on object keysConvert keys to category with observed=True

Measure: %timeit in notebooks, df.memory_usage(deep=True).sum() / 1e6 for MB.

Gotchas

  • Chained assignment (df[df.x > 0]["y"] = 1) does not modify df. With Copy-on-Write (default in pandas 3), it never works; use df.loc[mask, "y"] = 1.
  • pandas 3 infers a dedicated string dtype for text; code checking dtype == object for strings needs updating.
  • groupby drops NaN keys unless dropna=False; categoricals produce empty groups unless observed=True.
  • Averaging per-group averages weights small groups like big ones.
  • Timezone-naive and aware datetimes cannot be compared; normalize to UTC on load.
  • value_counts() excludes NaN by default; mean() skips NaN silently.
  • Integer columns become float when a NaN appears; IDs like 1234567890123456789 lose precision. Load IDs as strings.
  • read_csv with mixed types per column emits DtypeWarning and guesses per chunk; set dtypes.

Output

**Question:** Monthly revenue by region, Jan–Sep 2026
**Data:** orders.csv 1,204,331 rows -> 1,198,902 after removing 5,429 test/duplicate orders
**Joins:** orders -> customers (many_to_one), 312 orders with unknown customer kept as "unknown"
**Result:** EMEA revenue +18% vs. H1; APAC flat (table saved to out/revenue_by_region.parquet)
**Checks:** totals match raw sum; September total within 0.2% of finance dashboard
**Caveats:** amounts in original currency; FX conversion not applied

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc