learn.aathan.in

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.

0-D scalar 1-D vector 2-D matrix 3-D tensor ndim 0ndim 1ndim 2ndim 3
Each nesting level of lists adds one dimension to the array.
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 arrayPython list
Element typesall the same (dtype)anything mixed
Memorycompact, contiguouspointers to objects
Speedfast (vectorized in C)slower (Python loops)
* operatorelement-wise mathrepeats the list
Sizefixed at creationgrows and shrinks
Best fornumeric data, matricesgeneral collections

Takeaways

  • NumPy’s ndarray is 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, .dtype tell you everything about an array.
  • Index 2-D arrays as arr[row, col] and slice as arr[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.