Chatomics Field GuideWhat They Don't Teach You

Sanity check · Single-Cell RNA-seq

How to Choose a Normalization Method in Single-Cell RNA-seq

LogNormalize, SCTransform, CPM, TPM and dsb answer different questions; picking the wrong one quietly rewrites your clusters and your DE calls.

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

You ran the Seurat vignette, called NormalizeData() with defaults, and got clusters. Then you read a paper that used SCTransform, switched to it out of habit, and now your marker genes, your variable feature list, and your cluster boundaries all moved. Or you're about to hand log-normalized values to DESeq2 because that's what was sitting in the object, and something about that feels wrong. It is wrong, and the reason is not a style preference: LogNormalize and SCTransform encode different assumptions about what "technical noise" looks like in UMI counts, and CPM, TPM, and RPKM were built for a different assay entirely.

The cost of getting this wrong is not cosmetic. Library-size normalization run on the wrong assumption inflates false-positive differential expression between cells that differ only in sequencing depth, not biology, sometimes by orders of magnitude. It can also erase the exact signal your experiment was funded to detect, when total RNA content differs by cell type or treatment. In CITE-seq experiments, reusing an RNA-style normalization on protein counts leaves ambient antibody noise sitting in your data untouched.

This page gets you to a defensible choice in the next hour: which method fits your platform and question, a set of checks to run on your own object before you trust the output, and the specific fixes for the RNA layer, the DE layer, and the CITE-seq layer.

What it looks like when it's happening

  • FindVariableFeatures returns a different gene list depending on method: 2,000 genes for LogNormalize, 3,000 for SCTransform, with only partial overlap between the two on the same object.
  • Clusters and cluster counts shift when you swap NormalizeData for SCTransform on the same Seurat object, even holding resolution constant.
  • A DE test between two halves of the same cell population, split only by sequencing depth, returns thousands of 'significant' genes instead of near zero.
  • PC1 in your PCA plot correlates with nCount_RNA or percent.mt instead of separating known cell types.
  • Heatmaps of marker genes look uniformly washed out or saturated because scale.data or raw counts, not the log-normalized data slot, ended up on the plot.
  • CITE-seq ADT counts separate by antibody panel lot or sequencing run rather than by protein expression after being pushed through the RNA normalization pipeline.
  • DESeq2 or edgeR produces nonsensical dispersion estimates or an outright error because it was fed log-normalized values instead of raw counts.

Why it happens

Every normalization method in this space is built on one assumption: most genes don't change between cells, so differences in total counts are technical, not biological. That assumption holds often enough to be the default, and fails predictably in a few situations: when cell types genuinely differ in total RNA content, when a treatment causes a global transcriptional shift, or in spike-in-controlled designs where the whole point is to measure a global change. Library-size scaling forces every cell to look the same total size, so when that assumption is wrong, the correction flattens real composition signal along with the technical noise.

LogNormalize scales each cell's counts to a fixed total (10,000 by default), then applies log1p. It's fast and it's still the most widely used method, but the pseudocount added before the log is arbitrary, and the log transform does not fully stabilize variance across the expression range, so highly expressed genes stay poorly normalized. In a direct comparison, standard log-normalization produced over 2,000 false-positive differentially expressed genes between two groups of biologically identical cells that differed only in sequencing depth. SCTransform instead fits each gene's counts with regularized negative binomial regression, pooling information across genes with similar mean expression to learn the technical trend directly from the UMI counts, and uses the resulting Pearson residuals as the normalized value. No log step, no arbitrary pseudocount, and on the same false-positive test it produced 11 genes instead of 2,000. That better-behaved technical model is also why SCTransform workflows typically use more principal components downstream (around 30) than LogNormalize workflows (around 10): higher PCs stop being technical noise and start carrying real biology.

