Back to the map
DataFoundation18h

NumPy & pandas

Array and table manipulation, vectorised.

NumPy is a Python library for holding large grids of numbers and doing arithmetic on all of them in one instruction, rather than one at a time in a loop. pandas builds on it for labelled tables with named columns and mixed types, which is the shape real data arrives in; its table is called a DataFrame, and you filter, join and group it in memory on your own machine.

A Python loop over a million numbers takes seconds; the same work as one NumPy operation takes milliseconds, because it drops into compiled code. Speed aside, every machine learning library expects data in this form, so being unable to reshape into it means being unable to feed anything. Some ML coding rounds are conducted in NumPy with no framework available.

It builds on Python and on linear algebra, since an array is a vector or matrix and the operations are the ones that subject defines. It pairs with SQL, which does similar filtering and grouping while the data is still in a database; pandas takes over once it is on your machine, and training, evals and analysis all pass through it.

Think in whole arrays rather than loops; that is both where the speed comes from and the idiom every library expects.

In more detailVectorised operations, broadcasting, indexing, joins and group-bys. The skill is thinking in whole arrays rather than loops, both for speed and because it is how every ML API expects data shaped. Shape debugging is most of the day-to-day work.

Section 1 of 4, 6h

Thinking in arrays

By the end: You can manipulate data without loops, which is how every ML library expects it.

  • Vectorised operations
  • Indexing, masking and reshaping
  • pandas for tabular work
  • Group-by and joins

Every numerical library in this field expects whole-array operations rather than loops, and the gap is not small: the loop version of a job can be a hundred times slower than the same thing expressed as one expression over the whole array. This section is about thinking that way, along with the indexing, masking and reshaping that make it possible, and the table operations in pandas that most real data work actually consists of.

Stop writing loops#

A loop over a NumPy array is usually a hundred times slower than the vectorised equivalent, because the loop runs in Python while the vectorised version runs in compiled code. Learning to see operations as whole-array is the main skill here.

import numpy as np

# Slow, and how most people first write it
result = []
for x in data:
    result.append(x * 2 + 1)

# Fast, and shorter
result = data * 2 + 1

Two things make this worth the adjustment. The obvious one is speed: the Python version pays interpreter overhead per element, boxing each number into an object, while the array version dispatches once into compiled code that walks contiguous memory. The less obvious one is that every library you will hand this data to — scikit-learn, PyTorch, anything — expects arrays, so a list of Python floats has to be converted anyway.

The axis argument#

This is the single thing people get wrong most often, and the rule is short: axis names the dimension that disappears. An array of shape (2, 3) summed along axis 0 loses the 2 and leaves shape (3,); summed along axis 1 it loses the 3 and leaves shape (2,). Say it that way rather than in terms of rows and columns, because the rows-and-columns version stops working the moment there are three dimensions.

a = np.array([[1, 2, 3],
              [4, 5, 6]])            # shape (2, 3)

a.sum()                              # 21          everything, shape ()
a.sum(axis=0)                        # [5 7 9]     shape (3,)  the 2 is gone
a.sum(axis=1)                        # [ 6 15]     shape (2,)  the 3 is gone
a.sum(axis=1, keepdims=True).shape   # (2, 1)      kept as a length-1 dimension
a.argmax(axis=1)                     # [2 2]       index of the max in each row
Check the shape, not the numbers. On a 2x3 array the shape tells you unambiguously which axis you collapsed; the numbers often do not.

The same rule applies to any array. For a batch of token embeddings of shape (batch, sequence, features), averaging over axis=1 collapses the sequence and gives one vector per item, which is mean pooling. Averaging over axis=0 collapses the batch, which is almost never what anyone meant. Negative indices count from the end, so axis=-1 is the last dimension and is the safe way to write an operation that should apply to features regardless of how many leading dimensions there are.

def softmax(z):
    """Over the last axis, so it works on (n,), (batch, n) and (batch, seq, n)."""
    z = z - z.max(axis=-1, keepdims=True)      # subtract the max: stops exp overflowing
    e = np.exp(z)
    return e / e.sum(axis=-1, keepdims=True)

logits = np.array([[2.0, 1.0, 0.1],
                   [0.5, 0.5, 0.5]])
softmax(logits)
# [[0.659 0.242 0.099]
#  [0.333 0.333 0.333]]
softmax(logits).sum(axis=-1)         # [1. 1.]  always check this
keepdims=True is what makes the division line up. Without it the shapes are (2,3) and (2,), and see the trap below.

The axis mistake that does not raise

Normalising rows with m / m.sum(axis=1) fails loudly on a (2, 3) array, because (2,3) and (2,) cannot broadcast. On a square array it succeeds and normalises the columns instead. Take m = [[1, 1], [1, 3]]: the correct row-normalised result is [[0.5, 0.5], [0.25, 0.75]], and the version without keepdims gives [[0.5, 0.25], [0.5, 0.75]], whose rows sum to 0.75 and 1.25. Nothing errors, the numbers look like probabilities, and a model trains on them. Write keepdims=True, and assert the rows sum to one.

Broadcasting, as rules#

Broadcasting is how NumPy lets arrays of different shapes take part in the same operation without you writing a loop or copying data. It is worth learning as three mechanical rules rather than as intuition, because then you can work out what a line does without running it.

