mrkeyoor.com_
Thu 06 Aug 07:43 UTC
PyPIDataupdated 06 Aug 2026

numba

Numba is a just-in-time compiler that turns numeric Python functions into machine code through LLVM. You put @njit above a function; the first time you call it, Numba inspects the types of the arguments you actually passed, compiles a version of the function specialised to those types, and caches it in memory. Later calls with the same argument types run the compiled version, which for a tight loop over NumPy arrays is commonly one to two orders of magnitude faster than the interpreter. The important constraint is what it can compile. Inside a Numba function you get scalars, NumPy arrays and their arithmetic, tuples, strings to a limited degree, and the typed containers Numba supplies. You do not get arbitrary Python objects, pandas DataFrames, dictionaries of mixed types, or most third-party library calls, and hitting one of those raises a TypingError at compile time rather than falling back to slow interpreted execution. On top of the basic decorator, Numba can parallelise loops across cores with prange, build NumPy ufuncs out of scalar functions with @vectorize, release the GIL, and compile CUDA kernels for NVIDIA GPUs.

Verdict

For a numeric loop that resists vectorization, Numba is the highest speedup per line of code change available in Python, and prange makes multicore nearly free. Treat the supported-features page as the real API surface, and expect to fight the NumPy and Python version pins at least once a year.

API stability4/5@njit, @vectorize, @guvectorize and prange have looked the same for close to a decade and old code keeps running. Against that, the project is still on 0.x with a Development Status :: 4 - Beta classifier after fourteen years, object mode fallback was made non-default in 0.59, reflected lists have been on the way out for years, and pycc has warned about pending deprecation since 0.57.
Docs4/5The manual on numba.readthedocs.io has the section that matters most: explicit, exhaustive lists of which Python and NumPy features are supported in nopython mode, plus a troubleshooting chapter covering how to read a TypingError and how to use NUMBA_DISABLE_JIT. The weak spot is that the errors themselves are enormous and link to generic pages, so the docs are good but the path from failure to the right page is not.
Maintenance4/50.66.0 shipped on 6 July 2026 moving to llvmlite 0.48 and LLVM 22, and the repo was pushed on 3 August 2026, so it tracks new LLVM and Python releases on a roughly quarterly rhythm. The tracker carries 1671 open issues (1800 counting PRs), which reflects how many long-tail unsupported-feature requests a compiler accumulates rather than neglect.
Ecosystem5/5Around 20M weekly downloads, conda-forge builds for every platform, and it sits underneath a long list of scientific packages that use it for their inner loops. The surrounding projects are real: llvmlite as the LLVM binding, numba-cuda as the NVIDIA-maintained GPU target, and a documented extension API for teaching Numba about your own types.

Use it if

  • You have an explicit Python for loop over array elements that cannot be written as a vectorized NumPy expression, which is the case Numba was built for and where the speedup is largest
  • You need a loop that both runs fast and releases the GIL so several threads make real progress, which nogil=True gives you and pure NumPy does not
  • You want to parallelise an embarrassingly parallel numeric loop over cores by changing range to prange and adding parallel=True, without writing multiprocessing code or copying arrays between processes
  • You need to hand a fast scalar callback to something that expects a C function pointer, such as scipy.integrate or scipy.LowLevelCallable, and do not want to write a C extension
Skip it if

Setup reality

pip install numba pulls llvmlite, which bundles a full LLVM build inside the wheel and is around 40 MB, plus NumPy. Wheels exist for CPython 3.10 through 3.14 on the usual platforms, so you do not compile LLVM yourself unless you are on something unusual, and conda install -c conda-forge numba is the smoother path on macOS and for GPU work. The pinning is the part that bites: 0.66.0 requires llvmlite>=0.48,<0.49 and numpy>=1.22,<2.5 exactly, so pip will happily downgrade your NumPy to satisfy it, or refuse to resolve at all in an environment that needs a newer one. Nothing about the install tells you whether your code will actually compile; that only shows up on first call. Turn on cache=True to persist compiled code between runs, but know that it writes .nbi and .nbc files next to your source in __pycache__, silently falls back to recompiling when the directory is not writable, and refuses to cache functions defined in a REPL or a closure. For GPU work, numba.cuda is still built into this release, while NVIDIA maintains an out-of-tree numba-cuda package that is where new CUDA development happens; check numba.cuda.implementation to see which one you loaded.