Platform matters as much as method. 10x UMI counts are already length-independent, so a gene-length correction like TPM does nothing useful there; that correction exists for full-length platforms like SMART-seq, where a longer transcript mechanically produces more reads regardless of expression. CPM, TPM, and RPKM were built for bulk RNA-seq comparisons and were never designed to feed count-based differential expression models, which is the same failure mode as handing DESeq2 or edgeR a log-normalized single-cell matrix instead of raw counts: both break the mean-variance relationship those tools are modeling.

Processing pipeline differences compound all of this before normalization even starts. Cell Ranger v3 excludes intronic reads from counts; v6 includes them. Two datasets labeled the same platform can have systematically different total counts per cell for a purely technical reason that has nothing to do with your normalization choice, and if you don't know which Cell Ranger version generated a public dataset, no normalization method downstream fixes that mismatch.

The checks

Run them in order. Each one tells you what healthy looks like and what the problem looks like.

0/8 checked · saved in this browser

  1. Check the Cell Ranger version (or kallisto/salmon pipeline) and whether introns were included, from the pipeline log or GEO/cellxgene metadata. Confirm the platform is UMI-based (10x) or full-length (SMART-seq), since that decides whether gene-length correction is relevant at all.

    Healthy
    One consistent pipeline version across every sample you plan to merge, and a clearly stated platform.
    Red flag
    Samples processed with different Cell Ranger versions (intron-inclusion changes what the count matrix represents), or an unlabeled platform you're assuming is 10x.
  2. Plot nCount_RNA grouped by sample and by expected cell type using a violin plot, before running any normalization step.

    r
    VlnPlot(seurat_obj, features = "nCount_RNA", group.by = "orig.ident", pt.size = 0)
    Healthy
    Distribution shape and median are similar across samples with only technical variation between them; between-celltype differences are modest and explainable.
    Red flag
    Total counts differ systematically by biological group (e.g., one condition is globally lower or higher), this could be a real global transcriptional shift, a batch effect, or both, and you need to know which before you normalize it away.
  3. Run both normalization paths on the same Seurat object and check how many of the top variable genes overlap.

    r
    seurat_obj <- NormalizeData(seurat_obj) |> FindVariableFeatures(nfeatures = 2000)
    log_hvg <- VariableFeatures(seurat_obj)
    
    seurat_sct <- SCTransform(seurat_obj, vars.to.regress = "percent.mt", vst.flavor = "v2")
    sct_hvg <- VariableFeatures(seurat_sct)
    
    length(intersect(log_hvg, sct_hvg))
    Healthy
    Substantial overlap on core biology genes, with SCTransform typically returning more variable features (3,000 vs 2,000 default) because fewer of them are technically driven.
    Red flag
    Very little overlap, or known marker genes for your expected cell types are missing from one method's variable feature list.
  4. Inspect DefaultAssay and the slot argument passed into FindMarkers, pseudobulk aggregation, or DESeq2/edgeR. Print a few rows of counts vs data to confirm which one you're actually using.

    r
    GetAssayData(seurat_obj, slot = "counts")[1:5, 1:5]
    GetAssayData(seurat_obj, slot = "data")[1:5, 1:5]
    Healthy
    Raw counts (counts slot) go into DESeq2/edgeR/pseudobulk; log-normalized values (data slot) are used only for visualization or exploratory Wilcoxon tests; scale.data is PCA input only.
    Red flag
    Log-normalized or scaled values passed directly into DESeq2/edgeR, or raw counts plotted on a heatmap with no normalization applied at all.
  5. Split one biologically homogeneous population into two groups by sequencing depth alone (e.g., cells above vs below the median nCount_RNA within one cluster) and run a DE test between the two halves using each normalization method.

    Healthy
    Near-zero significant genes between the two depth-only groups, since they're biologically identical.
    Red flag
    Thousands of 'significant' genes between technically identical groups, the documented gap is roughly 2,000 false positives for LogNormalize versus about 11 for SCTransform on the same test.
  6. Run PCA on the normalized object and generate a FeaturePlot or DimPlot colored by nCount_RNA and percent.mt on the PCA reduction.

    r
    FeaturePlot(seurat_obj, features = c("nCount_RNA", "percent.mt"), reduction = "pca")
    Healthy
    Cells are not strongly gradient-colored by depth within a known cell type; biological identity dominates the axes.
    Red flag
    PC1 or PC2 tracks nCount_RNA or percent.mt more than cell-type identity, meaning depth wasn't fully corrected.
  7. Confirm whether antibody-derived tag (ADT) counts went through the same normalization call as the RNA data, or through a background-aware method built for protein data.

    Healthy
    Protein counts normalized separately using an empty-droplet-based background model, since ambient unbound antibody is the dominant noise source, not sequencing depth.
    Red flag
    ADT counts run through the same CPM/log pipeline as RNA, with normalization dominated by ambient antibody signal rather than true protein expression.
  8. Look at how many principal components feed FindNeighbors/RunUMAP, and whether that number was chosen for LogNormalize or SCTransform output.

    Healthy
    Roughly 10 PCs for a LogNormalize workflow, roughly 30 for SCTransform, since SCTransform's better technical model pushes real biological variance into higher PCs.
    Red flag
    SCTransform output truncated to 10 PCs (losing captured biological variance) or LogNormalize output extended to 30 PCs (adding noise dimensions into clustering).