1. Line the shapes up from the RIGHT, padding the shorter one with 1s on the left.
2. Two dimensions are compatible if they are equal, or if one of them is 1.
3. A dimension of 1 is stretched to match the other. Anything else is an error.

  (3, 2)      (3, 1)        (2, 3)        (3, 2)
+ (   2)    + (   2)      + (2, 1)      + (   3)
---------   ---------     --------      ---------
  (3, 2)      (3, 2)        (2, 3)        error: 2 and 3 are neither
   ok          ok            ok           equal nor 1
Worth memorising. Every broadcasting error message quotes the two shapes, and these rules tell you which one to change.
x = np.arange(6).reshape(3, 2)       # [[0 1] [2 3] [4 5]]
row = np.array([10, 20])             # (2,)   -> treated as (1, 2), stretched down rows
col = np.array([[100], [200], [300]])  # (3, 1) -> stretched across columns

x + row
# [[10 21]
#  [12 23]
#  [14 25]]

x + col
# [[100 101]
#  [202 203]
#  [304 305]]

x + np.array([1, 2, 3])
# ValueError: operands could not be broadcast together with shapes (3,2) (3,)
The error names both shapes. Read it right to left: 2 against 3, neither equal nor 1, so it stops.

Nothing is copied. A stretched dimension is read repeatedly from the same memory, which is why subtracting a mean vector from a million rows costs nothing extra in space. The tool for making shapes line up is None, also spelled np.newaxis, which inserts a dimension of length 1 wherever you put it.

v = np.array([1, 2, 3])
v[:, None].shape      # (3, 1)  a column
v[None, :].shape      # (1, 3)  a row

# All pairwise differences, with no loop:
v[:, None] - v[None, :]
# [[ 0 -1 -2]
#  [ 1  0 -1]
#  [ 2  1  0]]

# Centre every feature of a (n, d) matrix on its own mean:
X - X.mean(axis=0)                 # (n,d) - (d,)  -> fine, stretches down rows

# Scale every row to unit length:
X / np.linalg.norm(X, axis=1, keepdims=True)    # (n,d) / (n,1)
The pairwise-difference trick is how a distance matrix is built without a double loop. It costs n squared memory, so check the shape before running it on a large n.

Masking#

A boolean array selects elements. This replaces most filtering loops and composes cleanly.

scores = np.array([0.2, 0.9, 0.5, 0.95])

scores > 0.8                  # array([False, True, False, True])
scores[scores > 0.8]          # array([0.9, 0.95])
np.where(scores > 0.8, 1, 0)  # array([0, 1, 0, 1])

(scores > 0.4) & (scores < 0.9)   # & and |, not and/or
Use & and | with arrays. Python's and/or try to evaluate the whole array as one boolean and raise.

Three things follow from this. A mask is itself an array, so masks combine with & and | and invert with ~, and each operand needs its own brackets because & binds tighter than the comparison operators. A mask counts as well as selects, because summing booleans counts the Trues. And indexing with a mask, or with a list of indices, returns a copy rather than a view, which is the opposite of what a plain slice does.

mask = (scores > 0.4) & (scores < 0.9)

mask.sum()                # 1     how many match
mask.mean()               # 0.25  what fraction match
scores[~mask]             # everything that does not
np.where(mask)[0]         # array([2])  the positions, not the values

np.shares_memory(scores, scores[1:3])      # True:  a slice is a view
np.shares_memory(scores, scores[mask])     # False: a mask gives a copy
np.shares_memory(scores, scores[[0, 2]])   # False: so does a list of indices

scores[scores > 0.8] = 0.8    # assignment through a mask does write back
mask.mean() as a fraction is the fastest way to check that a filter did roughly what you expected before you rely on it.

Shapes#

a = np.arange(12)

a.reshape(3, 4)         # explicit
a.reshape(3, -1)        # -1 means "work it out"
a[:, None].shape        # (12, 1)  add an axis
a.T                     # transpose
np.concatenate([a, a])  # join along an existing axis
np.stack([a, a])        # join along a new axis

reshape never moves data; it reinterprets the same buffer, so it is free and it returns a view. It fills the new shape in row-major order, last axis fastest, which is why reshaping (2, 3) to (3, 2) is not a transpose and gives a different arrangement of the same numbers. Transposing also returns a view, one whose strides are swapped rather than whose memory is rearranged, which is why some later operation on it may quietly copy.

a = np.arange(6).reshape(2, 3)
# [[0 1 2]
#  [3 4 5]]

a.reshape(3, 2)      # [[0 1] [2 3] [4 5]]   filled across, then down
a.T                  # [[0 3] [1 4] [2 5]]   not the same thing

np.concatenate([a, a], axis=0).shape   # (4, 3)  more rows
np.concatenate([a, a], axis=1).shape   # (2, 6)  more columns
np.stack([a, a]).shape                 # (2, 2, 3)  a new leading axis

a.squeeze()          # drop every axis of length 1
a.ravel()            # flatten to 1-D, a view when it can be
Getting a (n, 1) where a (n,) was expected is the commonest shape bug in ML code, and squeeze or reshape(-1) is the fix.

Print the shape, not the array

Almost all debugging in array code is shape debugging, and the array is too big to read. Put print(name, x.shape) at each step, or assert x.shape == (batch, n_classes) at the boundaries of a function. An assert on shape catches the class of bug where the numbers are wrong but plausible, which no amount of looking at the output will.

