NumPy dtypes
NumPy dtypes
Core Principle: An array data type (
dtype) dictates element bit-width, memory consumption, precision limits, and numerical overflow behaviors across computational operations.
The families
| Family | Examples | Notes |
|---|---|---|
| Signed int | int8 int16 int32 int64 |
Wraps silently on overflow |
| Unsigned int | uint8 uint16 uint32 uint64 |
Mixing with signed promotes to float |
| Float | float16 float32 float64 |
float64 is the default |
| Complex | complex64 complex128 |
Rare in bio |
| Bool | bool_ |
One byte per element, not one bit |
| Fixed string | <U10, S10 |
Unicode / bytes, fixed width: truncates! |
| Object | object |
Python objects in a box; kills all performance |
| Datetime | datetime64[D], timedelta64[ns] |
Unit is part of the dtype |
| Structured | see Structured Arrays | Named fields, C-struct-like |
array([1., 2., 3.], dtype=float32)
Precision, memory and the real tradeoff
| dtype | Bytes | Range / precision |
|---|---|---|
int8 |
1 | −128 … 127 |
int16 |
2 | ±32,767 |
int32 |
4 | ±2.1 × 10⁹ |
int64 |
8 | ±9.2 × 10¹⁸ |
float16 |
2 | ~3 decimal digits |
float32 |
4 | ~7 decimal digits |
float64 |
8 | ~16 decimal digits |
A 20,000 × 500 matrix: 80 MB as float64, 40 MB as float32. For expression counts, coverage, or quality scores, float32 is plenty: 7 significant digits far exceeds the measurement precision of any assay. For accumulating sums over millions of elements, keep float64, because rounding error accumulates.
counts.astype(np.float32) # halve the memory
counts.sum(axis=0, dtype=np.float64) # but accumulate in float64
That dtype= argument on reductions is underused and exactly right: low-precision storage, high-precision accumulation.
Overflow is silent and it will get you
array([-2], dtype=int32)
Genomic coordinates on chromosome 1 (~249 Mb) fit in int32, but a whole-genome cumulative offset (~3.1 Gb) does not. Coverage totals across a large cohort do not. Use int64 for anything positional or cumulative.
Python's own ints are arbitrary-precision, so this is a NumPy-specific hazard that people migrating from pure Python do not expect.
NEP 50: what changed in NumPy 2
Scalar promotion rules changed. Under NumPy 2:
np.uint8(44)
<cell>:2: RuntimeWarning: overflow encountered in scalar add
The rule is now: a Python scalar adopts the array's dtype rather than promoting it. This is more predictable, but it means numerical results can differ between NumPy 1 and 2 in low-precision code. If you have old float32 pipelines, re-verify their output. See Versions and Compatibility.
The fixed-string trap
This code completed without producing output.
The width is baked in at creation. This is one of the reasons genomic labels belong in Pandas (which now has a proper str dtype) rather than NumPy string arrays.
The object dtype is a warning sign
array([1, 'a', None], dtype=object)
An object array is a Python list wearing an ndarray costume: every operation goes through the interpreter, so you lose all of NumPy's speed and most of its functions. If you land in object dtype, you almost certainly want a DataFrame.
Checking and converting
np.issubdtype(a.dtype, np.integer)
np.issubdtype(a.dtype, np.floating)
a.astype(np.int32, casting="safe") # raises rather than silently truncating
np.can_cast(np.int64, np.int32) # False
casting="safe" is a good habit in pipeline code: it turns a silent data corruption into an exception.
Common mistakes
- Integer division surprises.
np.array([5]) / 2gives2.5(float), but// 2gives2and stays integer. In-placearr /= 2on an int array raises. np.nanin an integer array. Impossible: NaN is a float concept. The array is silently promoted to float, or the assignment fails. See Missing Data in NumPy.- Assuming
intmeansint64everywhere. On Windows, the default integer was historicallyint32. Be explicit in cross-platform code. float16for real work. It has about 3 decimal digits. Fine for storage of already-noisy data, dangerous for computation.- Forgetting
astypereturns a copy.a.astype(np.float32)does not modifya.
See also
ndarray · Missing Data in NumPy · Structured Arrays · pandas dtypes · Memory Layout and Strides · Versions and Compatibility
Exercise
Write pack(values), which narrows a list of Python integers to np.int8 and
reports what that costs. Return ["empty"] for an empty list.
Otherwise return five lines: overflow=<n>/<total>, counting the values outside
the closed range -128 to 127; wrapped=<list>, the result of casting an int64
array straight to int8, which silently wraps around rather than raising;
clipped=<list>, the result of clipping to the int8 range first and then
casting, which saturates instead; promoted=<name> widened=<name>, the dtype
names np.result_type gives for the int8 array combined with np.uint8 and
with np.float32: the first is wider than either input, which surprises most
people; and bytes=<n> vs <n>, the int8 array's nbytes against the int64
array's. Report dtypes by .name and never by their repr.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 10 200 -5 128 -129 127 | overflow=3/6 wrapped=[10, -56, -5, -128, 127, 127] clipped=[10, 127, -5, 127, -128, 127] promoted=int16 widened=float32 bytes=6 vs 48 |
| 2 | stdin | 0 1 2 3 | overflow=0/4 wrapped=[0, 1, 2, 3] clipped=[0, 1, 2, 3] promoted=int16 widened=float32 bytes=4 vs 32 |
| 3 | stdin | -128 | overflow=0/1 wrapped=[-128] clipped=[-128] promoted=int16 widened=float32 bytes=1 vs 8 |
| 4 | stdin | · | empty |
Hints · 2
01Hint
np.clip(arr, -128, 127).astype(np.int8) saturates, while arr.astype(np.int8) alone wraps modulo 256.
02Hint
np.result_type(np.int8, np.uint8) has to hold every value of both, so neither 8-bit type is wide enough.