scanpy · normalize_total · silent data loss · CSR vs dense · preprocessing bug
A Year-Old scanpy Bug That Could Silently Zero Out Your Data — Fixed Last Month
We've written before about how easy it is to accidentally double-log-normalize spatial data (it happened across our own corpus — see our earlier workflow post). This one is a different bug in the same function, and it's worse: it can silently zero out your entire matrix with no error, just two easy-to-miss RuntimeWarnings.
Status as of writing: present in scanpy 1.11.2 through 1.12.3 (2025-05-28 → 2026-07-24). Fixed in 1.12.4 (released 2026-08-27) via PR #4256, reported in scanpy#4251. If you pinned scanpy any time in the last 14 months, check your version.
What target_sum=None is supposed to mean
sc.pp.normalize_total(adata, target_sum=None) — the default most tutorials use — documents its target as "the median of total counts for observations." Simple enough. But _normalize_total_helper computes that median two different ways depending on whether your matrix is CSR sparse or dense:
# CSR path (scanpy/preprocessing/_normalization.py, pre-1.12.4)
if target_sum is None:
target_sum = np.median(counts_per_cell) # median over ALL cells
# dense / everything-else path
if target_sum is None:
target_sum = _compute_nnz_median(counts_per_cell) # median over POSITIVE cells only
The two only disagree when a zero-total cell shifts the median — with row sums like [0, 10, 10, 20] both give 10, which is probably why this went unnoticed for so long. Empty cells are common in practice: an unfiltered raw matrix, an aggressive gene subset, a .raw slice with different filtering than the parent object.
The severe case: when at least half your cells are empty
If enough cells have zero total counts, the CSR branch's median lands on 0. Dividing by zero doesn't error — it silently produces an all-zero matrix, with only the warnings below as evidence anything went wrong:
import anndata as ad
import numpy as np
import scanpy as sc
import scipy.sparse as sp
# row sums [0, 0, 10] — the only populated cell has counts [4, 6]
dense2 = np.array([[0.0, 0.0], [0.0, 0.0], [4.0, 6.0]], dtype=np.float32)
for label, X in (("dense", dense2.copy()), ("csr", sp.csr_matrix(dense2))):
a = ad.AnnData(X=X)
sc.pp.normalize_total(a, target_sum=None)
out = a.X if isinstance(a.X, np.ndarray) else a.X.toarray()
print(f"{label:>5}: {out.ravel()}")
# dense: [0. 0. 0. 0. 4. 6.] <- correct, target=10, populated cell untouched
# csr: [0. 0. 0. 0. 0. 0.] <- target=0, the ONLY real data point is destroyed
.../scanpy/preprocessing/_normalization.py:119: RuntimeWarning: divide by zero encountered in divide
.../scanpy/preprocessing/_normalization.py:119: RuntimeWarning: invalid value encountered in divide
No exception. No crash. Just a warning easy to filter out in a notebook with dozens of other warnings, and a matrix that's now all zeros.
Why the CSR path had a different rule at all
Before 1.11.2 there was a single code path, and it excluded zero-count cells on both branches. The split arrived with the numba CSR kernel in PR #3571 (merged 2025-05-19), which kept the old positive-cell-median rule for the non-CSR branch but gave the new CSR branch a plain np.median. Neither that PR nor its release notes mention a semantic change — this reads as an oversight, not a decision, and the reporter's one-line fix (route the CSR branch through the same _compute_nnz_median helper) is exactly what landed in 1.12.4.
What to actually do
- Upgrade to scanpy ≥1.12.4 if you can — this is the real fix.
- If you're pinned to an older version, pass an explicit
target_sum(e.g.target_sum=1e4) instead of relying on the median default — this sidesteps the bug entirely regardless of version. - Before normalizing, check
(adata.X.sum(axis=1) == 0).sum()— if it's more than a small fraction of your cells, filter them out first rather than letting either branch guess a target around them.
Verified directly against scanpy's main branch source and the released 1.12.4 tag, 2026-09. Source: scanpy#4251.