datarekha

Matrix multiplication

np.dot, np.matmul, and the @ operator — what they do, where they differ, and how to read the shape rules.

6 min read Intermediate NumPy Lesson 11 of 14

What you'll learn

  • When np.dot, np.matmul, and @ behave differently
  • The shape compatibility rule for matrix multiplication
  • Outer, inner, and cross products

Before you start

The Vectorization chapter treated arrays as bags of independent numbers. This one breaks that: matrix multiplication mixes elements — every output is a dot product woven from a whole row and a whole column. It is the engine behind every neural-network layer, every linear-regression fit, every PCA decomposition. NumPy gives you three ways to spell it — np.dot, np.matmul, and the @ operator — and they do not quite agree at the edges, which is exactly where the bugs live.

The three ways

import numpy as np

A = np.array([[1, 2],
              [3, 4]])
B = np.array([[5, 6],
              [7, 8]])

# Three spellings, same result for 2D arrays
print("np.dot:\n",    np.dot(A, B))
print("np.matmul:\n", np.matmul(A, B))
print("A @ B:\n",     A @ B)
np.dot:
 [[19 22]
 [43 50]]
np.matmul:
 [[19 22]
 [43 50]]
A @ B:
 [[19 22]
 [43 50]]

For two 2-D matrices they are identical. The differences surface at the edges — 1-D vectors and 3-D+ tensors.

The shape rule

For A @ B to work, the last axis of A must equal the second-to-last axis of B. The result drops those two and keeps the rest.

Why? Each output element is a dot product (sum of element-wise products) between a row of A and a column of B. Rows and columns must be the same length — that is why the inner k must agree.

A: (m, k) @ B: (k, n)(m, n)2D matmulA: (b, m, k) @ B: (b, k, n)(b, m, n)batched (leading axis broadcasts)A: (m, k) @ B: (k,)(m,)matrix · vectorinner k cancelsouter dims survive
Same rule everywhere: inner axis cancels, the rest survives.
TryMatrix multiply

Watch row times column, summed

Each cell of C = the dot product of one row of A and one column of B. Step through to see each multiply-and-accumulate, or press Play to run automatically.

A (2×3)
2
0
1
3
1
2
B (3×2)
1
3
4
0
2
1
C (2×2)
Press Step or Play to start.

When you see ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, that is NumPy telling you the inner dimensions don’t line up. Print the shapes and read right-to-left.

Where dot, matmul, and @ diverge

np.dot has legacy behaviour — for 1-D arrays it does an inner product, for higher dims it sums over a single axis. np.matmul (and @) treat the last two axes as the matrix and broadcast everything else. That matters the moment you have a batch dimension.

import numpy as np

# Batch of 4 matrices, each 2x3
A = np.arange(24).reshape(4, 2, 3)
# Batch of 4 matrices, each 3x5
B = np.arange(60).reshape(4, 3, 5)

# matmul / @ broadcast the leading "batch" axis
C = A @ B
print("A @ B shape:", C.shape)   # (4, 2, 5)

# np.dot does NOT broadcast batches the same way
D = np.dot(A, B)
print("np.dot shape:", D.shape)  # (4, 2, 4, 5) — surprising!
A @ B shape: (4, 2, 5)
np.dot shape: (4, 2, 4, 5)

That second result — a 4-D (4, 2, 4, 5) — is almost never what you want: np.dot paired every batch matrix of A with every batch matrix of B. Use @ (or np.matmul). PEP 465 added @ in Python 3.5 precisely because the ambiguity around dot was costing people hours of debugging.

A neural net layer in one line

Every dense layer is output = inputs @ W + b. Here a batch of 64 feature vectors of size 784 flows through a layer that produces 128 hidden units:

import numpy as np

rng = np.random.default_rng(42)

# A batch of 64 flattened 28x28 images
X = rng.standard_normal((64, 784))

# A weight matrix mapping 784 -> 128
W = rng.standard_normal((784, 128)) * 0.01
b = np.zeros(128)

# Forward pass through one dense layer
H = X @ W + b
print("Input:", X.shape, "Weights:", W.shape, "Output:", H.shape)
# (64, 784) @ (784, 128) = (64, 128) — the 784s cancel
Input: (64, 784) Weights: (784, 128) Output: (64, 128)

The 784s on either side of @ are the “inner” dimensions, and they cancel. You are left with (64, 128) — 64 samples, each now represented by 128 hidden activations. That is it. That is every dense layer.

