datarekha

Dtypes

int8 vs int64, float32 vs float64, and the silent overflow bug that ate someone's pixel data. Pick the right dtype.

7 min read Intermediate NumPy Lesson 4 of 14

What you'll learn

  • The integer and float dtype families and their memory cost
  • Why float32 dominates ML and float64 dominates scientific computing
  • How small int types overflow silently — and how to spot it
  • Safe vs unsafe casts with `.astype`

Before you start

The last lesson kept nagging you to pass dtype= and never quite said why. Here is the why, and it has teeth. Every element in an ndarray shares one dtype, and that single choice silently decides three things at once: how much memory the array eats, how much precision it keeps, and whether your arithmetic quietly overflows into nonsense. Two of the most baffling bugs in all of numeric Python — an int8 wrapping from 127 to −128, a float32 rounding a sum into noise — both live here, and neither raises an error. This lesson teaches you to pick a dtype on purpose.

The dtype families

NumPy gives you several integer and float widths. The number after the name is bits, not bytes.

Integer (signed):    int8   int16   int32   int64
Integer (unsigned):  uint8  uint16  uint32  uint64
Float:               float16 float32 float64
Boolean:             bool_
Complex:             complex64  complex128
Object:              object   ← avoid for numeric data

Each step up doubles the memory and widens the representable range — and “widens” is doing a lot of work, so look at the actual numbers:

import numpy as np

# Range of each integer dtype
for dt in [np.int8, np.int16, np.int32, np.int64]:
    info = np.iinfo(dt)
    print(f"{dt.__name__:7}: {info.min:>22,} .. {info.max:<22,}")

# Range of float dtypes
for dt in [np.float16, np.float32, np.float64]:
    info = np.finfo(dt)
    print(f"{dt.__name__:9}: ±{info.max:.3e}, machine eps = {info.eps:.1e}")
int8   :                   -128 .. 127                   
int16  :                -32,768 .. 32,767                
int32  :         -2,147,483,648 .. 2,147,483,647         
int64  : -9,223,372,036,854,775,808 .. 9,223,372,036,854,775,807
float16  : ±6.550e+04, machine eps = 9.8e-04
float32  : ±3.403e+38, machine eps = 1.2e-07
float64  : ±1.798e+308, machine eps = 2.2e-16

int8 holds only −128 to 127. uint8 holds 0 to 255 — which happens to be exactly the range of 8-bit pixel values, which is why every image library stores pixels as uint8. And notice the float machine eps: float32 resolves differences down to ~1.2e-07, float64 to ~2.2e-16. That gap is the whole story of the section’s earlier numerical-stability lesson.

Real example — pixel data fits in uint8

A 1080p RGB image as float64 is ~50 MB. As uint8, it is ~6 MB. Same data, 8× the throughput.

import numpy as np

H, W, C = 1080, 1920, 3

img_float64 = np.zeros((H, W, C), dtype=np.float64)
img_uint8   = np.zeros((H, W, C), dtype=np.uint8)

print(f"float64: {img_float64.nbytes / 1e6:6.2f} MB")
print(f"uint8  : {img_uint8.nbytes  / 1e6:6.2f} MB")
print(f"ratio  : {img_float64.nbytes / img_uint8.nbytes:.0f}x")
float64:  49.77 MB
uint8  :   6.22 MB
ratio  : 8x

For a batch of 32 such images during training, that is 1.6 GB versus 200 MB. The right dtype is the difference between fitting in GPU memory and not.

The silent overflow bug

This is the classic gotcha. You compute something innocuous, and the result wraps around without a word of warning.

import numpy as np

# Pixel data, uint8. Now you want to compute the *mean brightness* of
# every pixel, summing R+G+B per pixel.
pixels = np.array([
    [200, 200, 200],   # near-white pixel — sum should be 600
    [100, 150, 200],   # sum should be 450
], dtype=np.uint8)

# Naive: just sum along the color axis.
bad = pixels.sum(axis=1)        # NumPy is smart enough to upcast here…
print("with NumPy default:", bad)

# But manual addition stays in uint8 and overflows!
manual = pixels[:, 0] + pixels[:, 1] + pixels[:, 2]
print("manual add (uint8):", manual)   # WRONG — wraps mod 256

# The fix: cast up before the math.
safe = pixels.astype(np.int32).sum(axis=1)
print("after upcast       :", safe)
with NumPy default: [600 450]
manual add (uint8): [ 88 194]
after upcast       : [600 450]

200 + 200 + 200 = 600, which does not fit in 8 bits. With unsigned wrap-around you get 600 mod 256 = 88, and 450 mod 256 = 194. No error, no warning — just wrong pixels. (Note that pixels.sum(axis=1) got it right: NumPy quietly upcasts the accumulator for reductions, but element-wise a + b + c stays in uint8 and wraps.) This bug type loves to hide for years in image-processing code.

float32 vs float64 — the ML tradeoff

In classical scientific computing you reach for float64 (double precision) because an eigenvalue decomposition or a stiff ODE genuinely needs the digits. In deep learning you reach for float32 (and increasingly float16 / bfloat16) because:

  • gradients only need ~3 decimal digits of accuracy,
  • half the memory means twice the batch size,
  • modern GPUs run float32 / float16 far faster than float64.
import numpy as np

# Same numbers, different precision.
x64 = np.array([1.0, 1e-8, 1.0 + 1e-8], dtype=np.float64)
x32 = np.array([1.0, 1e-8, 1.0 + 1e-8], dtype=np.float32)

