Perpendis Array tools
Royalty tools Sign in

Array tools › Blog

Compare two NumPy arrays and find the exact first difference

By Perpendis · Published 19 Sep 2026 · Reviewed 19 Sep 2026

Comparing two NumPy arrays sounds like a one-liner, and for integer data it is. For floating-point results the interesting question is rarely a plain yes or no. It is: are these the same, and if not, where do they first diverge, and by how much.

That shows up constantly in practice: a refactored kernel that should be a no-op, a model ported from float64 to float32, two runs on different BLAS libraries or on GPU versus CPU. A bare a == b quietly lies about all of it, and a single True/False tells you nothing about the one index that regressed.

First differing element012345a1.02.03.04.05.06.0b1.02.03.04.15.06.0first difference, index 3
Two arrays compared elementwise; five values agree and index 3 is the first to differ.

The four tools at a glance

Four comparison toolsa == belementwise · exactnp.iscloseelementwise · tolerantnp.array_equalone bool · exactnp.allcloseone bool · tolerantexacttolerantelementwisesingle bool
The four comparison calls placed on two axes: exact versus tolerant, and elementwise versus a single bool.

NumPy gives you four common ways to compare, and they answer different questions. Choosing the wrong one is the usual root cause of a comparison that either passes when it should fail or fails when it should pass.

ExpressionReturnsToleranceNaN handlingUse it for
np.array_equal(a, b)single boolnone (exact)equal_nan optionexact, whole-array equality with a shape check
np.allclose(a, b)single boolrtol + atolequal_nan optionone yes/no on computed floats
np.isclose(a, b)bool arrayrtol + atolequal_nan optionan elementwise mask so you can locate the differences
a == bbool arraynone (exact)NaN gives Falseinteger or otherwise exact data

One structural difference is easy to miss: np.array_equal checks shapes and returns False when they differ, whereas ==, isclose and allclose broadcast first. More on why that matters below.

Why == is a trap for computed floats

Exact equality only makes sense for values produced by identical operations in identical order. The moment a value is computed, the last few bits depend on rounding, summation order, fused multiply-add, and the maths library underneath. The classic demonstration:

>>> import numpy as np
>>> a = np.array([0.1 + 0.2])
>>> b = np.array([0.3])
>>> a == b
array([False])
>>> np.array_equal(a, b)
False
>>> np.allclose(a, b)
True

The two arrays look identical when printed, but 0.1 + 0.2 lands one representable float away from 0.3. So == and array_equal are correct to say they differ, and also useless as a regression check. What you almost always want for floats is a tolerant comparison, then a way to inspect the differences that survive it.

Finding where they differ

Start from an elementwise boolean mask. For exact data that is a != b; for floats use the tolerant form and invert it, so the mask marks the elements that are not close.

a = np.array([1.0, 2.0, 3.0, 4.0])
b = np.array([1.0, 2.0, 3.5, 4.0])

mask = ~np.isclose(a, b)
# array([False, False,  True, False])

From the mask you can pull the positions in a few ways. np.where(mask) and np.flatnonzero(mask) give every differing index; np.argmax gives the first one, because on a boolean array the first maximum is the first True.

np.where(mask)        # (array([2]),)
np.flatnonzero(mask)  # array([2])

if mask.any():
    first = np.argmax(mask)   # 2

The argmax gotcha. np.argmax returns 0 when the mask is all False, which is indistinguishable from a genuine difference at index 0. Always gate it on mask.any() first, or use np.flatnonzero(mask)[0] and handle the empty case explicitly.

For multi-dimensional arrays, np.argmax works on the flattened array in C order. Turn that flat index back into coordinates with np.unravel_index, or use np.argwhere(mask) to get one row of coordinates per difference.

a2 = np.array([[1., 2., 3.],
               [4., 5., 6.]])
b2 = a2.copy()
b2[1, 2] = 6.5

m = ~np.isclose(a2, b2)
flat = np.argmax(m)              # 5
np.unravel_index(flat, a2.shape) # (1, 2)

Choosing a tolerance, and why zero is dangerous near zero

Both allclose and isclose use the same test, elementwise:

abs(a - b) <= atol + rtol * abs(b)

The defaults are rtol=1e-5 and atol=1e-8. Two things about that formula bite people. First, the reference value is b, the second argument, so the comparison is not symmetric: isclose(a, b) can disagree with isclose(b, a) when the two differ in magnitude. Keep your expected value in a consistent position.

Second, and more important: as b approaches zero the relative term rtol * abs(b) collapses, and atol becomes the entire budget. If your true values are tiny, the default atol=1e-8 can wave through values that are wrong by orders of magnitude.

a = np.array([1e-12])
b = np.array([2e-12])

np.isclose(a, b)            # array([ True])
np.isclose(a, b, atol=0.0)  # array([False])

Here a and b differ by a factor of two, yet the default call reports them close because atol swamps the tiny relative budget. Forcing atol=0 hands the whole test back to rtol, which correctly rejects them.

There is no universal right answer. Set atol to the smallest magnitude you consider genuinely non-zero for your problem, and set rtol to the relative precision you expect, looser for float32 (about seven decimal digits) than float64 (about fifteen to sixteen). A tolerance you picked by feel is a guess; later we measure the gap in a unit that does not require one.

Comparisons in a test suite

For assertions in tests, prefer numpy.testing.assert_allclose over a bare assert np.allclose(...). It raises on failure with a report of the mismatch, including the maximum absolute and relative difference and how many elements disagree, so a failing test tells you how far off it was. Its defaults differ from np.allclose: rtol=1e-7 and atol=0. That atol=0 reintroduces the near-zero trap above, so pass an explicit atol whenever your expected values include zeros.