pandas#

A DataFrame is a table with labelled columns. The operations that matter are selection, grouping and joining, which cover most data preparation.

import pandas as pd

df = pd.read_csv("orders.csv")

df.head()                       # look at it, always
df.info()                       # types and null counts
df.describe()                   # distributions

df[df.total > 100]              # filter rows
df[["id", "total"]]             # select columns
df.loc[df.total > 100, "flag"] = True   # conditional assignment

df.groupby("customer_id").agg(
    orders=("id", "count"),
    revenue=("total", "sum"),
)

pd.merge(orders, customers, on="customer_id", how="left")

Two pieces of vocabulary before anything else. A DataFrame has an index as well as columns, and the index is what alignment happens on: adding two Series lines them up by index label, not by position, so a subtraction between a filtered frame and an unfiltered one produces NaN wherever the labels do not match rather than the answer you expected. And there are two accessors: .loc selects by label and .iloc by position, and mixing them up on a frame whose index is not 0..n-1 gives wrong rows silently.

df.loc[3, "total"]          # the row whose index LABEL is 3
df.iloc[3]                  # the FOURTH row, whatever its label
df.loc[df.total > 100, "flag"] = True     # label-based, and the way to assign

sub = df[df.total > 100]    # the index keeps the original labels: 4, 9, 17...
sub.reset_index(drop=True)  # renumber when position is what you want
After a filter the index has holes. sub.iloc[0] and sub.loc[0] are then different things, and the second may raise KeyError.

Assign with .loc, never through two brackets

Writing df[df.total > 100]["flag"] = True is chained assignment: the first bracket produces a new object, the assignment lands on that, and the original frame is unchanged. Older pandas raised SettingWithCopyWarning here, a warning people learned to ignore because it also fired on code that worked. Under the copy-on-write behaviour that is the default from pandas 3 it raises ChainedAssignmentError and tells you plainly that chained assignment never updates the original. Both are saying the same thing, and df.loc[df.total > 100, "flag"] = True is the fix in every version.

The related habit is to call .copy() when you deliberately want an independent frame to work on, as in ok = df[df.status == 'ok'].copy(). It states the intention, and it means later columns you add are yours rather than an attempt to write through a filter.

Group-by and merge, the two that matter#

Group-by splits the frame by a key, applies something to each piece, and combines the results. Which of the three shapes you want depends on what comes out: agg gives one row per group, transform gives one value per original row, and apply gives whatever your function returns and is the slow one to avoid when either of the others will do.

calls = pd.DataFrame({
    "user_id":    [1, 1, 2, 2, 3],
    "model":      ["sonnet", "haiku", "sonnet", "sonnet", "haiku"],
    "tokens":     [1200, 300, 900, 1500, None],
    "latency_ms": [820, 140, 760, 2100, 155],
})

calls.groupby("model").agg(
    calls=("user_id", "size"),           # rows in the group
    users=("user_id", "nunique"),
    tokens=("tokens", "sum"),
    p95=("latency_ms", lambda s: s.quantile(0.95)),
)
#         calls  users  tokens      p95
# model
# haiku       2      2   300.0   154.25
# sonnet      3      2  3600.0  1972.00
Named aggregation: each output column is named, and its definition is a column and a function. It is readable and it avoids the multi-level column names that plain .agg produces.

Note haiku's tokens: two rows, one of them missing, and the sum is 300 rather than an error or a NaN. size counts rows in the group and count counts non-null values in a column, so on that group they give 2 and 1. Choosing the wrong one is how a denominator quietly becomes too small.

calls.groupby("model").size()          # haiku 2, sonnet 3   rows
calls.groupby("model")["tokens"].count()   # haiku 1, sonnet 3   non-null

# transform: one value per original row, so it can go straight back on the frame
calls["model_total"] = calls.groupby("model")["tokens"].transform("sum")
#    user_id   model  tokens  latency_ms  model_total
# 0        1  sonnet  1200.0         820       3600.0
# 1        1   haiku   300.0         140        300.0
# ...
calls["share"] = calls["tokens"] / calls["model_total"]
transform is how you compute a per-group figure and keep every row. Doing it with agg and a merge back is the same answer and three times the code.

merge is a SQL join with a different spelling, and the same things go wrong. Two arguments turn its failure modes into errors instead of wrong numbers: validate states the relationship you believe holds and raises if it does not, and indicator adds a column saying which side each row came from so you can count the rows that failed to match.

users = pd.DataFrame({"user_id": [1, 2, 4], "plan": ["pro", "free", "pro"]})

m = calls.merge(users, on="user_id", how="left",
                validate="many_to_one", indicator=True)
m["_merge"].value_counts()
# both          4
# left_only     1        <- user 3 has no row in users
# right_only    0

# If users had two rows for user_id 1, the same call raises rather than silently
# duplicating: MergeError: Merge keys are not unique in right dataset;
# not a many-to-one merge
#
# Without validate, 5 rows in become 7 rows out and every sum is now wrong.
how takes left, right, inner and outer, matching the SQL join types. validate takes one_to_one, one_to_many, many_to_one and many_to_many.

The habit worth forming is to print len(df) before and after every merge. A row count that changed when you expected a lookup is the single most common cause of a number being wrong in a data pipeline, and it is free to check.