Other “products” you’ll see

NumPy has three more product flavours that come up regularly:

import numpy as np

u = np.array([1, 2, 3])
v = np.array([4, 5, 6])

# Inner (dot) product — scalar
print("inner:", np.inner(u, v))        # 1*4 + 2*5 + 3*6 = 32

# Outer product — matrix shape (3, 3)
print("outer:\n", np.outer(u, v))

# Cross product — only defined for 3D vectors
print("cross:", np.cross(u, v))        # vector perpendicular to both
inner: 32
outer:
 [[ 4  5  6]
 [ 8 10 12]
 [12 15 18]]
cross: [-3  6 -3]

np.outer is the move when you need a rank-1 matrix from two vectors — common in low-rank updates, word-embedding tricks, and deriving backprop by hand. np.cross shows up in graphics and physics.

Reading shape errors

Almost every matmul error is a mismatched inner dimension. The fix is one of:

  1. Transpose one operand (A.T @ B instead of A @ B).
  2. Reshape to add or move an axis (v[:, None] to make a column).
  3. Check the layout — did your data loader produce (features, samples) when you expected (samples, features)?

When in doubt, print(A.shape, B.shape) before the @, and the mismatch is almost always obvious.

In one breath

Matrix multiplication mixes elements: each output is the dot product of a row of A and a column of B, so the inner axes must match(m, k) @ (k, n) → (m, n), the k cancels. Three spellings agree on 2-D (A @ B == np.dot == np.matmul), but on 3-D+ they diverge: @/matmul treat the last two axes as the matrix and broadcast the batch ((4,2,3) @ (4,3,5) → (4,2,5)), while np.dot pairs every batch with every batch ((4,2,4,5)) — so prefer @. Every dense layer is X @ W + b ((64,784) @ (784,128) → (64,128)); attention is Q @ K.T. Also handy: np.inner (scalar), np.outer (rank-1 matrix), np.cross (3-D). Most matmul errors are a mismatched inner dim — print the shapes.

Practice

Quick check

0/4
Q1A has shape (32, 10), B has shape (10, 4). What is `(A @ B).shape`?
Q2You have a batched input X of shape (B, 64, 128) and weights W of shape (128, 32). What's `(X @ W).shape`?
Q3Why prefer `@` over `np.dot`?
Q4In attention, Q and K each have shape (8, 16) — 8 tokens, 16-dimensional embeddings. What is `(Q @ K.T).shape`?

A question to carry forward

Matrix multiplication builds a linear transformation — it takes A and applies it to data. But half of linear algebra is the inverse question: given the transformation A and a result b, what input x produced it? Solve Ax = b. Or: what are A’s hidden axes — its eigenvalues, its SVD — the decompositions that PCA and a hundred other methods quietly run on?

Those answers don’t come from @; they come from np.linalg. The next lesson opens that toolbox. What do np.linalg.solve, inv, eig, and svd actually give you — and why, even when the textbook writes x = A⁻¹b, should you almost never literally invert a matrix in code?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
When should you use apply, map, or applymap versus vectorized pandas operations, and what are the performance implications?

Vectorized pandas and NumPy operations operate on entire arrays in compiled C/Fortran code and should always be your first choice. apply runs a Python function row- or column-wise in a Python loop, map transforms a single Series element-by-element, and applymap (DataFrame.map in pandas 2.1+) applies a function to every scalar — all three are orders of magnitude slower than vectorized equivalents.

Why is NumPy significantly faster than Python for-loops for numerical computation, and what is vectorization?

NumPy operations execute compiled C code over contiguous memory blocks in a single call, while a Python loop incurs interpreter overhead and dynamic type checks on every element. Vectorization means expressing an operation over an entire array at once so the hot path never re-enters the Python interpreter.

What does a convolution operation do in a CNN?

A convolution slides a small learned weight matrix (kernel) across the input, computing a dot product at each position to produce a feature map. Each kernel learns to detect one spatial pattern — an edge, a corner, a texture — regardless of where it appears in the image.

When would you use a Python list versus a NumPy array, and what are the performance trade-offs?

Python lists are heterogeneous, pointer-based, and general-purpose. NumPy arrays are homogeneous, stored as contiguous typed memory, and support vectorised operations that run at C speed. For numerical work on more than a few hundred elements, NumPy is almost always faster and more memory-efficient.

Related lessons

Explore further

Skip to content