# float32 can't distinguish 1.0 from 1.0 + 1e-8.
print("float64:", x64, "diff:", x64[2] - x64[0])
print("float32:", x32, "diff:", x32[2] - x32[0])
float64: [1.00000000e+00 1.00000000e-08 1.00000001e+00] diff: 9.99999993922529e-09
float32: [1.e+00 1.e-08 1.e+00] diff: 0.0

There it is in one line: float32 simply cannot store 1.0 + 1e-8 as anything but 1.0, so the difference collapses to 0.0 — while float64 keeps it (≈1e-8). float32 has about 7 decimal digits of precision, float64 about 16. For weights and activations, 7 is plenty; for inverting an ill-conditioned matrix, 7 is a disaster.

TryDtype promotion

What dtype does a + b produce?

Pick a dtype for each operand. NumPy promotes to the narrowest type that can represent both — but the result can be wider than you expect, and overflow can still happen before the promotion kicks in.

4B / elem
2B / elem
int32 + float16float648B / elem
Silent memory widening (memory)

int32 (4B) + float16 (2B) → float64 (8B). The result array is 2× wider per element — can double memory use unexpectedly.

Promotion table (int + float)
float16
float32
float64
int8
float16
float32
float64
int16
float32
float32
float64
int32
float64
float64
float64
int64
float64
float64
float64

Highlighted cell = current selection. Pure int+int promotes to the wider int (not shown).

Casting with .astype

.astype(dtype) returns a new array with the requested dtype. It is the standard way to convert — but it has two sharp edges.

import numpy as np

# Read genomic markers as int8 (huge dataset, save memory).
markers = np.array([0, 1, 2, 1, 0, 2], dtype=np.int8)
print(markers.dtype, markers.nbytes, "bytes")

# Compute a float weighted score — upcast first.
weights = np.array([0.5, 1.5, 2.0, 1.0, 0.8, 0.2])  # default float64
score = (markers.astype(np.float64) * weights).sum()
print("score:", score)

# Unsafe cast — float → int truncates toward zero.
floats = np.array([1.9, -1.9, 2.5, -2.5])
print("as int32:", floats.astype(np.int32))   # truncates, does NOT round
int8 6 bytes
score: 6.9
as int32: [ 1 -1  2 -2]

The two edges:

  1. Float → int truncates, it does not round: 1.9 → 1, -1.9 → -1, 2.5 → 2. Use np.round(x).astype(int) when you actually want rounding.
  2. Casting to a narrower int silently wraps on overflow. Casting 300 to uint8 gives you 44 (300 % 256) with no warning. Always confirm the value range fits before narrowing.

The object dtype — avoid for numerics

dtype=object stores Python objects, not raw values. It is the fallback NumPy lands on when it cannot infer a numeric type — usually because you mixed in a None or a string.

arr = np.array([1, 2, None])   # dtype = object

Operations on an object array run Python-level loops — you have thrown away the entire point of NumPy. If you find yourself with an object array of numbers, either fill the gaps (np.nan for floats) or move to a Pandas nullable Int64.

In one breath

A NumPy dtype is one shared element type that fixes an array’s memory, precision, and overflow behaviour. Integers (int8int64, uint8uint64) double in range each step — uint8’s 0–255 is exactly pixel range; floats trade range/precision (float16/float32/float64, machine eps ~1e-3/1e-7/1e-16). Small ints overflow silently on element-wise math (200+200+200 in uint8 → 88), so cast up before arithmetic; float32’s ~7 digits can’t even represent 1.0 + 1e-8 (the diff prints 0.0), which is why scientific work uses float64 but ML happily uses float32/bf16 (half the memory, GPU-fast, precise enough for gradients). .astype() makes a new array but truncates float→int and wraps on narrowing, and dtype=object silently drops you back to slow Python loops.

Practice

Quick check

0/3
Q1You have a uint8 array of pixel intensities and want to compute the squared values. What goes wrong with `pixels ** 2`?
Q2Why does deep learning prefer float32 over float64?
Q3`np.array([1.9, 2.1, -1.9]).astype(np.int32)` returns:

A question to carry forward

You can now store an array as compactly as its values allow — pixels in uint8, weights in float32, the dtype matched to the job. But storing data is only half the work; the other half is reaching into it and pulling pieces out — a row, a column, every value above a threshold, a scattered handful of rows.

And here NumPy springs a trap that Python lists never had. Ask for a simple slice and you may get back not a copy but a view — a window onto the same memory — so that writing through the slice silently rewrites the original array. What are the rules of indexing: when is a slice a view versus a copy, why does that distinction exist, and how do boolean masks and fancy index arrays let you carve an array into any shape of selection you need?

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
How does the category dtype work in pandas and when should you use it?

CategoricalDtype stores a column as integer codes plus a small lookup table of unique values, dramatically reducing memory for low-cardinality string columns. It also enforces a fixed set of valid values, enables natural ordering, and speeds up groupby and sort operations.

How do you reduce memory usage in a pandas DataFrame using dtypes, category encoding, and downcasting?

The biggest wins come from converting low-cardinality string columns to category dtype (often 10x smaller), downcasting int64 and float64 to the smallest type that fits the data range, and using sparse arrays or chunked reads for data that doesn't need to live fully in memory.

How does the categorical dtype reduce memory and speed up operations in pandas?

Categorical dtype stores a column's unique values once in a lookup table and represents each row as a small integer code, replacing repeated Python string objects. This cuts memory by an order of magnitude for low-cardinality string columns and accelerates GroupBy, sorting, and equality comparisons because pandas operates on integer codes rather than string comparisons.

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