In PyTorch, torch.testing.assert_close fills the same role and picks default tolerances from the dtype, looser for float16 and bfloat16, tighter for float64. That is sensible when you check a model ported across precisions or run on a different backend, where fused multiply-add, TF32, or a changed reduction order legitimately move the last few bits.

NaN and shape: two silent failure modes

NaN is never equal to itself, so any comparison involving it defaults to not-equal. When NaNs are expected in matching positions, opt in with equal_nan. It is available on array_equal, allclose and isclose.

p = np.array([1.0, np.nan, 3.0])
q = np.array([1.0, np.nan, 3.0])

np.array_equal(p, q)                  # False
np.array_equal(p, q, equal_nan=True)  # True
np.allclose(p, q, equal_nan=True)     # True

The subtler trap is shape. Because ==, isclose and allclose broadcast, comparing a row against a column does not raise. It silently produces a full matrix, and a downstream .all() or .any() then reports something meaningless.

x = np.array([1., 2., 3.])   # shape (3,)
y = x.reshape(3, 1)          # shape (3, 1)

(x == y).shape               # (3, 3)  broadcast
np.array_equal(x, y)         # False   no broadcast

Check a.shape == b.shape before you trust an elementwise comparison, or lean on np.array_equal, which refuses to broadcast and returns False on any shape mismatch.

ULP: measuring the gap without picking a tolerance

ULP distance on a float number line……ULP distance = 2AB1.00000001.0000002each tick = the next representable float
Consecutive representable floats sit at even ticks; A and B are two ULPs apart, no tolerance chosen.

A hand-chosen atol is scale-dependent: 1e-8 is enormous next to 1e-12 and invisible next to 1e6. The scale-free alternative is the ULP, the unit in the last place, the distance between two adjacent representable floats at a given magnitude. Two values that are 1 ULP apart are neighbours with nothing between them; a diff of a few ULPs is ordinary rounding noise, whereas thousands of ULPs is a real divergence, and the same threshold means the same thing across the whole number line.

Because IEEE-754 floats are laid out so that ordering their bit patterns as integers is monotonic, the ULP distance is just the integer distance between those patterns, once you fold the sign so the two zeros meet:

import numpy as np

def ulp_distance(a, b):
    a = np.asarray(a, np.float64)
    b = np.asarray(b, np.float64)

    def ordered(x):
        i = x.view(np.int64).copy()
        lo = np.iinfo(np.int64).min
        neg = i < 0
        i[neg] = lo - i[neg]
        return i

    return np.abs(ordered(a) - ordered(b))

Run it on the opening example and the vague "they print the same but compare unequal" becomes an exact number:

x = np.array([0.1 + 0.2])
ulp_distance(x, np.array([0.3]))   # array([1])

One ULP. That is the tightest a computed result can miss by, and it is why == failed. For float32, take the int32 view and use its integer limits instead. The measure is meaningful for values of the same order; comparing across a huge magnitude gap, or against inf or NaN, needs the special-casing that a dedicated tool already handles.

Paste two arrays, see the first difference. Perpendis diff takes two float arrays (brackets, commas and a tensor(...) wrapper are all accepted) or two .npy files, and reports whether they are bit-identical, the first differing index with the A and B values there, the max and p95 absolute diff, the max relative diff, and the ULP distance. An optional tolerance field restates the measured max against your atol. It runs client-side in WebAssembly, so nothing is uploaded and there is no account. float32 and float64.

The short version: reach for np.array_equal only when you mean exact equality, use np.allclose for a single tolerant verdict and np.isclose when you need the mask, always check shapes and NaNs, and when a comparison fails, locate the first index and quantify the gap in ULPs before you argue about tolerances.

Frequently asked questions

What is the difference between np.array_equal and np.allclose?

np.array_equal tests exact, elementwise equality and also checks that the shapes match, returning a single bool with no tolerance. np.allclose returns a single bool too, but tolerates small floating-point differences via the test abs(a - b) <= atol + rtol * abs(b). Use array_equal for integer or otherwise exact data, and allclose for values that were computed and may differ in their last bits.

How do I get the index of the first element where two arrays differ?

Build a boolean mask (a != b for exact data, or ~np.isclose(a, b) for floats), then take np.argmax(mask), which returns the flat index of the first True. Guard it with mask.any() first, because np.argmax returns 0 when nothing differs, which is indistinguishable from a real difference at index 0. For multi-dimensional arrays, convert the flat index with np.unravel_index(np.argmax(mask), a.shape).

How do I compare two NumPy arrays that contain NaN?

By default NaN is never equal to itself, so any comparison touching it reports not-equal. When NaNs are expected in the same positions and should count as matching, pass equal_nan=True. It is available on np.array_equal, np.allclose and np.isclose, for example np.allclose(a, b, equal_nan=True).

What tolerance should I use when comparing float arrays?

There is no universal value. allclose and isclose default to rtol=1e-5 and atol=1e-8. Set atol to the smallest magnitude you treat as genuinely non-zero, since near zero the relative term collapses and atol becomes the whole budget; set rtol to the relative precision you expect, looser for float32 than float64. To avoid guessing entirely, measure the gap in ULPs, which is scale-free. In test suites, numpy.testing.assert_allclose plays the same role with different defaults (rtol=1e-7, atol=0), so pass an explicit atol when your expected values include zeros.

Sources

Keep reading