Skip to content

NumPy Broadcasting

Broadcasting is how NumPy does maths on arrays of different shapes. When one array is smaller, NumPy behaves as if it were repeated ("stretched") to match the bigger one, but without actually copying any data. You already used it every time you wrote prices * 2: the single number 2 was broadcast to every element.

Picture a teacher adding 5 grace marks to every student. The teacher does not write "5" 30 times; they say it once and it applies to everyone. That is broadcasting.

The rule

NumPy compares the two shapes from the right, one size at a time. Two sizes fit when:

  1. they are equal, or
  2. one of them is 1 (it gets stretched).

A missing size on the left counts as 1. If any pair does not fit, you get the error "operands could not be broadcast together".

Shape AShape BResultWhy
(3, 4)(4,)(3, 4)4 = 4; B's missing size counts as 1
(3, 4)(3, 1)(3, 4)1 stretches to 4; 3 = 3
(3, 4)(3,)error4 and 3 are different and neither is 1

How to run the examples

Create a new file named broadcasting.py in your NumpyTutorials folder (see the setup in lesson 01). Any name works; this one just matches the topic. Start the file with these lines:

import numpy as np

Then copy the examples of this page into the same file, one after another, in the order shown, and run it with python broadcasting.py (with your virtual environment active). Some examples use variables created in earlier ones, so keep everything in the one file. Each Output box shows what that example prints.

1. Array and a scalar

prices = np.array([[100, 200, 300],
                   [400, 500, 600]])
print("prices + 10:\n", prices + 10)          # 10 is "stretched" to every element
Output
prices + 10:
 [[110 210 310]
 [410 510 610]]

The simplest case: a single number fits every shape.

2. 2D array and a row

# 3 days x 4 products, and one discount per product
sales = np.array([[10, 20, 30, 40],
                  [15, 25, 35, 45],
                  [12, 22, 32, 42]])
price_per_product = np.array([5, 10, 2, 8])       # shape (4,)

print("sales shape", sales.shape, "+ prices shape", price_per_product.shape)
print("revenue = sales * price (each COLUMN gets its price):\n", sales * price_per_product)
Output
sales shape (3, 4) + prices shape (4,)
revenue = sales * price (each COLUMN gets its price):
 [[ 50 200  60 320]
 [ 75 250  70 360]
 [ 60 220  64 336]]

sales is (3, 4): 3 days x 4 products. price_per_product is (4,): one price per product. Compared from the right, 4 = 4, and the missing size counts as 1, so the prices are used for every row. Each column is multiplied by its own price.

3. 2D array and a column

bonus_per_day = np.array([[1],
                          [2],
                          [3]])                    # shape (3, 1)
print("sales + bonus (each ROW gets its bonus):\n", sales + bonus_per_day)
Output
sales + bonus (each ROW gets its bonus):
 [[11 21 31 41]
 [17 27 37 47]
 [15 25 35 45]]

bonus_per_day has shape (3, 1), a column. The 3 matches the rows, and the 1 is stretched across the 4 columns. So each row gets its own bonus: day 0 gets +1, day 1 gets +2, day 2 gets +3.

4. Column x row = table

numbers = np.arange(1, 6)
table = numbers[:, np.newaxis] * numbers           # (5, 1) * (5,) -> (5, 5)
print("multiplication table:\n", table)
Output
multiplication table:
 [[ 1  2  3  4  5]
 [ 2  4  6  8 10]
 [ 3  6  9 12 15]
 [ 4  8 12 16 20]
 [ 5 10 15 20 25]]

numbers[:, np.newaxis] is a column (5, 1), and numbers is a row (5,). Both get stretched, the column across and the row down, giving a (5, 5) result: a complete multiplication table in one line.

5. Which shapes fit?

np.broadcast_shapes applies the rule without doing any maths. It is a quick way to check shapes before you calculate.

pairs = [((3, 4), (4,)), ((3, 4), (3, 1)), ((5, 1, 4), (3, 1)), ((2, 3), (3, 2)), ((3, 4), (3,))]
for left, right in pairs:
    try:
        print(f"{str(left):<10} + {str(right):<7} -> {np.broadcast_shapes(left, right)}")
    except ValueError:
        print(f"{str(left):<10} + {str(right):<7} -> does NOT fit")
Output
(3, 4)     + (4,)    -> (3, 4)
(3, 4)     + (3, 1)  -> (3, 4)
(5, 1, 4)  + (3, 1)  -> (5, 3, 4)
(2, 3)     + (3, 2)  -> does NOT fit
(3, 4)     + (3,)    -> does NOT fit