Missing values#

df.isna().sum()                 # how many per column
df.dropna(subset=["total"])     # drop rows missing something essential
df.total.fillna(df.total.median())   # impute

# Whether a value is missing is often informative in itself
df["total_was_missing"] = df.total.isna()
Missingness frequently carries signal. Record it before you impute it away.

Where people go wrong

Fitting an imputer or scaler on the whole dataset before splitting. Information from the test set enters training, the score improves, and the model fails in production.

Build this: Take a messy CSV and clean it without a single explicit loop: handle missing values, filter, group and join. Then time it against a loop-based version.

Open this section on its own page
Section 2 of 4, 4h

Memory, views and larger-than-memory data

By the end: You can avoid the silent bugs and the memory blowups in array code.

  • Views versus copies
  • Data types and memory
  • Working with data too big for memory
  • When pandas is the wrong tool

This is where array code goes quietly wrong. Slicing usually returns a view onto the same memory rather than a copy, so writing to what you thought was a separate array changes the original — no error, wrong numbers. This section covers views and copies, choosing data types so a table fits in memory at all, what to do when it does not, and how to recognise the point where pandas has stopped being the right tool for the size of data in front of you.

Views and copies#

An array is a block of memory plus a description of how to walk it. A view is a second description of the same block, which is why slicing costs nothing regardless of how large the array is, and why writing through a slice writes into the original. This is efficient and it is the source of confusing bugs, because nothing in the syntax tells you which you have.

a = np.arange(10)
b = a[:5]         # a view
b[0] = 99
a[0]              # 99  -- the original changed

c = a[:5].copy()  # an independent copy

The rule is short. Basic indexing — a slice, a step, a transpose, a reshape — returns a view. Advanced indexing — a boolean mask, a list or array of indices — returns a copy, because the selected elements are not evenly spaced and cannot be described by strides. Assignment through either does write to the original, which is the part that makes the rule feel inconsistent until you see why.

np.shares_memory(a, a[2:7])      # True   slice: a view
np.shares_memory(a, a[::2])      # True   step: still a view
np.shares_memory(a, a.T)         # True   transpose: a view
np.shares_memory(a, a[a > 3])    # False  mask: a copy
np.shares_memory(a, a[[0, 2]])   # False  list of indices: a copy

a[2:7].flags["OWNDATA"]          # False  it is borrowing
a[2:7].base is a                 # True   this is who it borrowed from

a[a > 3] = 0                     # but assignment through a mask DOES write back
np.shares_memory is the answer to 'did that copy?'. Reach for it rather than reasoning about it when a number changes that should not have.

The bug this causes, and what it looks like

You split a dataset with train = X[:800] and then normalise train in place with train -= train.mean(0). X is now half-normalised, so the test slice X[800:] was never touched and the two halves are on different scales. The model trains, the loss falls, and the validation score is quietly wrong. Nothing raises. If a function modifies an array it was given, either document that it does or take a copy at the top of it.

pandas has the same question and has answered it differently over time. Under the copy-on-write behaviour that became the default in pandas 3, every operation behaves as though it returned a copy, and the copy is only actually made if you write to it. That removes the ambiguity: chained assignment now raises ChainedAssignmentError rather than the old SettingWithCopyWarning, and .loc is the way to assign in both.

Data types#

Memory scales directly with the data type, and the default is generous. A column of ages stored as the default 64-bit integer uses eight bytes per value to hold a number that fits in one. On a million rows that is 8 MB against 1 MB, and on a table with thirty such columns it is the difference between fitting in memory and not.

dtype      bytes   range or precision              use it for
int8         1      -128 to 127                     small counts, ages, flags
int16        2      -32,768 to 32,767               most counts
int32        4      about +/- 2.1 billion           ids, unless they exceed that
int64        8      the default                     large ids, timestamps in ns
float32      4      about 7 significant digits      embeddings, activations, images
float64      8      about 16 significant digits     the default; money, accumulators
bool         1      True or False
category   varies   stores each distinct string once  repeated strings in pandas
An embedding matrix of a million vectors at 1,536 dimensions is 6.1 GB in float32 and 12.3 GB in float64. That choice alone decides whether it loads.
df.memory_usage(deep=True).sum()     # 70,500,000 on a 1M-row frame

df["age"] = df["age"].astype("int8")
df["country"] = df["country"].astype("category")

df.memory_usage(deep=True).sum()     # 2,000,000 -- 35x smaller
Real figures from a two-column frame of 1M rows: a country name and an age. deep=True is required, or the string column is reported as pointer-sized and you are told it is tiny.

Categorical types for repeated strings are frequently the largest single saving, because a column of a million country names becomes a million small integers plus a lookup table of the handful of distinct values. Use it when the number of distinct values is small relative to the rows, and not when nearly every value is unique, where it adds overhead instead.

Narrow types overflow and truncate in silence

np.int8 holds up to 127. Add 10 to 120 in int8 and you get -126, with no exception and no warning in an array operation. float32 has about seven significant digits, so 1e8 + 1 is exactly 1e8 and a running total accumulated in float32 stops increasing once it is large enough. Narrow the storage type, and keep accumulators and sums in float64: df[col].sum(dtype='float64') costs nothing and removes the whole class of problem.