What to do about it

Default to SCTransform v2 for 10x UMI data going into clustering

When: You have standard droplet-based (10x) UMI data headed into PCA, clustering, or cell-type discovery.

Run SCTransform with vst.flavor = "v2", regress out percent.mt, keep the default ~3,000 variable features, and carry ~30 PCs into FindNeighbors/RunUMAP instead of the LogNormalize-era default of ~10.

Caveat: Heavier compute and memory than LogNormalize. If earlier samples in the same study were already processed with LogNormalize, reprocess them with SCTransform too, the two outputs aren't comparable side by side.

Use SCT-aware functions for multi-sample integration

When: You're integrating multiple samples or batches, not just clustering one object.

Run SCTransform per sample separately, then use Seurat's SCT-specific integration path (SelectIntegrationFeatures and PrepSCTIntegration with method = "SCT") consistently across every integration call.

Caveat: Every downstream integration function needs method = "SCT" set explicitly. Miss one and the pipeline silently falls back to LogNormalize assumptions partway through, breaking the workflow without an obvious error.

Feed pseudobulk counts, not log-normalized values, to your DE test

When: You're producing DE results for a paper, a regulatory figure, or any result someone will act on.

Aggregate raw UMI counts per sample and cell type into pseudobulk profiles, then run DESeq2 or edgeR on those counts rather than testing log-normalized single-cell values directly.

Caveat: Needs enough cells and enough biological replicates per group to build a meaningful pseudobulk sample. A pilot with one replicate per condition can't support this properly, treat it as exploratory only.

Normalize CITE-seq protein data with dsb, not the RNA pipeline

When: You have antibody-derived tag (protein) counts alongside RNA in the same experiment.

Use the dsb method, which estimates background from empty droplets, to normalize ADT counts separately from whatever normalized the RNA assay.

Caveat: Requires the raw unfiltered matrix, including empty droplets, which some processed or public datasets have already discarded before deposition.

Reprocess or at least log the pipeline version before merging public datasets

When: You're combining scRNA-seq datasets pulled from GEO, cellxgene, or other public repositories.

Check each dataset's Cell Ranger (or equivalent) version and intron-inclusion setting before treating counts as comparable; reprocess from raw fastqs with one consistent pipeline version when feasible.

Caveat: Full reprocessing costs compute and time that not every meta-analysis budget has. At minimum, record the pipeline version as an explicit covariate so it can be checked against your results later.

When not to "fix" it

