Chatomics Field GuideWhat They Don't Teach You

Glossary · Single-Cell and Spatial

LogNormalize (log1p CP10k)

Seurat's default normalization looks like one function call, but the scale factor and the log1p transform each carry an assumption that decides whether your clusters reflect biology or sequencing depth.

By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 2 min read

Also: log1p, CP10k

Definition

LogNormalize is Seurat's default normalization method: for each cell, it divides every feature's raw count by that cell's total counts, multiplies by a scale factor (default 10,000, giving "counts per 10k" or CP10k), then applies log1p, the natural log of 1 plus the value. The log1p step exists because log(0) is undefined but log1p(0) = log(1+0) = 0, so zeros stay zero instead of breaking the transform. log(x+1) and log1p(x) are the same operation; the notation differs, the math doesn't. Scanpy's sc.pp.normalize_total() + sc.pp.log1p() implements the same two-step logic.

You'll meet LogNormalize the moment you run NormalizeData() in a Seurat tutorial, or sc.pp.normalize_total() followed by sc.pp.log1p() in Scanpy. It's the default, so most people run it without reading what it assumes, and that's exactly the problem: it decides how comparable your cells are to each other before you've clustered a single one.

Get the scale factor or the transform wrong, and every downstream step inherits it: PCA loadings, cluster boundaries, and any avg_log2FC you report off FindMarkers are all built on top of this one normalization choice.

Why it matters

Skip proper depth normalization and your PCA and clustering get driven by sequencing depth, not biology. A cell with 20,000 UMIs looks transcriptionally different from a cell with 400 UMIs even when they're the same cell type, purely because it was sequenced deeper. LogNormalize forces every cell onto the same total-count scale (CP10k) before the log transform, which is why it sits at the top of nearly every Seurat and Scanpy tutorial.

CP10k is not neutral, though. For genes with only one or two counts per cell, log1p introduces a real, quantifiable bias: the expected log1p value diverges from log(true expression) as the count drops toward zero, so low-abundance genes are systematically distorted in a way high-abundance genes aren't. And LogNormalize does not fix depth-driven noise in gene-gene correlation, run a correlation on LogNormalize output across cells with wildly different depths and you'll still see spurious co-expression, because low-depth cells inject noise and high-depth cells inject signal unevenly. That's why meta-cell or pseudobulk aggregation exists as a separate step, not a substitute for normalization.

Where people get it wrong

The mistake people make is trusting the scale factor as if it were measured rather than chosen. 10,000 is a convention, not a biological constant; CPM normalization uses 1,000,000 instead, and neither number is "correct", they just relocate the same log-compressed distribution to a different intercept. A second, sneakier confusion: LogNormalize output looks like it belongs in the same units as raw counts, so people accidentally feed it into tools that expect raw UMI counts (some differential expression methods, ambient RNA correctors), silently breaking the statistical model those tools rely on. And when people report avg_log2FC from Seurat's FindMarkers, they assume it's a textbook log2 fold change, when it's actually computed on log1p-transformed, pseudocount-shifted values, it does not equal log2(mean_A / mean_B) the way bulk RNA-seq log2FC does, and treating it as if it did will mislead your effect-size interpretation.

A concrete example

Run NormalizeData() on a Seurat object with the defaults and you get LogNormalize with scale.factor = 10000: each cell's counts are divided by that cell's total, multiplied by 10,000, then log1p-transformed. The Scanpy equivalent is sc.pp.normalize_total(adata) followed by sc.pp.log1p(adata), landing on the same math through a different API.

r
library(Seurat)
pbmc <- NormalizeData(pbmc, normalization.method = "LogNormalize", scale.factor = 10000)

# what happens per gene, per cell, under the hood:
# normalized_value <- log1p(raw_count / total_counts_in_cell * 10000)

Related terms

Questions people ask

What does Seurat's LogNormalize actually do?

It divides each cell's raw counts by that cell's total counts, multiplies by a scale factor (default 10,000, hence CP10k), then applies log1p. The result is a log-scale, depth-corrected expression matrix used for PCA, clustering, and marker detection.

Is log1p the same as log(x+1)?

Yes. log1p(x) and log(x+1) are mathematically identical; log1p is just the numerically stable implementation that handles zero counts correctly without producing an undefined value.

Why does Seurat use 10,000 as the scale factor instead of 1 million?

10,000 (CP10k) is a convention that became standard in single-cell tutorials, not a biologically derived number. CPM normalization uses 1,000,000 instead; both apply the same logic, they just place the log-transformed values on a different scale.

Should I use LogNormalize for CITE-seq or ADT protein count data?

No. Seurat itself uses centered log ratio (CLR) normalization for antibody-derived tag data because protein counts have a different noise structure than RNA counts. Applying LogNormalize to protein data carries over an assumption it wasn't built for.

Does LogNormalize remove the need for pseudobulk or meta-cell aggregation?

No. LogNormalize corrects for total counts per cell, but it doesn't remove the depth-driven noise that distorts gene-gene correlation across cells with very different sequencing depths. For reliable co-expression estimates, aggregate similar cells into pseudobulk or meta-cell profiles first.

Related pages

Related reading on the blog

Sources

  1. NormalizeData, Seurat — Default LogNormalize behavior and scale.factor parameter
  2. LogNormalize, Seurat — Function-level definition, margin parameter, default scale factor of 10,000
  3. Normalization of single-cell RNA-seq counts by log(x + 1) or log(1 + x) — Quantifies the log1p bias for low-count genes cited in why_it_matters
  4. Depth normalization for single-cell genomics count data — CP10k rationale and variance-stabilizing effect of log1p
  5. scanpy.pp.normalize_total, Scanpy — Scanpy's equivalent two-step normalize_total + log1p workflow
  6. Normalization, redux, Advanced Single-Cell Analysis with Bioconductor — Critique of constant-pseudocount normalization versus size-factor methods
  7. Part 3 Centered log ratio (CLR) normalization for CITE-seq protein count data — Explains log1p behavior and why protein/ADT data uses CLR instead of LogNormalize