np.array([120, 100], dtype="int8") + np.int8(10)    # [-126  110], no warning
np.int8(120) + np.int8(10)                          # -126, with a RuntimeWarning

np.float32(1e8) + np.float32(1.0) == np.float32(1e8)    # True

arr = np.arange(5)                  # int64
arr += 0.5
# UFuncTypeError: Cannot cast ufunc 'add' output from dtype('float64')
#                 to dtype('int64') with casting rule 'same_kind'
arr = arr + 0.5                     # this works, and returns a new float64 array
The in-place version raises because it would have to discard the fractional part. The out-of-place version promotes the type instead, which is why the two lines behave differently.

Where the memory actually goes#

A MemoryError almost never happens at the line that allocated the data. It happens at some intermediate result: every arithmetic expression allocates a new array of the same size, so a chain of four operations on a 6 GB matrix briefly needs several copies of it. The fixes are in-place operators, which write into the existing buffer, and doing the work in chunks.

X = X - mean          # allocates a whole new X
X /= std              # in place: no allocation

np.subtract(X, mean, out=X)        # explicit form of the same thing

# Chunking, when even one pass will not fit:
total = 0.0
for chunk in pd.read_csv("events.csv", chunksize=500_000):
    total += chunk["cost_usd"].sum(dtype="float64")
In-place operators mutate, so they interact with the view rule above. Only use them on an array you own.

Data larger than memory#

  • Read in chunks and process each, so memory stays bounded. pandas read_csv takes chunksize and returns an iterator of frames.
  • Use a columnar format such as Parquet and read only the columns you need. A twenty-column CSV read for two columns parses all twenty; Parquet reads two.
  • Give read_csv the dtypes rather than letting it infer them. Inference reads the file to guess, then stores the widest type it saw, and a single stray string turns a numeric column into object.
  • For larger analytical work, an engine like DuckDB or Polars handles data beyond memory efficiently and is often far faster than pandas on a single machine.
  • The point at which pandas is the wrong tool is not a fixed size. It is when one machine's memory cannot hold a few copies of the data, which in practice is somewhere around a few gigabytes on a laptop and tens of gigabytes on a server.

Where people go wrong

Modifying a slice and expecting the original to be unchanged. Slices are usually views, so the original changes too.

Build this: Load a large CSV, measure its memory, then reduce it with appropriate types and categoricals. Record the before and after.

Open this section on its own page
Section 3 of 4, 5h

pandas on a real table

By the end: You can load, inspect, clean, aggregate, join and reshape a real dataset in pandas without loops and without silent mistakes.

  • Loading with the right types
  • Looking before doing anything
  • Filtering and new columns
  • Group-by with named aggregations
  • Joins that check themselves
  • Dates and resampling

pandas is where most data work in Python actually happens: a table held in memory, with named columns and typed values, that you filter, group, join and reshape. This section takes one real dataset from a CSV file through to a weekly summary per model, doing each step the way that survives contact with bad data. The habits are the point, particularly declaring types on load, looking at the rows before transforming them, and asking a join to check its own assumptions. They are what separates a number you can quote from one that is quietly wrong.

Step            Command                                    What it saves you from
load            read_csv(dtype=..., parse_dates=...)       silent object columns, 10x memory
look            info(), describe(), value_counts()         a whole analysis of bad data
filter          df[mask].copy()                            writing through a filtered view
derive          vectorised arithmetic, not apply           a job that takes an hour
aggregate       groupby().agg(named=("col", "fn"))         unreadable multi-level columns
join            merge(validate=..., indicator=True)        rows multiplying, totals doubling
resample        set_index(date).resample("D")              missing days vanishing from a chart
Worth memorising as a checklist. Every line of it exists because of a specific way the obvious version goes wrong.

Load it properly#

import pandas as pd

calls = pd.read_csv(
    "llm_calls.csv",
    parse_dates=["created_at"],
    dtype={"model": "category", "status": "category", "user_id": "int64"},
)

Stating the types does three jobs. It stops the inference that turns a column with one stray value into object, which is pandas storing Python objects and is both slow and eight times the memory. It makes dates into real timestamps so that comparison, sorting and resampling work, rather than strings where '2026-3-1' sorts after '2026-12-01'. And a category column stores each distinct string once, which on a model or status column is usually the largest saving in the frame. Add usecols to read only the columns you need, and nrows=1000 while you are still working out what the file contains.

Look first#

calls.info()                           # columns, types, missing counts, memory
calls.describe()                       # numeric summaries: look for impossible values
calls["status"].value_counts(dropna=False)
calls.sample(5, random_state=0)        # read actual rows

Five minutes here saves hours later. Negative token counts, a status of 'OK ' with a trailing space, or a column that is 40% empty change everything you do next.

Read each of those for something specific. info tells you whether any column came in as object when you expected a number, and how many nulls each has. describe shows min and max, which is where impossible values live: a negative token count, a latency of zero, a date in 1970 that is really a missing value written as an epoch. value_counts with dropna=False is the one that finds the trailing space, because 'ok' and 'ok ' appear as two rows; add normalize=True and it gives proportions, which is how you notice that twenty percent of calls errored. And sample beats head, because the first five rows of a file are often a header artefact or the oldest and least representative data in it.

calls["status"].value_counts(normalize=True, dropna=False)
# ok       0.8
# error    0.2