If the difference in total UMI counts tracks a real biological axis, don't force normalization to erase it. Plasma cells and other high-output secretory cells, proliferating cells, and cells under a treatment that globally boosts transcription genuinely carry more total RNA than quiescent neighbors. Size-factor normalization assumes total RNA is roughly constant across cells; applying it anyway removes exactly the composition or global-shift signal you were trying to measure. Run the analysis both with and without aggressive size-factor correction and check whether your expected marker genes and known cell-type proportions survive, if the depth difference lines up with your treatment or your known biology rather than with a technical batch, treat it as signal, not noise.

Five things experienced analysts do here

  1. Before committing to SCTransform for a whole project, run both methods on one sample and check how known marker gene expression and expected cell-type separation change, not just how the cluster count changes.
  2. Never mix LogNormalize-based integration anchors with SCTransform-normalized samples in the same integration call; Seurat's integration functions need normalization.method set consistently across every sample.
  3. Log which Cell Ranger version processed every public dataset you pull in before merging counts across studies; intron inclusion alone changes what a count means.
  4. Keep the three assay slots straight: counts feeds pseudobulk DE, data (log-normalized) is for plots and exploratory single-cell tests, scale.data is PCA input only and should never touch a DE test.
  5. Budget for keeping the raw unfiltered matrix around in CITE-seq experiments; dsb needs the empty droplets, and most processing pipelines discard them at the filtering step.

Questions people ask

Should I use LogNormalize or SCTransform for scRNA-seq?

For 10x UMI data headed into clustering and PCA, SCTransform v2 is the stronger default because it models technical noise directly from the counts and produces far fewer false-positive DE genes between depth-differing but biologically identical groups. LogNormalize is still reasonable for quick exploratory passes or when you need behavior consistent with older Seurat workflows.

Can I use TPM or CPM for single-cell RNA-seq?

CPM only adjusts for depth and TPM adds a gene-length correction, but 10x UMI counts are already length-independent, so TPM's extra correction does nothing useful there. Neither was designed to feed count-based DE models, so reserve them for full-length platforms like SMART-seq or for quick cross-sample depth comparisons, not for differential expression.

Which normalization should feed differential expression testing?

Raw or pseudobulk-aggregated counts through DESeq2 or edgeR, not log-normalized or scaled values. The normalized data slot is built for visualization and exploratory single-cell tests like Wilcoxon, not for the count-based statistical models DE tools rely on.

Do I need different normalization for CITE-seq protein data?

Yes. Antibody-derived tag counts are dominated by ambient unbound antibody, a different noise source than RNA sequencing depth, so reusing the RNA CPM/log pipeline leaves that noise in place. dsb, which estimates background from empty droplets, is built specifically for this.

Why do LogNormalize and SCTransform give different clusters on the same data?

They select different variable gene sets and produce different PCA structure. SCTransform's Pearson residuals from regularized negative binomial regression change which genes and how many principal components carry real biological signal, often pushing the useful PC count from around 10 up to around 30, so the neighbor graph and clusters shift even before you touch the resolution parameter.

Related pages

Related reading on the blog

Sources

  1. Using sctransform in Seurat — SCTransform v2 defaults: ~3,000 variable features and ~30 PCs versus LogNormalize's ~2,000/~10.
  2. Normalize Data, NormalizeData • Seurat — LogNormalize formula: divide by cell total, scale by 10,000, apply log1p.
  3. Normalization and variance stabilization of single-cell RNA-seq data using regularized negative binomial regression — False-positive DE gene counts between depth-differing, biologically identical groups: ~2,000 for LogNormalize vs 11 for SCTransform.
  4. Single-cell RNA-seq data normalization: A benchmarking study — Best normalization method depends on platform: Dino performs best for 10x, SCTransform for SMART-Seq2 low-dropout data.
  5. scanpy.pp.normalize_total, Scanpy documentation — Python equivalent of library-size normalization and its target_sum/CPM behavior.
  6. A multicenter study benchmarking single-cell RNA sequencing technologies using reference samples — Batch-effect correction, not normalization choice, was the dominant factor in correct cell classification.

Part of the Normalization choice series.