Sanity check · Single-Cell RNA-seq
How to Log-Transform Counts Without Fooling Yourself in Single-Cell RNA-seq
A pseudocount of 1 versus 1e-9 can turn the same CD19 fold change from 1.24 into 5.64, know which number your tool used before you trust it.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 5 min read
You're staring at FindAllMarkers output, and avg_log2FC for a marker you expected to be strong, CD19 for a B cell cluster, say, looks either suspiciously modest or suspiciously huge depending on which run you're looking at. Nothing about your input data changed. What changed is the epsilon buried in the fold-change formula, or the pseudocount your normalization step added before taking a log. log(0) is undefined, every pipeline patches around it, and that patch is not neutral.
The stakes are concrete: a marker panel built on inflated logFC for a barely-detected gene, a reviewer who reruns your numbers with a different tool and gets a different answer, or a batch effect that masquerades as a condition effect because one group happened to be sequenced deeper. None of this shows up as an error message. It shows up as a number that looks plausible and is wrong.
In the next hour you can do three things: find out exactly what pseudocount and formula your current pipeline uses, run a cheap check on one low-expressed gene to see how much that choice is driving your result, and decide whether SCTransform, VST, or a pct-expressed filter is the right fix for your specific comparison.
What it looks like when it's happening
- avg_log2FC for a rare marker (CD19, FOXP3, low-UMI genes) changes by several-fold when you tweak the epsilon or pseudocount, while housekeeping genes barely move
- a volcano or MA plot has a dense wall of points sitting at the same small logFC near zero expression
- a histogram of log1p-normalized values for a gene is a narrow spike near zero with a long thin tail, no visible separation between 'off' and 'on' cells
- Seurat's avg_log2FC and Scanpy's logfoldchanges disagree in magnitude, sometimes in direction, for the same gene and the same two clusters
- clusters or conditions that differ mainly in nCount_RNA / total UMI also come out as having the most differentially expressed genes
- a Wilcoxon test on log-normalized counts calls a gene significant with a fold change too small to matter biologically
- marker rankings shift noticeably after changing scale.factor in NormalizeData from the 10,000 default
Why it happens
Every UMI count matrix is full of zeros, most genes aren't detected in most cells, either because they truly aren't expressed or because a shallow per-cell library missed a real transcript (a technical zero, or dropout). You can't take log(0), so normalization pipelines add a pseudocount before logging: Seurat's NormalizeData with LogNormalize divides by total counts, scales by 10,000, and applies log1p, which is an implicit pseudocount of 1. That's a reasonable default, it preserves sparsity and its distorting effect shrinks as sequencing gets deeper, but it is still an arbitrary constant sitting inside every fold-change calculation downstream.
The distortion is worst exactly where you're least likely to notice it: low-expressed genes. For a highly expressed gene, adding 1 to a count of 500 changes nothing. For CD19 detected at a handful of counts per cell, the pseudocount dominates the arithmetic. The research behind this page shows a concrete case: the same two-cluster comparison for CD19 produces logFC = 1.24 with a pseudocount of 1 and logFC = 5.64 with a pseudocount of 1e-9, a 4.4-fold difference from changing one constant, not one biological fact. Seurat's FindAllMarkers computes avg_log2FC as log2(mean(expm1(Y1)) + eps) − log2(mean(expm1(Y2)) + eps) with eps = 1e-9; Scanpy averages the log-transformed values before exponentiating. Different formula, different epsilon, different answer for the same gene, which is why cross-checking a marker between Seurat and Scanpy output can look like a disagreement about biology when it's a disagreement about arithmetic.
Layered on top of the pseudocount problem is sequencing depth. Cells with fewer UMIs have more technical zeros simply because fewer molecules were sampled, not because the gene is off. If depth differs systematically across the clusters or conditions you're comparing, a very common situation when samples were multiplexed unevenly or processed in different batches, naive differential expression on log-normalized counts over-calls genes that are just better sampled in one group. The log transform doesn't fix this; it just makes the artifact look like a stable number you can put a p-value next to.
Variance-stabilizing approaches exist specifically to route around the pseudocount problem rather than patch it. DESeq2's vst/rlog transform size-factor-normalized counts to make variance roughly constant across the expression range instead of relying on an arbitrary additive constant. Seurat's SCTransform goes further for single-cell data: it fits a regularized negative binomial regression per gene and works with Pearson residuals, so there's no pseudocount or log step in the actual modeling, the log-normalized values you see afterward are only for visualization, not for the statistics.
The checks
Run them in order. Each one tells you what healthy looks like and what the problem looks like.
0/7 checked · saved in this browser
Grep your analysis script for the normalization call and check what defaults it's using, Seurat's LogNormalize defaults to scale.factor = 10000 and an implicit pseudocount of 1 from log1p; Scanpy's normalize_total defaults to target_sum=None (median depth) and needs an explicit log1p() call after.
bashgrep -n "NormalizeData\|log1p\|scale.factor\|normalize_total" analysis.R analysis.py 2>/dev/null- Healthy
- You can name the exact pseudocount and scale factor your pipeline used, and it matches a deliberate choice, not just a copied vignette default.
- Red flag
- You have to go dig through Seurat/Scanpy source to find out what epsilon your marker table was computed with, meaning nobody chose it on purpose.
Pull raw (or normalized, pre-log) expression for a lowly-detected marker gene in the two groups you're comparing, then compute Seurat's avg_log2FC formula with eps = 1 and eps = 1e-9 and compare.
reps <- c(1, 1e-9) sapply(eps, function(e) { log2(mean(expm1(y1)) + e) - log2(mean(expm1(y2)) + e) })- Healthy
- For a moderately-to-highly expressed gene, the two logFC values are close (within a few tenths).
- Red flag
- The two values differ by several-fold (e.g. 1.24 vs 5.64, the CD19 case), the number is pseudocount-driven, not biology-driven.
Compute per-gene mean and variance of the log-normalized expression matrix across cells and plot variance against mean.
rm <- rowMeans(norm_mat) v <- apply(norm_mat, 1, var) plot(m, v, log = "xy", xlab = "mean", ylab = "variance")- Healthy
- For SCTransform Pearson residuals or a proper VST, variance is roughly flat across the mean range.
- Red flag
- Variance still climbs with mean for low-count genes after log1p, the log hasn't actually stabilized variance the way the name suggests.
Plot nCount_RNA (or total_counts in Scanpy) per cell, grouped by the cluster or condition you're running DE on.
rVlnPlot(pbmc, features = "nCount_RNA", group.by = "seurat_clusters")- Healthy
- Similar median depth across the groups being compared.
- Red flag
- The group with the most DE genes is also the group with visibly higher median nCount, a depth confound, not biology.
Run marker detection in both tools on the same processed data and compare direction and magnitude for a handful of low-expressed genes.
pythonsc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon") sc.get.rank_genes_groups_df(adata, group="0").head()- Healthy
- Same direction and roughly comparable magnitude for genes with decent expression.
- Red flag
- Sign or magnitude disagree substantially for a low-expression gene, Seurat averages counts before logging, Scanpy averages logs before exponentiating, and the gap shows up exactly where the pseudocount matters most.
Run SCTransform alongside your LogNormalize pipeline on the same object, redo FindAllMarkers on the SCT assay, and compare top markers per cluster.
rpbmc <- SCTransform(pbmc, vars.to.regress = "percent.mt", verbose = FALSE) markers_sct <- FindAllMarkers(pbmc, assay = "SCT")- Healthy
- High overlap in top markers between the two methods.
- Red flag
- Markers unique to the log-normalized run are concentrated among genes detected in under 5-10% of cells.
Plot a histogram of log1p-normalized expression for the gene across all cells in the comparison.
rhist(norm_mat["CD19", ], breaks = 50)- Healthy
- For a real marker, a visible split, a spike at zero for non-expressing cells and a separated peak in the expressing population.
- Red flag
- Everything piles into a narrow band just above zero with no separation, the log1p compression is hiding whatever signal exists.
What to do about it
Switch to SCTransform for anything feeding marker detection or clustering
When: You're doing per-gene modeling, feature selection, or marker detection in Seurat and want to avoid an arbitrary pseudocount driving the result.
Run pbmc <- SCTransform(pbmc, vars.to.regress = "percent.mt", verbose = FALSE) in place of NormalizeData + FindVariableFeatures + ScaleData, then use the SCT assay for PCA, clustering, and FindAllMarkers.
Caveat: SCTransform still log-normalizes its depth-corrected counts for visualization, so the pseudocount question doesn't fully disappear, just moves off the statistics. It's also slower and its regression is fit per dataset, so rerun it if you add samples or cells.
Report pct.expressed alongside any logFC for low-count genes
When: Any DE or marker table includes genes detected in under roughly 10% of cells in either group.
Keep the pct.1/pct.2 columns FindAllMarkers already gives you (or compute the equivalent detection rate in Scanpy), and flag or filter markers with high logFC but low detection rate before reporting them.
Caveat: This doesn't fix the underlying math, it just stops you from over-interpreting it, and filtering too aggressively can bury a real rare marker that's biologically meaningful precisely because it's sparse.
Use VST/rlog for pseudobulk comparisons, not for the DE test itself
When: You're comparing conditions by aggregating counts to pseudobulk (sum per sample per cell type) for a proper statistical test.
Aggregate to a pseudobulk count matrix, run vst(dds, blind=FALSE) (or rlog for under ~100 samples) for PCA and visualization, but run the actual DE test on the raw pseudobulk counts through DESeq2's negative binomial model, not on the transformed values.
Caveat: Pseudobulk needs enough cells per sample/cell-type combination to be meaningful; thin pseudobulk groups just reintroduce the same low-count instability one level up.
Don't run rank-based or count-based tests on log-normalized counts when depth differs across groups
When: Check 4 shows the groups you're comparing have visibly different median nCount.
Equalize by downsampling to the lower depth, add depth as a covariate in a GLM-based test (e.g. a negative binomial model), or switch to a method built to model depth explicitly rather than testing on already-log-transformed values.
Caveat: Downsampling throws away real reads and loses power; adding depth as a covariate adds a modeling step you now have to defend.
When not to "fix" it
For genes with moderate-to-high expression, the pseudocount is negligible next to the count itself, don't spend time tuning epsilon for your top markers, only for the low-expression tail. And if a gene is genuinely on/off across a small, biologically real population, CD19 detected almost exclusively in B cells, with true zeros everywhere else, the sparsity isn't an artifact to correct away. Forcing a "smoother" fold change onto a marker like that with a different pseudocount doesn't make the biology more accurate, it just hides the fact that the gene really is restricted to one population. Report the raw detection rate and counts alongside the logFC instead of tuning the transform to make the number look a certain way.
Five things experienced analysts do here
- Never report avg_log2FC alone for a low-expression gene, always pair it with pct.1/pct.2 (or the Scanpy equivalent) so a reader can tell if the fold change is backed by real detection or by a handful of counts near the pseudocount.
- Know which formula and epsilon your tool uses before comparing logFC across Seurat and Scanpy output, they compute the average differently, and a disagreement between them is often arithmetic, not biology.
- Never feed log-transformed values into a count-based statistical test (DESeq2, edgeR); and treat rank-based tests like Wilcoxon on log-normalized counts as biased once per-cell depth is uneven across the groups being compared.
- Plot per-cluster or per-condition total UMI count before trusting any DE result, if depth differs, treat every 'significant' gene as confounded until you've checked it survives a depth-aware test.
- Don't assume more sophisticated always beats simpler: a benchmark across 22 transformations found shifted-log-plus-PCA still competitive with much more complex methods, so check the depth confound first before reaching for a fancier transform.
Questions people ask
- Should I use log2(x+1) or VST before clustering scRNA-seq data?
For clustering and visualization, Seurat's default log1p (pseudocount 1) is a reasonable, well-tested choice and matches most published workflows. For marker detection and feature selection where fold-change accuracy on low-expressed genes matters, SCTransform's Pearson residuals avoid the pseudocount question entirely and are the better default in Seurat's own current vignettes.
- Why does the pseudocount matter for fold change in scRNA-seq?
Because log(0) is undefined, every pipeline adds a small constant before logging. For highly expressed genes that constant is irrelevant, but for lowly expressed genes it can dominate the fold-change calculation, the documented CD19 example shows a logFC of 1.24 versus 5.64 for pseudocounts of 1 versus 1e-9, a 4.4-fold swing from the constant alone.
- Is SCTransform better than NormalizeData's LogNormalize?
For marker detection, variable feature selection, and anything feeding a statistical test, yes, SCTransform models counts with regularized negative binomial regression and Pearson residuals, avoiding the arbitrary pseudocount. LogNormalize is simpler, faster, and fine when you just need normalized values for a UMAP plot or quick visualization.
- Can I feed log-normalized single-cell counts into DESeq2?
No. DESeq2 expects raw counts and models them with its own negative binomial dispersion estimation and size factors; feeding it already log-transformed values breaks that model. If you want a DESeq2-style test on single-cell data, aggregate to pseudobulk raw counts first, then let DESeq2 do its own normalization and transformation.
- Why do Seurat and Scanpy give different log2FC values for the same gene?
They use different formulas. Seurat's FindAllMarkers averages the counts (after undoing the log) before logging the ratio, with an epsilon of 1e-9. Scanpy's default workflow averages the already-log-transformed values before exponentiating. The two approaches converge for well-expressed genes but diverge for low-expressed ones.
Related pages
- Guide · How to Detect Integration Over-Correction in Spatial Transcriptomics
- Guide · How to Sanity-Check Marker Genes and Cell Type Labels in Spatial Transcriptomics
- Guide · How to Tell If You Overclustered in Spatial Transcriptomics
- Glossary · Log fold change (log2FC)
- Glossary · Pseudobulk
- Glossary · Spatial transcriptomics
Related reading on the blog
Sources
- Seurat: NormalizeData reference — LogNormalize default scale.factor=10000 and implicit pseudocount of 1 via log1p
- Do you really understand log2Fold change in single-cell RNAseq data? — CD19 pseudocount example and Seurat vs Scanpy avg_log2FC formula comparison
- Using sctransform in Seurat — SCTransform Pearson residual approach and corrected-counts workflow
- Chapter 2 Normalization, redux | Advanced Single-Cell Analysis with Bioconductor — Recommendation of pseudocount=1 as default
- Analyzing RNA-seq data with DESeq2 — vst vs rlog guidance for pseudobulk comparisons
- Scanpy: normalize_total API reference — normalize_total defaults and need for explicit log1p
- Comparison of Transformations for Single-Cell RNA-Seq Data — Benchmark showing shifted log plus PCA remains competitive
- GitHub: PseudoCount2018, Some thoughts on choosing an appropriate pseudo-count — Mean-of-logs vs log-of-mean and pseudocount shrinkage effects on low-abundance genes
Part of the Log transformation of counts series.