calls.duplicated(subset=["request_id"]).sum()     # are the keys actually unique
calls["created_at"].agg(["min", "max"])           # is the date range what you think
calls.isna().mean().sort_values(ascending=False)  # fraction missing, worst first
Four lines that answer the four questions that invalidate an analysis: what proportion, are keys unique, what period, and how complete.

Filter and derive#

ok = calls[calls["status"] == "ok"].copy()
ok["cost_usd"] = ok["input_tokens"] * 3e-6 + ok["output_tokens"] * 15e-6   # vectorised
ok["slow"] = ok["latency_ms"] > 5_000
Illustrative prices. .copy() states that this is now your own frame, rather than an attempt to write through a filter, which does not update the original.

Derive columns with arithmetic on whole columns, never with apply over rows. The difference is not marginal. Computing a product two ways on two hundred thousand rows: apply with axis=1 took 1,445 ms and the vectorised expression took 0.32 ms, a factor of about four and a half thousand, because apply builds a Python Series object for every row.

# 1,445 ms on 200,000 rows
df["c"] = df.apply(lambda r: r["a"] * r["b"], axis=1)

# 0.32 ms on the same rows
df["c"] = df["a"] * df["b"]

# For branching, np.where and np.select stay vectorised:
import numpy as np
ok["tier"] = np.where(ok["cost_usd"] > 0.01, "expensive", "cheap")
ok["band"] = np.select(
    [ok["latency_ms"] < 500, ok["latency_ms"] < 2000],
    ["fast", "normal"],
    default="slow",
)
np.select is the vectorised if/elif/else chain, and the one people reach for apply instead of. Conditions are evaluated in order; default catches the rest.

Group and aggregate#

per_model = ok.groupby("model", observed=True).agg(
    calls=("user_id", "size"),
    users=("user_id", "nunique"),
    cost=("cost_usd", "sum"),
    p95_ms=("latency_ms", lambda s: s.quantile(0.95)),
).sort_values("cost", ascending=False)

#         calls  users     cost  p95_ms
# model
# sonnet      2      2  0.01290  817.00
# haiku       2      2  0.00225  154.25
observed=True matters on a categorical key: without it, pandas produces a row for every category that exists in the type, including ones with no rows in this frame.

Three details are worth knowing here. size counts rows and count counts non-null values in the named column, so on a column with nulls they disagree and the wrong one gives you a denominator that is too small. The group key becomes the index; add as_index=False, or call reset_index(), when you want it back as a column for a later merge. And to keep every original row while attaching a per-group figure, use transform rather than agg, which is how a share-of-group column is computed in one line.

ok["model_cost"] = ok.groupby("model", observed=True)["cost_usd"].transform("sum")
ok["share_of_model"] = ok["cost_usd"] / ok["model_cost"]

# Top 3 calls per user by cost, the pandas spelling of the SQL window pattern:
top3 = (ok.sort_values("cost_usd", ascending=False)
          .groupby("user_id", observed=True)
          .head(3))
groupby().head(n) after a sort is the whole top-N-per-group pattern. It does not need a rank column.

Joins that check themselves#

users = pd.read_csv("users.csv")
merged = ok.merge(users, on="user_id", how="left", validate="many_to_one", indicator=True)
print(merged["_merge"].value_counts())      # how many calls found no user?
# both          4
# left_only     1
# right_only    0

validate raises an error if users has duplicate ids, which would otherwise silently multiply your rows and your cost totals. indicator shows how many rows failed to match. Without validate, five rows joined to a users table with one duplicate id come back as seven, every sum over them is inflated, and there is no error and no warning — only a revenue figure that is wrong in a way nobody notices for a month.

# MergeError: Merge keys are not unique in right dataset;
#             not a many-to-one merge

# When it fires, find them and decide what one row per key means:
dupes = users[users.duplicated("user_id", keep=False)].sort_values("user_id")
users_latest = (users.sort_values("updated_at")
                     .drop_duplicates("user_id", keep="last"))
Usually the right table is a history and you wanted the latest row per key. drop_duplicates after a sort is the pandas version of the SQL latest-row pattern.

Check the row count across every merge

assert len(merged) == len(ok) after a left join you believe is a lookup costs nothing and catches the entire class of quiet duplication. Do the same after a filter you believe removes nothing. Most wrong numbers in a data pipeline are a row count that changed when it should not have.

Time#

daily = ok.set_index("created_at").resample("D")["cost_usd"].sum()
# 2026-03-01    0.00885
# 2026-03-02    0.00540
# 2026-03-03    0.00000     <- days with no calls are present, as zero
# ...
# 2026-03-09    0.00090

weekly_by_model = ok.pivot_table(index=pd.Grouper(key="created_at", freq="W"),
                                 columns="model", values="cost_usd", aggfunc="sum",
                                 observed=True)
# model         haiku  sonnet
# created_at
# 2026-03-01  0.00135  0.0075
# 2026-03-08      NaN  0.0054     <- NaN, not 0: no haiku calls that week
resample fills the gaps in time, which is what groupby on a date column does not do. That difference is why a chart built with groupby joins straight across an outage.

Two things to get right about time. resample needs a datetime index, which is what the set_index is for; Grouper does the same job inline when the frame has other grouping keys. And a frequency string means the period it ends on: W is weeks ending Sunday by default, ME is month end, MS is month start. Getting that wrong shifts every figure by a period, which is hard to see and easy to present.