Patterns

Speed up an explicit numeric loopcompile-a-loop

import numpy as np
from numba import njit

@njit
def pairwise_min_dist(xs, ys):
    best = np.inf
    for i in range(xs.shape[0]):
        for j in range(ys.shape[0]):
            d = (xs[i] - ys[j]) ** 2
            if d < best:
                best = d
    return np.sqrt(best)

pairwise_min_dist(a, b)  # first call compiles
pairwise_min_dist(a, b)  # this is the one worth timing

The first call pays compilation, so any benchmark that includes it measures the compiler, not your code. @njit is @jit(nopython=True); since 0.59 plain @jit also defaults to nopython, and passing nopython=False now emits a warning rather than quietly running the slow path.

Avoid recompiling on every process startcache-compiled-code

from numba import njit

@njit("float64(float64[:], float64[:])", cache=True)
def dot(a, b):
    total = 0.0
    for i in range(a.shape[0]):
        total += a[i] * b[i]
    return total

An explicit signature compiles at import time instead of on first call, which moves the cost somewhere you control. cache=True writes .nbi and .nbc files into __pycache__ next to the source; in a read-only container it warns and recompiles every start, and it refuses to cache functions defined in a REPL or generated at runtime. Note that float64[:] means a possibly non-contiguous array, so a caller passing a strided slice still matches.

Spread a loop across cores with prangeparallel-loops

from numba import njit, prange, set_num_threads, get_num_threads

@njit(parallel=True)
def row_norms(m):
    out = np.empty(m.shape[0])
    for i in prange(m.shape[0]):
        acc = 0.0
        for j in range(m.shape[1]):
            acc += m[i, j] ** 2
        out[i] = np.sqrt(acc)
    return out

set_num_threads(4)

Without parallel=True, prange is silently just range and you get no error and no speedup. Numba detects reductions on scalars accumulated in the loop body, but any other write to a variable shared across iterations is a race it will not warn about. Thread count comes from NUMBA_NUM_THREADS at import time and can be lowered with set_num_threads but never raised above the import-time value.

Turn a scalar function into a NumPy ufuncbuild-a-ufunc

from numba import vectorize

@vectorize(["float64(float64, float64)", "float32(float32, float32)"],
           target="parallel")
def clipped_ratio(a, b):
    if b == 0.0:
        return 0.0
    return a / b

clipped_ratio(arr_a, arr_b)          # broadcasts like any ufunc
clipped_ratio.reduce(arr_a)          # and gets reduce, accumulate, at

You write the scalar case and get broadcasting, out=, reduce and accumulate for free. target="parallel" only pays off on large arrays; on small ones the thread launch dominates and target="cpu" wins. Listing signatures explicitly compiles eagerly and fixes the dtype behaviour; omit them and you get lazy compilation but a DUFunc rather than a plain ufunc.

Write a ufunc that operates on subarraysgeneralized-ufunc

from numba import guvectorize

@guvectorize(["void(float64[:], float64[:], float64[:])"], "(n),(n)->()")
def cosine_sim(a, b, out):
    dot = na = nb = 0.0
    for i in range(a.shape[0]):
        dot += a[i] * b[i]
        na += a[i] * a[i]
        nb += b[i] * b[i]
    out[0] = dot / (np.sqrt(na) * np.sqrt(nb))

cosine_sim(matrix, query)  # (m, n) against (n,) gives (m,)

The layout string is the contract and getting it wrong produces confusing shape errors rather than a clear message. A scalar output is still written through out[0], because the output is passed in as a zero-dimensional array rather than returned. Never return a value from a guvectorize function; the return is ignored.

Use dicts and lists inside compiled codetyped-containers

from numba import njit
from numba.typed import Dict, List
from numba.core import types

counts = Dict.empty(key_type=types.unicode_type, value_type=types.int64)

@njit
def tally(words, counts):
    for w in words:
        counts[w] = counts.get(w, 0) + 1
    return counts

A plain Python dict passed into nopython code is a TypingError, and typed.Dict is the replacement. It is noticeably slower than a builtin dict when accessed from Python, so build it inside the compiled function where possible and only read it out at the end. Both key and value types are fixed at creation, so a heterogeneous dict is simply not expressible.

