Views vs Copies
Views vs Copies
Core Principle: Slicing creates a strided view sharing memory with the parent array, whereas boolean masking and integer indexing create independent memory copies.
The demonstration
array([ 0, 1, 999, 3, 4, 5, 6, 7, 8, 9])
The table
| Operation | Result |
|---|---|
a[2:5], a[::2], a[:, 0] |
view |
a.T, a.reshape(...) (when compatible), a.ravel() |
view (usually) |
a0, 2, 4 |
copy |
a[a > 5] |
copy |
a.flatten() |
copy (always) |
a.astype(...) |
copy (always) |
a.copy() |
copy |
np.asarray(a) |
same object if already an array |
a + 0, any arithmetic |
new array |
np.concatenate, np.stack |
new array |
Note ravel() vs flatten(): ravel returns a view when it can, flatten always copies. If you want a guaranteed-independent 1-D version, use flatten. If you want speed and will not write to it, use ravel.
reshape returns a view when the requested shape is compatible with the existing strides. After a transpose it usually is not, so a.T.reshape(-1) silently copies. See Memory Layout and Strides.
How to check
b.base is a # True → b is a view of a
b.base # None → b owns its data
b.flags.owndata # False → it's a view
np.shares_memory(a, b) # definitive, can be slow
np.may_share_memory(a, b) # fast, conservative
b.base is a is the everyday tool. np.shares_memory is the one to reach for when debugging something genuinely confusing.
Why views exist
They are the reason NumPy scales.
array([[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
...,
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.]], shape=(1000, 100000))
If slicing copied, every intermediate step in a pipeline over a large matrix would double your memory. Views make chained slicing free.
Where it hurts
1. The function that mutates its argument.
def normalise(matrix):
matrix -= matrix.mean(axis=1, keepdims=True) # IN-PLACE: mutates the caller's array!
return matrix
expr_norm = normalise(expr) # expr is now also normalised
The -= operates on the array the caller passed. Either document that clearly, or copy first:
This code completed without producing output.
x = x - y rebinds to a new array; x -= y mutates in place. In NumPy those are genuinely different operations, unlike for immutable Python types.
2. Keeping a small slice of a huge array alive.
big = np.zeros((100_000, 1000))
small = big[0:10]
del big
# the full 800 MB is STILL allocated: `small` holds a reference through .base
small = big[0:10].copy() # ← the fix
This is a real memory leak pattern when you slice a large file-loaded array and keep only the slice.
3. Chained assignment that goes nowhere.
This code completed without producing output.
No error, no warning, no effect. This is the exact same failure mode as pandas chained assignment: see Copy-on-Write, which is why pandas 3.0 made it impossible.
Assignment is different from selection
This code completed without producing output.
So masked reads copy but masked writes do modify the original. This asymmetry is correct but surprising the first time.
Defensive practice
- Copy at API boundaries. A function that takes an array from a caller and mutates it should say so in its name (
normalise_inplace) or copy. - Copy after slicing something large you want to keep.
roi = big[a:b].copy(). - Use
np.shares_memorywhen debugging "why did this change". - Prefer explicit
out=over relying on in-place operators when the intent matters. See ufuncs.
Bioinformatics example
# subsetting to a gene panel from a whole-transcriptome matrix
panel_idx = np.flatnonzero(np.isin(gene_names, PANEL))
panel_expr = expr[panel_idx] # fancy indexing → copy, safe
del expr # the big matrix is genuinely freed
# a chromosome window from a whole-genome coverage array
window = coverage[start:end] # view: do NOT modify unless you mean to
window_own = coverage[start:end].copy() # safe to modify
Common mistakes
- Mutating a view and corrupting the source.
- Expecting a mask-selected copy to write back.
a[mask][0] = xdoes nothing. - A hidden memory leak from a small view of a big array.
ravel()when you neededflatten().- Assuming
reshapenever copies. After a transpose it usually does. - Confusing
x = x + 1withx += 1on a view. The first is safe; the second writes through.
See also
Indexing and Slicing · Boolean Masking · Fancy Indexing · Memory Layout and Strides · Copy-on-Write · ndarray
Exercise
Write aliasing(values, threshold), which demonstrates which ways of selecting
from an array give a view and which give a copy. Return ["empty"] for an empty
array.
Take three selections: the strided slice values[1::2], the fancy selection
values[[0, -1]], and the boolean selection values[values > threshold].
Return six lines: slice_shares=<bool>, slice_base_is_source=<bool>,
fancy_shares=<bool> and mask_shares=<bool>, testing each with
np.shares_memory and, for the slice only, also comparing .base against the
original. Then assign 0 through the slice and 99 through the fancy selection and
report after=<list>, the original array afterwards, and picked=<list>, the
fancy selection afterwards. A single-element array is the case that separates
the two slice tests: the strided slice is empty, so it shares no memory with
anything, yet its .base is still the array it came from.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 3 1 2 3 4 5 6 | slice_shares=True slice_base_is_source=True fancy_shares=False mask_shares=False after=[1, 0, 3, 0, 5, 0] picked=[99, 99] |
| 2 | stdin | 10 1 2 3 | slice_shares=True slice_base_is_source=True fancy_shares=False mask_shares=False after=[1, 0, 3] picked=[99, 99] |
| 3 | stdin | 0 7 | slice_shares=False slice_base_is_source=True fancy_shares=False mask_shares=False after=[7] picked=[99, 99] |
| 4 | stdin | 5 | empty |
Hints · 2
01Hint
np.shares_memory returns False for an empty selection because there is no overlapping byte to find.
02Hint
Writing through a view changes the source; writing through a fancy or boolean selection changes only the copy.