NumPy & pandas, 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

New to numpy & pandas? 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. Why it matters

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.

If this comes up in an interview

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.

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

This is one section of NumPy & pandas, 18 hours across 4 sections. Read it in full to get the whole topic in order.