Store timestamps in UTC and convert only when displaying. A daily total computed in local time counts the wrong hours on the days the clocks change, and comparing two days across a daylight saving boundary compares 23 hours with 25.

calls["created_at"] = pd.to_datetime(calls["created_at"], utc=True, errors="coerce")
calls["created_at"].isna().sum()     # rows whose date would not parse: check this

local = calls["created_at"].dt.tz_convert("Europe/London")
calls["hour"] = local.dt.hour
calls["date"] = local.dt.date
errors='coerce' turns unparseable dates into NaT rather than raising. That is useful only if you then count them, which is what the second line is for.

Where people go wrong

Looping over rows with iterrows. It is often a hundred times slower than the vectorised equivalent and hides bugs in per-row logic.

Build this: Take any public CSV with a date, a category and a number. Load it with correct types, inspect it, produce a daily and weekly summary per category, join a second table with validate, and write down three data problems you found.

Open this section on its own page
Section 4 of 4, 3h

When pandas is slow: vectorising, Polars and DuckDB

By the end: You can measure a slow data step and choose between vectorising, a faster file format, Polars or DuckDB.

  • Measure first
  • Parquet instead of CSV
  • Polars and lazy queries
  • SQL over files with DuckDB

You can already do the work in pandas. This is what to do when a step that used to take seconds takes twenty minutes, or dies with a memory error. Four things usually fix it: replacing a Python loop with a vectorised expression, storing the data as Parquet rather than CSV, and moving to Polars or DuckDB, both of which run on one machine and handle files larger than memory. Knowing them means you reach for a cluster when you genuinely need one, which is far rarer than people assume.

Measure#

Find the slow step before changing anything, because the answer is routinely not where people assume. Time the stages separately rather than the whole script, and look at memory as well as seconds: a job that swaps to disk looks like a slow job and is really a memory problem.

%timeit df["total"] = [a * b for a, b in zip(df["qty"], df["price"])]   # Python loop
%timeit df["total"] = df["qty"] * df["price"]                            # vectorised

# Measured on 200,000 rows:
#   df.apply(..., axis=1)   1,445 ms
#   df["a"] * df["b"]           0.32 ms      about 4,500x

import time
t = time.perf_counter()
df = pd.read_parquet("calls.parquet")
print(f"load {time.perf_counter() - t:.1f}s  {df.memory_usage(deep=True).sum()/1e9:.1f} GB")
In a notebook, %timeit runs it several times and reports the best. Outside one, time.perf_counter around each stage is enough to find the step that dominates.
What is slow                          What actually fixes it
a per-row apply or an explicit loop   vectorise, or np.select for branching
reading the same CSV repeatedly       convert to Parquet once
reading 30 columns to use 3           Parquet plus a column list
merging on a string key               narrow the dtype, or merge on an integer id
it fits, but only just                narrower dtypes and categoricals
it does not fit at all                Polars lazy, or DuckDB over the files
none of the above                     then, and only then, a cluster
Worth memorising in order. The last line is where most teams start, and it is rarely where the problem is.

Parquet#

CSV is text: every read parses every value. Parquet stores typed columns, compressed, and lets readers load only the columns and row groups they need. Converting a large CSV to Parquet once often makes every later step several times faster and the files much smaller.

Three properties do the work. It is columnar, so reading two columns of thirty reads two columns of data rather than every byte of every row. It carries a schema, so a numeric column comes back numeric with no inference, and a type error in the data is caught at write time rather than discovered a week later. And it stores statistics per row group, so a reader can skip whole blocks that cannot match a filter. Partitioning the files by date on disk takes that further: a query for one day opens one directory.

df.to_parquet("calls.parquet", compression="zstd")
df.to_parquet("calls/", partition_cols=["date"])     # calls/date=2026-03-11/...

pd.read_parquet("calls.parquet", columns=["user_id", "cost_usd"])   # two columns only
pd.read_parquet("calls/", filters=[("date", "=", "2026-03-11")])    # one partition
Use Parquet for anything you will read more than once. Keep CSV for handing a file to a human.

Polars#

import polars as pl

result = (
    pl.scan_parquet("calls/*.parquet")            # lazy: nothing is read yet
      .filter(pl.col("status") == "ok")
      .group_by("model")
      .agg(pl.col("latency_ms").quantile(0.95).alias("p95"), pl.len().alias("calls"))
      .collect()                                  # plan optimised, then run in parallel
)

The word doing the work is scan rather than read. Nothing happens until collect, so Polars sees the whole chain first and can push the filter down into the file reader and load only the columns the query mentions. A pandas version of the same chain reads every column of every file into memory and then discards most of it, which is why the difference is often large rather than marginal. Polars also uses every core by default, where pandas uses one.

The API is different enough to be worth an hour of adjustment. There is no index, which removes a whole category of alignment surprises. Expressions are built with pl.col rather than by subscripting the frame, so a filter is written as a value passed to filter rather than a boolean frame. And there is no in-place mutation: you add columns with with_columns and get a new frame back.

