00Overview 01Python semantics 02Python runtime 03NumPy 04pandas 05Operating systems 06Networking 07Resume project 0812-interview plan
04 · pandas

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.

Predict without running

.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.

Predict before revealing
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.

The habit to adopt Use .loc and .iloc everywhere in code, and say both axes explicitly: df.loc[mask, 'price'] rather than df[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.
Mechanism

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:

The Copy-on-Write rule 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.
  • SettingWithCopyWarning is 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.

The pattern that broke on upgrade

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.

If asked what you would say to an interviewer

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.

Predict without running

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?

original, 6 rows A 10 A 20 B 30 B 40 B 50 C 60 .agg("mean") .transform("mean") agg: 3 rows A 15.0 B 40.0 C 60.0 index is now the group key. rows collapsed. transform: 6 rows A 15.0 A 15.0 B 40.0 B 40.0 B 40.0 C 60.0 same index as the input, so it assigns straight back as a column.

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().
Predict without running

What a merge does with duplicate keys

The join types are the easy part. The row count is where people get hurt.

Predict before revealing
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.

Silent no-match

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.

join and concat, in one line each

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.

Mechanism

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.

Say it like this 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

  1. Can it be arithmetic on whole columns? Usually yes, including conditional logic via np.where or np.select.
  2. Is it a group calculation? Then it is groupby plus agg or transform, not a loop over groups.
  3. 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.
  4. 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.
Where apply is genuinely fine

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".

Mechanism · highest yield

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:

  • closed asks: 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.
  • label asks: 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.

Why this is a lookahead trap and not a cosmetic choice

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.

Widget

Resample bin inspector

Nine ticks on a timeline, resampled into bins. Toggle closed and label and watch the boundary ticks change bin and the row stamps move. The two edge ticks that sit exactly on a boundary are the ones to watch.

closed
label
bin

Mechanism

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.

Three neighbours worth naming

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 windows are backward looking, and centre is not

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.

Mechanism · highest yield

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.

The rule that prevents the bug 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.

Widget

Lookahead bias detector

A random price series and a trivially simple signal: go long when the return was positive. Slide the shift and watch a strategy that cannot lose become a strategy that does nothing, which is the honest answer.

signal shift shift(0)
Total return
Sharpe, annualised
Win rate

Mechanism · highest yield

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.
Both sides must be sorted on the key

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.

The connection worth making out loud

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.

Widget

merge_asof aligner

Quotes on the top track, trades on the bottom. Drag the slider to move the selected trade in time and watch which quote it matches. Switch direction to see why forward and nearest are lookahead, and tighten the tolerance to see matches drop to NaN rather than go stale.

direction
tolerance none
trade time t = 42

Mechanism

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_localize takes a naive timestamp and asserts which zone that wall clock reading was taken in. The digits stay the same, the meaning becomes definite.
  • tz_convert takes 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 NonExistentTimeError unless you pass nonexistent='shift_forward' or similar.
  • The autumn overlap. Clocks fall back, so 01:30 happens twice, an hour apart. Localizing it raises AmbiguousTimeError unless you say which one with ambiguous='infer' or an explicit boolean array.
The convention that removes the whole problem 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.

The one that actually bites in market data

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.

From memory

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
Filling forward is a modelling decision

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.

Name them as memory levers

Memory levers you can name

PyArrow backendArrow-backed dtypes give real memory savings on strings, proper missing-value support for integers and booleans, and faster I/O. pandas 3.0 made a PyArrow-backed type the default for string columns, which alone removes the old object-dtype cost that made string columns so expensive.
CategoricalStores a column as small integer codes plus one table of distinct values. For a symbol column with a few thousand names repeated across a hundred million rows, that is an enormous saving, and it makes groupby faster too.
Downcastingastype('float32') or astype('int32') halves a numeric column. Worth doing on features, not on prices or on anything you accumulate.
Nullable dtypesInt64 with a capital I holds integers alongside a missing-value mask, so a column with gaps stays integral instead of being upcast to float.
Chunked readingpd.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.
Copy-on-WriteAlready covered above, but worth naming here too, since a side effect of removing defensive copying is lower peak memory in 3.0 than in 2.x for the same pipeline.