Look at (5, 1, 4) with (3, 1): from the right, 4 and 1 fit (giving 4), 1 and 3 fit (giving 3), and 5 with nothing fits (giving 5). The result is (5, 3, 4).

6. Fixing a shape that does not fit

daily_target = np.array([50, 60, 70])              # one per DAY (row), shape (3,)
try:
    sales - daily_target                           # (3, 4) - (3,)  -> no
except ValueError as error:
    print("Error:", error)

print("make it a column with [:, np.newaxis]:\n", sales - daily_target[:, np.newaxis])
Output
Error: operands could not be broadcast together with shapes (3,4) (3,) 
make it a column with [:, np.newaxis]:
 [[-40 -30 -20 -10]
 [-45 -35 -25 -15]
 [-58 -48 -38 -28]]

We have one target per day (row), shape (3,). NumPy compares 3 with the last size of sales, which is 4 (products), so it fails. NumPy always lines shapes up from the right; it does not guess that you meant rows. The fix is to make the targets a column with [:, np.newaxis], shape (3, 1).

When you see "operands could not be broadcast together", print both shapes, write them right-aligned under each other, and you will see which size does not fit. Usually an np.newaxis or keepdims=True fixes it.

7. Broadcasting does not copy data

row = np.array([1, 2, 3])
stretched = np.broadcast_to(row, (4, 3))
print(stretched)
print("strides:", stretched.strides, "(0 = the same row is reused, no copy)")
Output
[[1 2 3]
 [1 2 3]
 [1 2 3]
 [1 2 3]]
strides: (0, 8) (0 = the same row is reused, no copy)

np.broadcast_to shows what the stretched array "looks like". Its strides (how many bytes to jump to the next row) is 0: every "row" is the same memory, read again. That is why broadcasting is fast and uses no extra memory. Lesson 16 explains strides.

8. Real example

# Share of each product in a day's sales (each row adds up to 100%)
day_totals = sales.sum(axis=1, keepdims=True)      # shape (3, 1)
print("share of the day in %:\n", (sales / day_totals * 100).round(1))

# Standardise each column: (value - column mean) / column std
heights_weights = np.array([[160, 55], [172, 70], [168, 65], [181, 80]], dtype=float)
z = (heights_weights - heights_weights.mean(axis=0)) / heights_weights.std(axis=0)
print("standardised (z-scores):\n", z.round(2))

# Distance of each shop from each warehouse (points on a map)
shops = np.array([[0, 0], [3, 4], [6, 8]])          # (3, 2)
warehouses = np.array([[0, 0], [5, 5]])             # (2, 2)
difference = shops[:, np.newaxis, :] - warehouses   # (3, 1, 2) - (2, 2) -> (3, 2, 2)
distance = np.sqrt((difference ** 2).sum(axis=2))
print("distance (rows = shops, columns = warehouses):\n", distance.round(2))
Output
share of the day in %:
 [[10.  20.  30.  40. ]
 [12.5 20.8 29.2 37.5]
 [11.1 20.4 29.6 38.9]]
standardised (z-scores):
 [[-1.36 -1.39]
 [ 0.23  0.28]
 [-0.3  -0.28]
 [ 1.42  1.39]]
distance (rows = shops, columns = warehouses):
 [[ 0.    7.07]
 [ 5.    2.24]
 [10.    3.16]]
  • Percentages: sum(axis=1, keepdims=True) keeps the day totals as a column (3, 1), so each row is divided by its own total, and each row adds up to 100%.
  • Standardising: subtracting the column means (2,) and dividing by the column standard deviations works on every row. The result (z-scores) shows how far each person is from the average, in standard deviations.
  • Distances: shops (3, 1, 2) minus warehouses (2, 2) gives every shop-warehouse pair (3, 2, 2) at once. Then Pythagoras gives a 3x2 distance table, with no loop at all.

Exercises

Try these exercises on your own before moving on:

  1. Add np.array([1, 2, 3]) to every row of np.zeros((4, 3)).
  2. Add np.array([10, 20, 30, 40]) to every COLUMN of np.zeros((4, 3)).
  3. Make an addition table of 1..9 with broadcasting.
  4. Will (4, 1, 3) and (2, 1) broadcast? Check with np.broadcast_shapes.
  5. Each row of marks has a different maximum: divide each row by its own max.

Summary

  • Broadcasting lets arrays of different shapes work together by stretching sizes of 1.
  • Compare shapes from the right: sizes fit when they are equal or one of them is 1.
  • A row (n,) applies to every row; a column (m, 1) applies to every column.
  • Fix shape errors with np.newaxis or keepdims=True; check with np.broadcast_shapes.
  • Broadcasting never copies data, so it is fast and memory-friendly.

Next: Lesson 11: Aggregations, axis and NaN-safe Functions.