pandas                                    Polars
df[df.status == "ok"]                     df.filter(pl.col("status") == "ok")
df["c"] = df["a"] * df["b"]               df.with_columns((pl.col("a") * pl.col("b")).alias("c"))
df.groupby("k").agg(n=("a", "sum"))       df.group_by("k").agg(pl.col("a").sum().alias("n"))
pd.read_parquet(p)                        pl.read_parquet(p), or scan_parquet for lazy
df.merge(o, on="k", how="left")           df.join(o, on="k", how="left")
df.to_pandas() is not needed              df.to_pandas() when a library demands pandas
Enough to translate a script. Reach for Polars when a pandas step is slow or will not fit, not as a rewrite of everything that already works.

DuckDB#

import duckdb

duckdb.sql("""
    SELECT model, count(*) AS calls, quantile_cont(latency_ms, 0.95) AS p95
    FROM 'calls/*.parquet'
    WHERE status = 'ok'
    GROUP BY model
""").df()
SQL straight over files, fast, in process, no server.

DuckDB is a database engine that runs inside your process with no server to install and no data to load. It reads Parquet, CSV and JSON files directly, and it also reads a pandas or Polars frame that is already in your session by name, which makes it easy to drop into an existing script for the one step that is slow. It streams, so it will happily group by over files larger than memory, and it uses every core.

calls = pd.read_parquet("calls.parquet")     # an ordinary pandas frame

duckdb.sql("SELECT model, sum(cost_usd) FROM calls GROUP BY model").df()
# 'calls' is found in the enclosing Python scope; nothing was copied in

duckdb.sql("COPY (SELECT * FROM 'raw/*.csv') TO 'out.parquet' (FORMAT parquet)")
# convert a directory of CSVs to Parquet without loading any of it

con = duckdb.connect("analysis.duckdb")      # or persist it to a file
The last two are the ones worth remembering: a one-line CSV-to-Parquet conversion, and a persistent file when the analysis outlives the session.

Choosing between them is mostly about which language you want to write. DuckDB when the operation is naturally a query and especially when it joins several files, Polars when it is a chain of transformations inside Python code. Both run on one machine, and a single machine with fast disks handles tens of gigabytes comfortably, which is more than most jobs that get moved to a cluster.

Where people go wrong

Reaching for a cluster when a columnar file format and a single-machine engine would handle the data in seconds.

Build this: Generate a 10 million row dataset, save it as CSV and Parquet, and time the same group-by in pandas on each, in Polars and in DuckDB. Record time and peak memory.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

RAG with citations you can check
  • Stage 2, Embed and index: One matrix multiply searches the whole corpus; a Python loop over chunks does not.
Take a model from a table to an endpoint
  • Stage 1, Get a real table and find out what is wrong with it: Loading and interrogating a table that was not prepared for you, which is every table.
  • Stage 4, Engineer features inside a pipeline: Chained assignment and silent copies are how a feature ends up computed on the wrong frame and nobody notices.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Remove every loop

8h

Take a data processing script with explicit loops and rewrite it fully vectorised. Time both. Then do the same for a pandas pipeline using groupby and merge instead of iterrows.

Done when: The vectorised version is correct and dramatically faster, and you can say by how much.

2Clean something genuinely messy

7h

Take a real CSV with missing values, inconsistent types and duplicates. Clean it, recording missingness as a feature before imputing, and document every decision.

Done when: Another person can read your notebook and understand why each cleaning decision was made.

15 hours of practice

Interview questions

7 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Thinking in arrays

Why is vectorised NumPy faster than a Python loop?

The loop executes in the Python interpreter with per-element overhead, while the vectorised operation runs in compiled code over contiguous memory, often using multiple values per instruction. The difference is typically one to two orders of magnitude.

What does SettingWithCopyWarning mean?

You may be assigning to a copy rather than the original DataFrame, so the change silently does nothing. It usually follows a filter, and using .loc for conditional assignment avoids both the warning and the bug.

How would you handle missing values?

First find out whether missingness is informative, and if so record it as a feature before imputing. Then drop rows missing something essential, and impute the rest with a median or a model, fitted inside the training fold to avoid leakage.

Memory, views and larger-than-memory data

What is the difference between a view and a copy in NumPy?

A view shares memory with the original array, so modifying it changes the original; slicing usually returns a view. A copy is independent. Confusing them causes silent modifications.

How do you reduce the memory a DataFrame uses?

Choose smaller numeric types where the range allows, convert repeated strings to categorical, and read only the columns you need from a columnar format.

pandas on a real table

After a join, your total revenue doubled. What happened?

The right-hand table probably had duplicate keys, so each left row matched several right rows and was repeated. Check key uniqueness before joining, use validate to enforce the expected relationship, and compare row counts before and after.

Then they push further

How would you find the duplicates?

Group the right table by the join key and count, or use duplicated on the key column, and inspect those rows. Often it is a history table where each change adds a row, and you need the latest one per key.

What gets marked up. Marked up for naming duplicate keys immediately and for a preventive habit such as validate.

When pandas is slow: vectorising, Polars and DuckDB

A pandas job on a 20 GB CSV runs out of memory. What are your options?

Read only needed columns with compact dtypes, process in chunks, convert to Parquet, or use a tool that streams and parallelises such as Polars in lazy mode or DuckDB, which can query files larger than memory. Move to a distributed engine only if a single machine truly cannot cope.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.

For when pandas is slow: vectorising, Polars and DuckDB