Python Coding · Cheat Sheet
Where pandas Lies to You
Cheatsheet: Where pandas Lies to You
One page for Topic 2 — the three silent bugs: vanishing groups, edits that never landed, and merges that multiply revenue. All run cleanly.
GroupBy: the grain question
df.groupby('region')['revenue'].sum()
Read in three pieces: group the rows → take a column → aggregate. One output row per unique key. Group keys move into the index; .reset_index() moves them back to ordinary columns (changes no numbers, matters for merges/charts).
Always name the grain — one row per what? — before and after the groupby, and check it matches the question's grain, not just its keywords. "Revenue per customer by region" run on an order-grain table gives revenue per order.
Aggregation reference
| Pattern | Computes | Gotcha |
|---|---|---|
.agg(['sum', 'mean', 'count']) | Several stats per group | count counts non-missing values, not rows |
.size() | Raw row count per group | Use this for "how many rows" |
Named agg: .agg(total=('revenue','sum'), orders=('order_id','count')) | Clean output column names | Column names are claims — verify against the expression |
The average-of-averages rule
.mean() on any column that is itself a ratio (rate, %, CPA, margin) deserves immediate scrutiny:
| Approach | Example (10/100 day, 100/10,000 day) | Answers |
|---|---|---|
Mean of ratios: groupby('channel')['cvr'].mean() | (10% + 1%) / 2 = 5.5% | "Average daily rate" — every day weighted equally |
| Ratio of sums: sum conversions, sum visits, divide once | 110 / 10,100 ≈ 1.1% | The actual blended rate |
Five-fold gap from code that runs perfectly and sounds right. Blended rates = sum numerator / sum denominator. The gap grows with how uneven the traffic is.
NaN group keys vanish
groupby silently drops rows whose group key is missing (dropna=True default) — no warning, no "Unknown" bucket. $500 of real revenue with an unrecorded region is simply gone; a systematic gap (new channel that doesn't set region) makes an entire business line invisible.
The cheap tell: grouped total vs ungrouped total. df.groupby('region')['revenue'].sum().sum() should equal df['revenue'].sum(). If not, rows fell out. Fix to ask for: groupby('region', dropna=False).
The view-vs-copy bug
| Pattern | Shape | What happens |
|---|---|---|
df[df['revenue'] < 0]['revenue'] = 0 | Two bracket selections, then = (chained indexing) | Assigns into a temporary copy that's immediately discarded — original unchanged, runs without error |
df.loc[df['revenue'] < 0, 'revenue'] = 0 | One .loc, rows and column together | Correct — routes back to the original |
SettingWithCopyWarning translates to: "your edit probably didn't land." It's a warning, not an error — easy to scroll past, and some AI notebooks suppress warnings entirely ("for cleaner output"), which removes your smoke detector.
Copy-on-write note: under pandas 2.x with copy_on_write = True, and in pandas 3.0 where it's the default, chained assignment never modifies the original (raises ChainedAssignmentError warning). Version change makes the pattern reliably broken, not correct — the reading rule is the same everywhere: chained indexing + assignment means the data was almost certainly not modified.
One-line verification after every cleaning step — check the condition, not the print statement:
(df['revenue'] > 10000).sum() # should be 0 after capping
"Cleaning complete" prints whether or not anything happened. Standing request for AI: "after every cleaning step, print a check showing the condition you just fixed no longer occurs."
Merges
merged = orders.merge(customers, on='customer_id', how='left')
how= table
| how | Keeps | SQL equivalent | Gotcha |
|---|---|---|---|
'inner' | Only rows matching in both tables | INNER JOIN | The default if how is omitted — silent dropping by omission, not decision |
'left' | Every left row; NaN where lookup missed | LEFT JOIN | Can never shrink — if it grew, the right table has duplicate keys |
'right' | Every right row | RIGHT JOIN | Rare — rewrite as left |
'outer' | Everything from both sides | FULL OUTER JOIN | NaN on both sides |
If the business question says "all orders" and the code says inner (or omits how=), rows and their revenue are quietly gone.
The row-explosion check
A merge changes row count exactly two ways, both diagnostic:
len(orders), len(merged) # (1240, 1240) good. (1240, 1113) or (1240, 1892) — explain yourself.
- Shrinkage = inner merge dropped non-matching rows.
- Growth = duplicate keys multiplied rows. One duplicate customer record turned $1,000 of revenue into $1,400 (each of C1's orders matched twice) — no error, no warning, every downstream number inherits the inflation.
validate= — the free safety check
orders.merge(customers, on='customer_id', how='left', validate='many_to_one')
Asserts "right table has at most one row per key" — raises MergeError immediately instead of silently inflating. Options: 'one_to_one', 'one_to_many', 'many_to_one'. Absent from nearly all AI-generated merges; one of the highest-value standing instructions: "add validate= to every merge, stating the relationship you expect."
Suffix collisions
Same-named non-key columns come out as _x (left) and _y (right). amount_x vs amount_y — nothing says which is order vs refund; downstream code grabbing "the amount column" has a coin-flip chance of summing refunds as revenue. Good code renames before merging or passes explicit suffixes=('_order', '_refund').
Traps
- Trusting a grouped report to be complete — NaN keys vanish; reconcile grouped total vs ungrouped total first.
.mean()on a ratio column — "average conversion rate by channel" literally describes the wrong calculation for a blended rate.- Grain drift — grouping keys match the question's keywords but not its grain.
- Reading
.aggcountas row count — it counts non-missing values; use.size()for rows. df[mask]['col'] = value— the edit hit a throwaway copy; the safe form isdf.loc[mask, 'col'] = value.- "It printed success, so it worked" — prints prove a line ran, not that it did anything; re-inspect the data.
warnings.filterwarnings('ignore')at the top of a notebook — the smoke detector is off.- Merge with no
how=— that's an inner join nobody chose. - Assuming a lookup table is deduplicated because it should be — prove it with
validate=or a duplicate check. - Verifying a merge with
merged.head()— five tidy rows show whether or not 10% of orders vanished; only row counts and a reconciled total verify a merge.
Aligned to the official Python and pandas documentation.
Make this cheat sheet yours
Personalize this sheet — focus it however you study, or build one from the exact questions you keep getting wrong.