Compile a small class with typed attributesjit-class

from numba.experimental import jitclass
from numba import int64, float64

@jitclass([("total", float64), ("n", int64)])
class RunningMean:
    def __init__(self):
        self.total = 0.0
        self.n = 0

    def add(self, x):
        self.total += x
        self.n += 1

    @property
    def value(self):
        return self.total / self.n

It lives under numba.experimental for a reason: no inheritance, no class attributes, every instance attribute needs a declared type, and instances cannot be pickled. It is a way to give a compiled kernel some structured state, not a way to compile your domain model.

Trade IEEE strictness for speedloosen-float-semantics

@njit(fastmath=True, nogil=True, error_model="numpy")
def mean_abs(x):
    total = 0.0
    for i in range(x.shape[0]):
        total += abs(x[i])
    return total / x.shape[0]

fastmath lets LLVM reassociate and vectorize float arithmetic, which changes results in the last bits and makes NaN and infinity handling undefined, so never use it on data that can contain NaN. nogil=True only helps if you actually run the function from several threads. error_model="numpy" makes division by zero return inf instead of raising, matching NumPy rather than Python.

Work out why a function will not compilediagnose-typing-errors

@njit
def f(df):
    return df["col"].sum()

# TypingError: non-precise type pyobject ...

# what Numba actually inferred:
f.inspect_types()
print(f.signatures)

# run the original Python for comparison:
f.py_func(df)

The first line of a TypingError names the unsupported operation and the last few hundred lines are compiler internals, so read the top and stop. inspect_types shows the inferred type of every variable, which is how you find the one that came out as pyobject. Every decorated function keeps the original at .py_func, which is the fastest way to check whether the bug is yours or the compiler's.

Step through your kernel with a debuggerdisable-jit-for-debugging

# shell
NUMBA_DISABLE_JIT=1 python -m pdb run.py

# or in a conftest.py for tests
import numba
numba.config.DISABLE_JIT = True

# useful during development
@njit(boundscheck=True)
def g(a, i):
    return a[i]

With JIT disabled every decorator becomes a no-op, so print and pdb work normally and coverage tools see your lines. Compiled code does no bounds checking by default, so an out-of-range index reads adjacent memory and returns garbage instead of raising IndexError; turn boundscheck on in tests and off in production.

Write a CUDA kernel in Pythoncuda-kernel

from numba import cuda
import numpy as np

@cuda.jit
def add_kernel(x, y, out):
    i = cuda.grid(1)
    if i < out.size:
        out[i] = x[i] + y[i]

d_x = cuda.to_device(np.arange(1_000_000, dtype=np.float32))
d_out = cuda.device_array_like(d_x)
threads = 256
add_kernel[(d_x.size + threads - 1) // threads, threads](d_x, d_x, d_out)
result = d_out.copy_to_host()

The bounds check inside the kernel is not optional: the grid almost always covers more threads than elements. Kernels cannot return values, so results come back through an output array. Passing host NumPy arrays works but copies both ways on every launch, which usually erases the speedup; move data to the device once. Set NUMBA_ENABLE_CUDASIM=1 to run kernels in the interpreter for debugging, slowly.

Hand a compiled callback to SciPyc-callback

from numba import cfunc, types
from scipy.integrate import quad

@cfunc(types.float64(types.float64), cache=True)
def integrand(x):
    return np.exp(-x * x) * np.sin(10.0 * x)

value, err = quad(integrand.ctypes, 0.0, 5.0)

cfunc needs an explicit signature because there is no call site to infer types from, and it gives you .ctypes and .address for handing to C. The callback runs outside the interpreter, so an exception inside it cannot propagate: it prints and returns a zero value. This is what makes quad over a Python lambda go from slow to fast without writing C.

Alternatives

PackageRegistryPick it when
cythonPyPIYou need an ahead-of-time compiled extension module to ship in a wheel, or you need to call C libraries and use Python objects in the same compiled function
jaxPyPIYou want automatic differentiation and XLA compilation over whole array programs, with CPU, GPU and TPU from one code path
numexprPyPIThe problem is one large NumPy expression building intermediate temporaries, and you want it evaluated in chunks across threads with no compiler in the picture
polarsPyPIThe slow code is dataframe work rather than a numeric kernel, which is exactly the shape Numba cannot compile