Alignment is
the whole library.
Most pandas confusion comes from forgetting that every Series and DataFrame carries an index, and that almost every operation aligns on it rather than on position. Get that, plus the time series cluster at the bottom of this page, and you have the part of pandas this desk actually cares about.
.loc against .iloc against bare brackets
Three ways to select, with different rules, and one of them changes meaning depending on what you hand it.
| Selects by | Slice endpoint | Missing key | |
|---|---|---|---|
.loc[] |
Label | Inclusive on both ends | KeyError |
.iloc[] |
Position | Half open, like every other Python slice | IndexError |
[] |
Depends on what you pass | Depends | Depends |
The inclusive .loc endpoint looks like an
inconsistency and is not. With labels you often have no idea
what the next label is, so
df.loc['2024-01-01':'2024-01-31'] has to
include the 31st. Under a half-open rule you would have to
name the following day, which for irregular or timestamped
indexes you may not know exists. Positional slicing has no
such problem, so .iloc keeps Python's normal
rule.
s = pd.Series([10, 20, 30, 40], index=[3, 1, 2, 0])
print(s.loc[1])
print(s.iloc[1])
print(s.loc[3:2])
print(s[1])
20, then 20, then a Series
holding 10, 20, 30, then 20.
s.loc[1] finds the label 1,
which sits at position 1, holding 20.
s.iloc[1] asks for position 1, which
happens to be the same element. The two agreeing here
is a coincidence of this data, not a rule.
s.loc[3:2] slices from the label 3 to the
label 2 in index order, giving
positions 0 through 2 with the endpoint included.
Label slicing does not sort, it walks the index as it
stands.
s[1] with an integer index means
label-based lookup, so it matches .loc.
Change the index to strings and
s[1] would mean position instead. This
context-dependence is exactly why you should not use
it, and pandas has been steadily deprecating it:
positional access through [] on an
integer-indexed Series was removed in pandas 3.0.
What bare brackets mean on a DataFrame
df['col'] # a column, as a Series
df[['a', 'b']] # several columns, as a DataFrame
df[df.price > 100] # rows, by boolean mask
df[0:5] # rows, by position. a slice means rows, a key means columns.
A string selects a column, a slice selects rows, a boolean array selects rows. Convenient at a prompt, and a liability in code, because a change in the type of what you pass silently changes the axis you are selecting on.
Use.locand.iloceverywhere in code, and say both axes explicitly:df.loc[mask, 'price']rather thandf[mask]['price']. Two-axis selection in one call is unambiguous, and as the next section shows, it is also the only form that reliably assigns.
Chained indexing, and what pandas 3.0 changed
This is a two-part answer now, and knowing both parts is a good signal. You will still meet the old behaviour in every existing codebase, and the new model is what makes the whole thing finally predictable.
The problem, as it existed for a decade
df[df.price > 100]['flag'] = True # chained: two separate operations
Python evaluates that left to right as two calls. First
df.__getitem__(mask) produces a new object.
Then __setitem__('flag', True) runs on
that object, whatever it turned out to be.
Whether the intermediate was a view onto df or
a fresh copy depended on internals: how the columns were
grouped into blocks by dtype, whether the selection covered
a whole block, what the mask happened to select. A view
meant the write reached df. A copy meant the
write went into a temporary that was discarded on the next
line, so the assignment silently did nothing. Same code, and
the outcome depended on the shape of your data.
SettingWithCopyWarning existed because pandas
could not tell you which one had happened. It was a warning
that said "this might not have worked", which is an
unsatisfying thing for a library to have to say.
What pandas 3.0 did about it
pandas 3.0, released in January 2026, made Copy-on-Write the only mode. The rule is now a single sentence with no exceptions:
Every indexing operation and every method that returns a DataFrame or Series behaves as if it returned a copy, so a write through an intermediate object can never reach the original.
Three consequences follow, and they are all improvements:
- Chained assignment never works, rather than working unpredictably. It fails the same way every time, which means you find it immediately instead of in production.
-
SettingWithCopyWarningis gone. It existed only to flag ambiguity, and there is no ambiguity left. Defensive.copy()calls added to silence it are now dead weight. - It is usually faster. "As if a copy" is not "always a copy". pandas tracks references internally and defers the actual copy until someone writes. Reads share memory as before, and the copy happens only on the write that would have been ambiguous. That also removes a great deal of defensive copying pandas used to do just in case, which is why 3.0 tends to use less memory rather than more.
The correct form, which has not changed
# right: one operation, both axes named, no intermediate object
df.loc[df.price > 100, 'flag'] = True
# wrong under any version: two operations
df[df.price > 100]['flag'] = True
Why the single call works:
df.loc[rows, cols] = value is one
__setitem__ on df itself. There is
no intermediate, so there is nothing to be a copy of. pandas
knows the target and the selection at once and writes into
the original directly.
Code that relied on a view to propagate a write, such as
taking sub = df[df.x > 0] and then writing
to sub expecting df to change,
silently stops updating df under 3.0. It was
already unreliable before, but it worked often enough that
people depended on it. If you touch a pandas 2.x codebase,
this is the migration hazard to look for. The fix is to
write through .loc on the original, or to
accept the copy and reassign deliberately.
Explain the mechanism first, since it is the same under both versions: chained indexing is two operations, so the write targets an intermediate whose identity you do not control. Then say that pandas 3.0 removed the ambiguity by making every intermediate behave as a copy, which also retired the warning. That shows you understand the cause rather than the symptom.
Split, apply, combine: agg against transform
Same grouping, same function, different shape out. Which one you want follows from a single question: do you want one row per group, or one row per original row?
agg collapses. transform broadcasts the group result back to every member row.
df.groupby('symbol')['px'].agg('mean') # one row per symbol
df.groupby('symbol')['px'].transform('mean') # one row per original row
# which is why transform is what you want for a group-relative feature:
g = df.groupby('symbol')['px']
df['demeaned'] = df['px'] - g.transform('mean')
df['zscore'] = (df['px'] - g.transform('mean')) / g.transform('std')
Assigning a transform result back is safe precisely because it carries the original index, so pandas aligns it row for row. Try the same with an agg result and you get NaN everywhere, because you are aligning a symbol-indexed Series against a row-indexed frame and almost nothing matches.
The aggregation forms worth knowing cold
# one function, several columns
df.groupby('symbol').agg({'px': 'mean', 'qty': 'sum'})
# named aggregation: flat column names, no MultiIndex to unpick
df.groupby('symbol').agg(
avg_px=('px', 'mean'),
total_qty=('qty', 'sum'),
n_trades=('px', 'size'),
)
# several functions on one column
df.groupby('symbol')['px'].agg(['mean', 'std', 'count'])
Named aggregation is the form to reach for by default. It
produces ordinary flat columns instead of a two-level column
index, which saves you a droplevel or a rename
on every downstream use.
| Detail | What to know |
|---|---|
count against size |
count excludes NaN,
size counts every row. The difference
is your missing-data count.
|
dropna |
Groups with a NaN key are dropped by default. Pass
dropna=False to keep them as their own
group. This has swallowed a lot of data quietly.
|
observed |
With Categorical keys, pandas 3.0 defaults to
observed=True, so unobserved category
combinations no longer appear as empty groups. Under
2.x the default was False, which could produce a
combinatorial explosion of empty rows.
|
sort |
Groups come out sorted by key by default.
sort=False keeps first-appearance order
and is faster.
|
as_index |
as_index=False leaves the keys as
columns instead of moving them into the index, which
saves a reset_index().
|
What a merge does with duplicate keys
The join types are the easy part. The row count is where people get hurt.
left = pd.DataFrame({'k': ['a', 'a', 'b'], 'x': [1, 2, 3]})
right = pd.DataFrame({'k': ['a', 'a', 'a', 'c'], 'y': [10, 20, 30, 40]})
out = left.merge(right, on='k', how='left')
print(len(out))
7, not 3.
A merge is a Cartesian product
within each key. The key
a has 2 rows on the left and 3 on the
right, so it produces 2 × 3 = 6 rows. The key
b has 1 on the left and 0 on the right,
and because this is a left join it survives as 1 row
with NaN in y. Total 7. The key
c is dropped, since it only exists on the
right.
The lesson is that how='left' does not
mean "keep the left row count". It means "keep every
left key". Those are different promises, and only the
second one is true.
| Relationship | Row count | Usually means |
|---|---|---|
| one to one | Unchanged | What you assumed |
| many to one | Unchanged, on the many side | The normal lookup: attaching reference data to events |
| one to many | Grows to the many side | Fine if intended, a fan-out if not |
| many to many | Product within each key | Nearly always a bug. Duplicate keys somewhere you did not expect them. |
Make pandas check it for you
out = left.merge(right, on='k', how='left',
validate='many_to_one', # raises if the right side has duplicate keys
indicator=True) # adds _merge: left_only / right_only / both
validate is the single highest-value habit in
this section. It converts a silent row explosion into an
immediate exception, and it documents your assumption in the
code where the next reader will see it.
indicator is how you check match rates:
out._merge.value_counts() tells you at once
whether your join found what you expected, which catches key
type mismatches and stray whitespace before they become a data
quality investigation.
A key that is int64 on one side and
object holding strings on the other matches
nothing, and a left join reports this as every row having
NaN rather than as an error. Same for a timezone-aware
timestamp against a naive one, and for
'AAPL' against 'AAPL '. Always
look at the match rate after a join you care about.
df.join(other) merges on the index by default,
which is convenient for index-aligned frames.
pd.concat([a, b]) stacks rather than joins:
axis=0 appends rows, axis=1 glues
columns side by side by aligning the index. Concatenating in
a loop is quadratic because each step copies everything, so
build a list and concatenate once.
Vectorised, then apply, then iterrows
Everyone knows the ordering. The value is in being able to say what each rung actually costs.
The same computation, four ways
# 1. vectorised: one C loop over contiguous memory
df['notional'] = df['px'] * df['qty']
# 2. apply on a column: a Python call per element, no per-row container
df['notional'] = df['px'].apply(lambda p: p * 2)
# 3. apply across rows: a Series is built for every row
df['notional'] = df.apply(lambda r: r['px'] * r['qty'], axis=1)
# 4. iterrows: a Series per row, and you rebuild the column by hand
out = []
for idx, row in df.iterrows():
out.append(row['px'] * row['qty'])
df['notional'] = out
| Rung | Per-row cost | Rough factor against vectorised |
|---|---|---|
| Vectorised | Nothing per row. One dispatch for the whole column, then a compiled loop. | 1x |
.apply on a Series |
One Python function call and one boxed scalar per element. | roughly 10 to 50x slower |
.apply(axis=1) |
A Series object constructed per row, plus the call. | roughly 50 to 200x slower |
.iterrows() |
Same Series construction, plus the dtype problem below. | roughly 100 to 500x slower |
.itertuples() |
A lightweight namedtuple per row, no dtype damage. | roughly 5 to 20x slower |
Treat those as orders of magnitude rather than measurements. The point is the shape of the ladder and the reason for each step, not the numbers.
Why iterrows is pathological, specifically
Two separate problems, and the second is worse than the first.
One: it constructs a Series per row. A DataFrame is stored by column, so its memory holds one contiguous array per column. A row is not a thing that exists in memory. To hand you one, pandas has to reach into every column, pull out the element at that position, and assemble a brand new Series object with its own index built from the column names. That is an allocation and a full index construction for every row, and it is thrown away immediately afterwards.
Two: it destroys your dtypes. A Series
holds one dtype. A row spanning an int column, a float
column and a string column cannot be one dtype, so pandas
upcasts everything to object. Your int64
becomes a boxed Python int and your float64 becomes a boxed
float. So iterrows is slow and lossy: values
can change type mid-loop, integers can silently become
floats, and comparisons can behave differently from the same
comparison on the column.
A DataFrame is stored column by column, so a row does not exist as an object until you ask for one. iterrows builds a fresh Series with its own index for every row, and because a row spans several dtypes it has to upcast everything to object. So you are paying an allocation and a boxing per row, and you are getting different types back than the columns hold.
The escape ladder when a loop feels necessary
-
Can it be arithmetic on whole columns?
Usually yes, including conditional logic via
np.whereornp.select. -
Is it a group calculation? Then it is
groupbyplusaggortransform, not a loop over groups. -
Does it depend on the previous row? Look
for
cumsum,cumprod,shift,diff,ffill,rolling,expanding. Most sequential logic is one of these in disguise. -
Is it genuinely path dependent, like a
state machine or an order book replay? Then drop to NumPy
arrays with
df['x'].to_numpy()and loop over those. You keep the loop and lose the pandas overhead, which is usually a 10 to 50 times improvement on its own. If it is still too slow, that loop is now in a shape numba or Cython can compile.
Small frames, one-off analysis, and group-level apply
where the function operates on an entire sub-frame rather
than a row. groupby(...).apply(fn) calls the
function once per group, so with a thousand groups you pay
a thousand calls rather than a million. The rule is about
how many times the Python function is called, not about
the word "apply".
Resampling, OHLC, and the label and closed arguments
This is the cluster this desk cares most about. Getting
label and closed wrong does not
raise an error, it shifts every bar you produce by one period,
which is a lookahead bug wearing a disguise.
Two independent questions
Resampling puts each timestamp into a time bin. Two settings control the details, and they are genuinely independent of each other:
-
closedasks: which end of the interval is inclusive?closed='left'means a tick exactly at 09:30:00 belongs to the bin starting at 09:30.closed='right'means it belongs to the previous bin. -
labelasks: what timestamp do we put on the resulting row?label='left'stamps it with the bin's start,label='right'with its end.
The defaults are closed='left' and
label='left' for most frequencies, which means
a bar stamped 09:30 covers 09:30:00 up to but not including
09:31:00. The exceptions are the calendar-ish frequencies
such as ME, W, QE and
YE, which default to right-closed and
right-labelled because a monthly period is naturally named
by its final day.
bars = ticks['price'].resample('1min', label='left', closed='left').ohlc()
vol = ticks['size'].resample('1min', label='left', closed='left').sum()
bars['volume'] = vol
.ohlc() gives you first, max, min and last per
bin in one call, which is exactly a candlestick. Note that
on a bin with no ticks, open, high, low and close are all
NaN while a summed volume is 0, so an empty bar is
detectable and you should decide deliberately whether to
forward fill it or drop it.
Take a bar covering 09:30:00 to 09:31:00. Label it left, and it is stamped 09:30, but its close is not known until 09:31:00. Any model that reads the 09:30 row and believes it had that information at 09:30 is using a full minute of the future. The bar is correct; the interpretation of its timestamp is what kills you. Either label the bars right, so the stamp is the moment the information became available, or keep left labels and shift by one bar before using them as features. Pick one convention and write it down.
Resample bin inspector
Rolling, and what min_periods is for
s.rolling(20).mean() # first 19 values are NaN
s.rolling(20, min_periods=1).mean() # no NaN, but early values use fewer points
s.rolling(20, min_periods=5).mean() # NaN until 5 observations, then compute
s.rolling('5min').mean() # time-based window, needs a DatetimeIndex
The default is min_periods=window, which is why
a 20-period mean starts with 19 NaNs. That default is the
honest one: a "20-day average" computed from 3 points is not
a 20-day average, and silently returning one would let an
unreliable early value flow into everything downstream.
Setting min_periods=1 removes the NaNs at the
cost of the first few values being noisier than the rest,
computed from fewer observations. Sometimes that is what you
want, at a series start where you would otherwise throw away
real data. Often it is a quiet way of pretending you have
more history than you do. Setting it to something in
between, say half the window, is usually the honest
compromise.
Count-based against time-based windows
rolling(20) takes the last 20 rows.
rolling('5min') takes every row within the last
5 minutes of wall clock time. On irregularly spaced data
these are completely different, and the time-based version
is almost always what you meant. Twenty ticks might span a
second during the open and an hour at lunchtime, so a
count-based window silently changes its time horizon with
market activity.
expanding() is a rolling window that grows
from the start, so it is the running version of a
statistic. ewm(span=n) is exponentially
weighted, so it needs no window length and reacts faster
to recent data.
rolling(...).apply(fn, raw=True) lets you use
a custom function, and raw=True passes a
NumPy array instead of building a Series per window, which
is a large speedup and the same lesson as
iterrows.
rolling is right-aligned by default: the
value at row i uses rows i−n+1 through i, all of
which are in the past. That is correct for a signal.
Passing center=True makes the window straddle
the point, which means it reads future data, and that is
legitimate for smoothing a chart and catastrophic for a
feature. If center=True appears anywhere near
a backtest, it is a bug.
shift, diff, and the lookahead trap
Two trivial functions and the single most expensive class of bug in quantitative work.
s.shift(1) # move values DOWN one row: each row now holds the previous value
s.shift(-1) # move values UP one row: each row now holds the NEXT value
s.diff() # s - s.shift(1)
s.pct_change() # s.diff() / s.shift(1)
s.shift(freq='1D') # shifts the INDEX by a duration, not the values by rows
Get the sign right by reading it as:
a positive shift brings the past forward.
After shift(1), row i holds what row i−1
held, so the row can see its own history. A negative shift
brings the future backward, which is only ever correct for
constructing a target variable, never for a feature.
Every value on row t must have been knowable at time t. If a feature uses the bar's own close, or a rolling window that includes the current bar's outcome, or anything shifted by a negative amount, it is using information the market had not produced yet.
The correct shape of a signal
# WRONG: today's return decides today's position
df['signal'] = (df['ret'] > 0).astype(int)
df['pnl'] = df['signal'] * df['ret'] # perfect. and impossible.
# RIGHT: yesterday's information decides today's position
df['signal'] = (df['ret'] > 0).astype(int).shift(1)
df['pnl'] = df['signal'] * df['ret']
The wrong version produces a beautiful upward-sloping equity curve, a Sharpe ratio in the double digits, and no money. It is the single most common error in backtesting because nothing about it looks wrong: no exception, no warning, and a result so good that it is tempting to believe.
The tell is the result itself. A backtest with a Sharpe over about 3 on daily data is far more likely to have lookahead than to have alpha. Treat an implausibly good result as a bug report rather than a discovery, and go looking for the shift you forgot.
Lookahead bias detector
merge_asof and the prevailing quote
The join that exists specifically for market data, and the one worth being able to name unprompted even if you would check the signature.
The problem it solves
You have trades at irregular times and quotes at different irregular times. For each trade you want the quote that was standing at that instant. An ordinary merge on timestamp finds almost nothing, because a trade at 09:30:01.234567 has no quote at exactly that microsecond. What you want is not equality but "the most recent quote at or before this trade".
pd.merge_asof(
trades.sort_values('time'),
quotes.sort_values('time'),
on='time',
by='symbol', # match within symbol first, exactly
direction='backward', # the default: last quote at or before the trade
tolerance=pd.Timedelta('2s'), # beyond this, leave it NaN rather than lie
)
| Argument | Meaning |
|---|---|
direction='backward' |
The default, and the only safe one for features. Matches the last right-hand row at or before the left timestamp. |
direction='forward' |
The next row at or after. This reads the future, so it is for measuring outcomes, never for building signals. |
direction='nearest' |
Whichever is closer in either direction. Also reads the future. Fine for reconciliation, not for backtests. |
by='symbol' |
Group first and match exactly on these columns, then do the as-of match within each group. Without it you will attach one symbol's quote to another symbol's trade. |
tolerance |
Maximum allowed gap. Past it, the result is NaN. This is what stops a stale quote from an hour ago being silently attached to a trade. |
allow_exact_matches |
Default True. Set False when a quote stamped identically to the trade may in fact be the quote the trade caused, which would be lookahead at microsecond resolution. |
merge_asof requires it and will raise if you forget. This
is not fussiness: the implementation is a merge walk down
two sorted sequences, essentially a vectorised
searchsorted, which is what makes it O(n + m)
rather than a nested comparison. The sortedness is the
algorithm, not a precondition someone added for tidiness.
merge_asof is np.searchsorted with a friendly
interface. That is a good thing to say, because it shows
the pandas function is not magic and that you know what it
costs.
merge_asof aligner
Timezones, localize and convert
Two operations that sound alike and are opposites
naive = pd.Timestamp('2024-03-10 09:30') # a wall clock reading. no zone.
aware = naive.tz_localize('America/New_York') # ATTACH a zone. the clock reading is unchanged.
utc = aware.tz_convert('UTC') # TRANSLATE. the instant is unchanged, the reading moves.
-
tz_localizetakes a naive timestamp and asserts which zone that wall clock reading was taken in. The digits stay the same, the meaning becomes definite. -
tz_converttakes an aware timestamp and expresses the same physical instant in another zone. The digits change, the instant does not.
Calling tz_convert on a naive timestamp raises,
because there is nothing to convert from. Calling
tz_localize on an aware one raises for the same
reason in reverse. If you see either error, you have the
wrong function.
Why localize is the risky one
Converting is pure arithmetic and cannot fail. Localizing can, because wall clock time is not a bijection with real time on the two days a year that DST changes.
-
The spring gap. Clocks jump from 02:00 to
03:00, so 02:30 never happened. Localizing it raises
NonExistentTimeErrorunless you passnonexistent='shift_forward'or similar. -
The autumn overlap. Clocks fall back, so
01:30 happens twice, an hour apart. Localizing it raises
AmbiguousTimeErrorunless you say which one withambiguous='infer'or an explicit boolean array.
Store everything in UTC, convert to a local zone only at the display boundary, and never do arithmetic on naive timestamps that came from more than one place.
The reason this works: UTC has no DST, so every day is exactly 24 hours and durations are simple subtraction. In a DST-observing zone, the difference between two wall clock readings is not necessarily the elapsed time between them, which quietly breaks any duration, any rolling window and any resample that straddles a transition.
Exchange sessions are defined in local time, so US market open is 09:30 New York, which is 13:30 UTC in summer and 14:30 UTC in winter. Hard-coding a UTC session filter gives you the wrong hour for half the year. Convert to the exchange's zone to slice sessions, then convert back. And note that a merge between a naive and an aware series raises rather than silently misaligning, which is one of the few places pandas protects you by default.
The operations to write without hesitating
# filter
df[df['px'] > 100]
df.loc[(df.px > 100) & (df.symbol == 'AAPL'), ['time', 'px']]
df.query('px > 100 and symbol == "AAPL"') # readable for long conditions
df[df.symbol.isin(['AAPL', 'MSFT'])] # membership, not a chain of ==
df[df.symbol.str.startswith('A')] # .str gives vectorised string ops
# group and aggregate
df.groupby('symbol').agg(px=('px', 'mean'), n=('px', 'size'))
df.groupby(['symbol', 'side'])['qty'].sum().unstack(fill_value=0)
df.pivot_table(index='date', columns='symbol', values='px', aggfunc='last')
# merge
trades.merge(ref, on='symbol', how='left', validate='many_to_one')
# missing data
df.isna().sum() # the first thing to run on new data
df.dropna(subset=['px']) # drop rows missing a specific column
df['px'].ffill(limit=5) # carry forward, but not indefinitely
df['px'].fillna(df.px.median())
df['px'].interpolate(method='time') # respects irregular spacing
ffill on prices asserts that the last known
price still holds, which is reasonable across a quiet
second and false across an overnight gap or a halt. Always
bound it with limit=, and never
bfill a feature, since backward filling
copies future values into the past and is lookahead by
construction.
Memory levers you can name
astype('float32') or
astype('int32') halves a numeric column.
Worth doing on features, not on prices or on anything you
accumulate.
Int64 with a capital I holds integers
alongside a missing-value mask, so a column with gaps
stays integral instead of being upcast to float.
pd.read_csv(..., chunksize=n) or reading
Parquet by row group processes a file larger than memory.
Parquet is columnar, so it also lets you read only the
columns you need.