NumPy Basics: Arrays, Dimensions & Slicing
Getting started with NumPy — why arrays beat lists (with real speed and memory measurements), creating ndarrays, dimensions, dtypes, indexing and slicing in 1-D and 2-D, and the array-generating helpers.
NumPy (Numerical Python) is the foundation of the entire Python data science stack — Pandas, scikit-learn, and TensorFlow all sit on top of it. Its core contribution is one object: the ndarray, a fast, compact, N-dimensional array.
If you know Python lists, you already know the shape of the ideas here. The difference is that NumPy arrays are typed, contiguous in memory, and operate on whole arrays at once — which makes them dramatically faster.
Installing and importing
# pip install numpy (run once in your terminal)
import numpy as np # np is the universal convention - always use it
print(np.__version__) # => 2.0.2
Why NumPy? Lists vs arrays, measured
Two concrete reasons: memory and speed.
Memory
import numpy as np
import sys
py_list = range(10)
print("size of the list:", sys.getsizeof(py_list) * len(py_list))
# => size of the list: 480
np_arr = np.arange(10)
print("size of the array:", np_arr.itemsize * np_arr.size)
# => size of the array: 80
The array uses ~6x less memory — it stores raw numbers back-to-back, while a list stores pointers to full Python objects.
Speed
import numpy as np
import time
list1, list2 = range(1_000_000), range(1_000_000)
arr1, arr2 = np.arange(1_000_000), np.arange(1_000_000)
start = time.time()
result = [(a * b) for a, b in zip(list1, list2)] # Python loop
print("list:", time.time() - start)
# => list: 0.101
start = time.time()
arr3 = arr1 * arr2 # vectorized - one operation
print("array:", time.time() - start)
# => array: 0.005
~20x faster. Notice the syntax too: arr1 * arr2 multiplies a million pairs
in one expression, with no loop. That’s called vectorization, and it’s the
whole point of NumPy.
Where the speed actually comes from
Two things, and it’s worth understanding both because they explain every other NumPy design decision.
1. Uniform types remove per-item overhead. A Python list can hold anything,
so every element is a full Python object carrying its own type information. When
you write a * b in a loop, Python must — for every single pair — check the
types, look up the right multiply function, allocate a new object for the
result, and update reference counts. A NumPy array stores raw numbers of one
fixed type, so none of that per-item bookkeeping is needed.
2. The loop runs in C, not Python. arr1 * arr2 hands the entire operation
to precompiled C code that loops over contiguous memory at machine speed. The
Python interpreter is involved once, not a million times.
That’s also why the memory difference exists: the list stores a million pointers to a million separate objects scattered around memory, while the array stores a million numbers packed end to end. Packed memory is not just smaller — it’s faster to read, because the CPU can pull in neighbouring values in a single cache line.
The practical rule: if you’re writing a for loop over a NumPy array,
there’s almost always a vectorized expression that replaces it and runs an order
of magnitude faster.
Creating arrays
Pass a list (or tuple) to np.array():
import numpy as np
arr = np.array([1, 2, 3, 4, 5])
print(arr) # => [1 2 3 4 5] (note: no commas when printed!)
print(type(arr)) # => <class 'numpy.ndarray'>
arr = np.array((1, 2, 3, 4, 5)) # a tuple works too
A very common beginner error — you must pass one sequence, not loose arguments:
arr = np.array(1, 2, 3, 4, 5)
# TypeError: array() takes from 1 to 2 positional arguments but 6 were given
arr = np.array([1, 2, 3, 4, 5]) # correct - wrap them in a list
Dimensions (0-D to 3-D)
An array’s number of dimensions is its rank, available as .ndim.
import numpy as np
a = np.array(42) # 0-D: a single scalar
b = np.array([1, 2, 3, 4, 5]) # 1-D: a vector
c = np.array([[1, 2, 3], [4, 5, 6]]) # 2-D: a matrix
d = np.array([[[1, 2, 3], [4, 5, 6]], [[1, 2, 3], [4, 5, 6]]]) # 3-D
print(a.ndim) # => 0
print(b.ndim) # => 1
print(c.ndim) # => 2
print(d.ndim) # => 3
print(c)
# => [[1 2 3]
# [4 5 6]]
Array attributes
arr = np.array([[1, 2, 3], [4, 5, 6]])
arr.ndim # => 2 number of dimensions
arr.shape # => (2, 3) 2 rows, 3 columns
arr.size # => 6 total number of elements
arr.dtype # => dtype('int64') the type of every element
arr.itemsize # => 8 bytes per element
Unlike a list, every element in an array has the same type (dtype). This
isn’t an arbitrary restriction — it’s the foundation of everything above. Because
every element is, say, exactly 8 bytes, NumPy can compute the memory address of
element n with simple arithmetic, and C loops can march through the data
without inspecting anything.
The consequence is type coercion: if you build an array from mixed types, NumPy silently promotes everything to the narrowest type that can hold them all. Watch the third example carefully — one string turns the entire numeric array into strings, which will break any maths you try afterwards:
np.array([1, 2, 3.5]) # => [1. 2. 3.5] (all become floats)
np.array([1, 2, "hi"]) # => ['1' '2' 'hi'] (all become strings!)
np.array([1, 2, 3], dtype=float) # => [1. 2. 3.] (set it explicitly)
Generating arrays without typing them out
np.arange(10) # => [0 1 2 3 4 5 6 7 8 9] (like range)
np.arange(2, 10, 2) # => [2 4 6 8] (start, stop, step)
np.zeros((2, 3)) # a 2x3 grid of 0.0
# => [[0. 0. 0.]
# [0. 0. 0.]]
np.zeros((2, 3, 2)) # 3-D: two 3x2 blocks of zeros
np.ones((2, 2)) # => [[1. 1.] [1. 1.]]
np.full((2, 2), 7) # => [[7 7] [7 7]]
np.eye(3) # 3x3 identity matrix
np.linspace(0, 1, 5) # => [0. 0.25 0.5 0.75 1. ] (5 evenly spaced)
np.random.rand(2, 2) # 2x2 of random floats in [0, 1)
Indexing
1-D — exactly like a list
arr = np.array([1, 2, 3, 4])
arr[0] # => 1
arr[-1] # => 4 (negative index from the end)
arr[2] = 99 # arrays are mutable -> [1, 2, 99, 4]
2-D — use [row, column]
arr = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
arr[0, 1] # => 2 row 0, column 1
arr[1, -1] # => 10 last element of the 2nd row
arr[1] # => [6 7 8 9 10] a whole row
The comma form arr[1, -1] is the NumPy way (a list would need arr[1][-1]).
3-D — [block, row, column]
arr = np.array([[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]])
arr[0, 1, 2] # => 6 first block, second row, third column
Slicing — [start:end:step]
The same rules as list slicing: start is
inclusive, end is exclusive, and all three parts are optional.
arr = np.array([1, 2, 3, 4, 5, 6, 7])
arr[1:5] # => [2 3 4 5] index 1 up to (not incl.) 5
arr[4:] # => [5 6 7] from index 4 to the end
arr[:4] # => [1 2 3 4] from the start to index 4
arr[-3:-1] # => [5 6] negative indices work too
arr[1:5:2] # => [2 4] every 2nd element in that range
arr[::2] # => [1 3 5 7] every 2nd element overall
arr[::-1] # => [7 6 5 4 3 2 1] reversed
Slicing 2-D arrays
Give a slice for each dimension, separated by a comma: arr[rows, columns].
arr = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
arr[1, 1:4] # => [7 8 9] row 1, columns 1-3
arr[0:2, 2] # => [3 8] column 2 from both rows
arr[0:2, 1:4] # => [[2 3 4]
# [7 8 9]] a 2x3 sub-grid
arr[:, 0] # => [1 6] the whole first column
arr[0, :] # => [1 2 3 4 5] the whole first row
Views vs copies — a critical difference from lists
Slicing a list gives you a brand-new list. Slicing a NumPy array gives you a view: a lightweight window onto the same underlying memory. No data is duplicated, so modifying the view modifies the original array.
arr = np.array([1, 2, 3, 4])
part = arr[0:2]
part[0] = 99
print(arr) # => [99 2 3 4] <- the original changed!
safe = arr[0:2].copy() # use .copy() when you want independence
This is a deliberate performance decision: slicing a 10-million-row array to
work on a section shouldn’t silently duplicate 80 MB of memory. The cost is that
you must stay aware of it — a function that “just tweaks a slice” can quietly
mutate its caller’s data. When in doubt, .copy().
Vectorized operations
Operations apply to every element at once — no loop needed.
arr = np.array([1, 2, 3, 4])
arr + 10 # => [11 12 13 14]
arr * 2 # => [2 4 6 8]
arr ** 2 # => [ 1 4 9 16]
arr > 2 # => [False False True True] (a boolean mask)
arr[arr > 2] # => [3 4] boolean indexing - keep only matching items
a = np.array([1, 2, 3])
b = np.array([10, 20, 30])
a + b # => [11 22 33] element-wise
a * b # => [10 40 90]
Compare that to lists, where [1,2,3] * 2 repeats the list instead of
multiplying — a frequent source of confusion.
[1, 2, 3] * 2 # => [1, 2, 3, 1, 2, 3] (list: repetition)
np.array([1, 2, 3]) * 2 # => [2 4 6] (array: multiplication)
Useful array methods
arr = np.array([3, 1, 4, 1, 5])
arr.sum() # => 14
arr.mean() # => 2.8
arr.min() # => 1
arr.max() # => 5
arr.std() # => standard deviation
np.sort(arr) # => [1 1 3 4 5]
arr.reshape(...) # change the shape without changing the data:
np.arange(6).reshape(2, 3) # => [[0 1 2]
# [3 4 5]]
arr2 = np.array([[1, 2], [3, 4]])
arr2.flatten() # => [1 2 3 4] (back to 1-D)
arr2.T # transpose -> [[1 3] [2 4]]
For 2-D arrays, axis picks the direction: axis=0 works down columns,
axis=1 across rows.
m = np.array([[1, 2], [3, 4]])
m.sum() # => 10 everything
m.sum(axis=0) # => [4 6] column sums
m.sum(axis=1) # => [3 7] row sums
NumPy arrays vs Python lists
| NumPy array | Python list | |
|---|---|---|
| Element types | all the same (dtype) | anything mixed |
| Memory | compact, contiguous | pointers to objects |
| Speed | fast (vectorized in C) | slower (Python loops) |
* operator | element-wise math | repeats the list |
| Size | fixed at creation | grows and shrinks |
| Best for | numeric data, matrices | general collections |
Takeaways
- NumPy’s
ndarrayis typed, compact, and vectorized — measurably smaller in memory and roughly an order of magnitude faster than list loops. np.array()takes one sequence:np.array([1, 2, 3])..ndim,.shape,.size,.dtypetell you everything about an array.- Index 2-D arrays as
arr[row, col]and slice asarr[rows, cols]. - Slices are views — use
.copy()when you need an independent array. - Do math on whole arrays (
arr * 2,arr[arr > 2]) instead of writing loops.
Next up: Pandas, which builds labelled tables on top of these arrays.