pandas is numpy with labels. a Series is a 1-d array married to an index; a DataFrame is a dict of such columns sharing one row index. the single organising idea — the one that explains both the magic and the bugs — is that every operation aligns on labels first and computes second. everything on this page runs against pandas 3.0 (outputs are real).𐃏
positional arithmetic is what numpy already does; pandas instead matches labels, outer-joining the two indices and inserting NaN where either side is silent:
nothing lined up by position: b met b and c met c regardless of where they sat, and the unmatched labels surfaced as missing data instead of silently pairing wrong values. this is the entire value proposition — merge two months of data with different row orders, subtract a baseline keyed by id, divide by a per-group total: alignment does the bookkeeping that positional code gets subtly wrong. the price: an unexpected NaN usually means your labels disagreed, not that data vanished — check indices before reaching for fillna.
importwarningsimportpandasaspddf=pd.DataFrame({"x":[1,-2,3],"y":[10,20,30]},index=["r0","r1","r2"])print(df.loc["r1","y"],df.iloc[1,1])# label vs position: same cellprint(df.loc[df.x>0,"y"].tolist())# boolean + column, one step# the chained-assignment trap (pandas 3 = copy-on-write):withwarnings.catch_warnings(record=True)asw:warnings.simplefilter("always")df[df.x>0]["y"]=99# two steps: filter THEN assignprint("warned:",w[0].category.__name__)print("y unchanged:",df.y.tolist())df.loc[df.x>0,"y"]=99# one step: worksprint("y now :",df.y.tolist())
20 20
[10, 30]
warned: ChainedAssignmentError
y unchanged: [10, 20, 30]
y now : [99, 20, 99]
the infamous SettingWithCopyWarning era ended with pandas 3: under copy-on-write, df[mask]always behaves as a copy, so chained assignment never writes through — you get a ChainedAssignmentError warning and no effect, instead of the old maybe-it-worked lottery.𐃏the rule was and remains: one indexing operation per assignment, df.loc[rows, cols] = value.
split-apply-combine: the frame is partitioned on the key, a function hits each part, and the parts are stitched back. agg shrinks each group to a row; transform returns a like-indexed frame; filter keeps or drops whole groups.
the three verbs differ in output shape, and choosing the wrong one is the classic groupby bug:
agg — group \(\to\) scalar(s). result has one row per group.
transform — group \(\to\) like-length column, broadcast back to the original index (the tool for “divide by group total” features).
filter — group \(\to\) boolean. keeps or drops whole groups, original shape otherwise untouched.
importnumpyasnpimportpandasaspdsales=pd.DataFrame({"shop":list("AABBBCC"),"item":["tea","coffee","tea","coffee","tea","tea","coffee"],"units":[30,21,44,12,20,52,33],})g=sales.groupby("shop")["units"]print(g.agg(["sum","mean"]))# agg: one row per groupsales["shop_share"]=g.transform(lambdau:u/u.sum())# transform: same lengthprint(sales.head(4))big=sales.groupby("shop").filter(lambdad:d.units.sum()>60)print("filter keeps shops:",sorted(big.shop.unique()))
sum mean
shop
A 51 25.500000
B 76 25.333333
C 85 42.500000
shop item units shop_share
0 A tea 30 0.588235
1 A coffee 21 0.411765
2 B tea 44 0.578947
3 B coffee 12 0.157895
filter keeps shops: ['B', 'C']
inner: keys=['b', 'c']
left: keys=['a', 'b', 'c']
right: keys=['b', 'c', 'd']
outer: keys=['a', 'b', 'c', 'd']
key l r _merge
0 a 1.0 NaN left_only
1 b 2.0 20.0 both
2 c 3.0 30.0 both
3 d NaN 40.0 right_only
two habits that pay for themselves: validate="one_to_one" (or "one_to_many" etc.) makes the merge assert its own cardinality — a silently duplicated key is the most expensive bug in data work, because row counts explode after the join and every downstream aggregate doubles;𐃏and indicator=True records where each row came from, which turns “why is this NaN” from archaeology into a groupby.
wide and tall are the two habitable phases of tabular data; these operators move between them:
importpandasaspdsales=pd.DataFrame({"shop":list("AABBBCC"),"item":["tea","coffee","tea","coffee","tea","tea","coffee"],"units":[30,21,44,12,20,52,33],})wide=sales.pivot_table(index="shop",columns="item",values="units",aggfunc="sum",fill_value=0)print(wide)tall=wide.reset_index().melt(id_vars="shop",value_name="units")print(tall.head(3))print(wide.stack().head(4))# columns -> inner index level
item coffee tea
shop
A 21 30
B 12 64
C 33 52
shop item units
0 A coffee 21
1 B coffee 12
2 C coffee 33
shop item
A coffee 21
tea 30
B coffee 12
tea 64
dtype: int64
pivot_table = groupby + reshape (it aggregates duplicates; plain pivot refuses them). melt is its inverse, back to tidy one-observation-per-row form. stack and unstack are the same moves phrased as index-level rotation — a pivot is just groupby + unstack in a trenchcoat.
the index earns its keep hardest when it is a DatetimeIndex: resampling is “groupby over time bins”, rolling is a sliding window with alignment handled for you.
importnumpyasnpimportpandasaspdrng=np.random.default_rng(0)idx=pd.date_range("2026-01-01",periods=90,freq="D")ts=pd.Series(50+np.arange(90)*0.3+rng.normal(0,4,90),index=idx)print(ts.resample("MS").mean().round(2))# downsample to month startsroll=ts.rolling(window=7,center=True).mean()# 7-day smootherprint("rolling covers",roll.notna().sum(),"of",len(ts),"days")print("jan mean vs mar mean: %.1f -> %.1f"%(ts["2026-01"].mean(),ts["2026-03"].mean()))
2026-01-01 53.90
2026-02-01 64.77
2026-03-01 72.79
Freq: MS, dtype: float64
rolling covers 84 of 90 days
jan mean vs mar mean: 53.9 -> 72.8
note the partial-string indexing (ts["2026-01"] selects the whole month) and the honest edges: a centred 7-day window cannot cover the first and last three days, and rolling says so with NaN rather than inventing data.
mutate-in-place scripts accumulate mystery state; the chaining idiom makes each frame a value and the pipeline a single readable expression — the fp instinct (see functional programming) applied to tables:
shop item units
0 A coffee 85
1 A tea 32
2 B tea 9
assign adds derived columns without mutation, query filters with a readable mini-language, pipe slots any frame-to-frame function into the chain. debugging tip: comment out the tail of the chain and inspect the prefix — the pipeline structure makes bisection trivial.
importnumpyasnp,pandasaspd,timeitrng=np.random.default_rng(0)n=1_000_000cities=pd.Series(rng.choice(["sydney","melbourne","brisbane"],n))as_obj=cities.astype("object")as_cat=cities.astype("category")print("object strings: %5.1f MB"%(as_obj.memory_usage(deep=True)/1e6))print("category : %5.1f MB"%(as_cat.memory_usage(deep=True)/1e6))t=lambdaf:timeit.timeit(f,number=5)/5*1e3print("upper via .apply(str.upper): %6.1f ms"%t(lambda:as_obj.apply(str.upper)))print("upper via .str.upper() : %6.1f ms"%t(lambda:as_obj.str.upper()))# when pandas overhead dominates, drop to numpys=pd.Series(rng.standard_normal(n))a=s.to_numpy()# zero-copy view of the same buffert_pd=timeit.timeit(lambda:(s-s.mean())/s.std(),number=10)/10t_np=timeit.timeit(lambda:(a-a.mean())/a.std(),number=10)/10print("standardise: pandas %5.2f ms | numpy %5.2f ms"%(t_pd*1e3,t_np*1e3))
object strings: 56.7 MB
category : 1.0 MB
upper via .apply(str.upper): 107.1 ms
upper via .str.upper() : 73.5 ms
standardise: pandas 6.09 ms | numpy 2.60 ms
three honest lessons from the measurements:
categoricals are the big win: a low-cardinality string column shrinks \(57\times\) (codes + a tiny dictionary), and groupbys on it get faster too. any id/label/state column qualifies.
vectorised string ops (.str.*) are tidier than .apply and comparable in speed (the ordering flips between runs — string work is loop-bound either way) — do not expect numpy-style \(65\times\) miracles from text.
dropping to numpy (.to_numpy() is zero-copy for a single numeric dtype) roughly halves purely numerical pipelines by shedding index-alignment overhead. inside a tight loop, that factor is the difference between interactive and annoying.
Structure of data.csv:
ID Name Age Gender Salary Target
1,Sara,25,Female,50000,0
2,Ophrah,30,Male,60000,1
3,Torben,22,Male,70000,0
4,Masaharu,35,Male,80000,1
5,Kaya,NaN,Female,55000,0
6,Abaddon,29,Male,NaN,1
Column Description:
ID: A unique identifier for each record (integer).
Name: The name of the individual (string).
Age: Age of the individual (numerical, may have missing values).
Gender: Gender of the individual (categorical: Male/Female).
Salary: The individual’s salary (numerical, may have missing values).
Target: The target variable for binary classification (binary: 0 or 1).
matplotlib has a reputation for being clunky, and the reputation is earned exactly by people fighting the wrong api. there are two: a stateful pyplot layer that mimics matlab (“draw on whatever was touched last”), and an object-oriented core in which every visible thing is an object you hold a reference to. the second is the real library; the first is sugar for one-liners. write fig, ax = plt.subplots() and stay in object land, and most of the clunk evaporates.𐃏all code on this page executed for real; figures were written to /tmp and are described rather than embedded, with true file sizes.
numpy is one idea executed ruthlessly: a typed, contiguous block of memory plus enough metadata to reinterpret it without copying. everything else — broadcasting, views, vectorised speed — falls out of that design. learn the memory model and the api stops being a list of functions and becomes a handful of consequences.𐃏all timings and outputs on this page are real (python 3, numpy 2.4, apple m-series).