Differential Expression Analysis: Guide with R & Python Code
What Is Differential Expression Analysis?
Differential expression (DE) analysis identifies which genes change their expression between conditions (e.g., treated vs. control). This guide walks you through the complete workflow for bulk RNA-seq and single-cell data, with ready-to-run code in R (DESeq2, edgeR, limma-voom) and Python (Scanpy).
The devil, as usual, is in the details — experimental design, normalization, dispersion modeling, multiple testing, and interpretation. This post gives you a clear, practical path through bulk RNA‑seq (and what changes for single‑cell), with concise definitions and copy-paste examples.
Updated 2026-10-19: section 6 now includes an executed check, on the public PBMC 3k dataset,
of what Scanpy’s logfoldchanges column measures and how far it sits from a ratio of means.
The R code on this page was not re-executed in this update.
TL;DR
- Plan the design (replicates, covariates, contrasts).
- Get counts (gene × sample matrix) and metadata (sample table).
- Filter low counts; normalize library size; model counts (NB).
- Test contrasts, control FDR, shrink log2FCs.
- QC and visualize (PCA, MA, volcano).
- Interpret with gene set enrichment.
- For single‑cell, prefer pseudobulk across biological replicates, or use cell‑level tests with care.
Key terms you’ll actually use
- Feature: usually a gene; can be a transcript or exon.
- Counts: integer read counts per feature per sample.
- Library size: total counts per sample (sequencing depth).
- Normalization: corrects sample‑level scaling (e.g., size factors, TMM).
- Dispersion (α): extra‑Poisson variability modeled by Negative Binomial (NB).
- Design matrix: encodes your experimental design (e.g.,
~ batch + condition). - Contrast: the specific comparison you test (e.g., treated vs control).
- log2 fold change (log2FC): magnitude of effect; often shrunk to stabilize.
- p‑value / adjusted p‑value (FDR): significance after multiple testing control.
- Batch effect: unwanted variation (date, kit, lane, donor) you model as covariates.
- Pseudobulk (scRNA‑seq): aggregate counts per sample × cell type to use bulk methods.
0) Experimental design first (don’t skip!)
- Replicates: ≥3 biological replicates per group is a practical minimum.
- Covariates: record anything systematic (batch/date/donor/sex/RIN). Add them to the model.
- Factorial designs: interactions test whether an effect differs by another factor
e.g.,
~ genotype * treatmentlets you examine genotype:treatment interaction. - Paired designs: repeated measures? Use
~ subject + condition(block on subject).
1) From FASTQ to counts (what you need to know)
- Alignment-based: STAR/Hisat2 → featureCounts/HTSeq to gene counts.
- Alignment-free: Salmon/Kallisto (quasi‑mapping) → transcript quantification; sum to genes via tximport.
- Do not use TPM/FPKM directly for DE testing—use raw counts (normalized internally by the method).
Everything on this page starts from a count matrix, which means it starts from libraries
that were pooled at a target molarity, not a target mass. That conversion is where a
surprising amount of uneven sequencing depth comes from — the ng/µL → nM step depends on
a molecular-weight convention that different calculators do not agree on, to the tune of
about 7%. If your library sizes came back lopsided, it is worth reading
DNA molecular weight in Python before you blame the
sequencer, and using the
DNA concentration calculator
to redo the pooling arithmetic.
2) Bulk RNA‑seq with DESeq2 (R)
Below is a lean, reliable template using the Bioconductor airway dataset (glucocorticoid treatment). It shows filtering, modeling with a batch covariate, contrasts, LFC shrinkage, and common plots.
Install once (uncomment if needed):
# install.packages("BiocManager") # BiocManager::install(c("DESeq2", "airway", "apeglm")) # install.packages("ggplot2")
library(DESeq2)
library(airway)
library(ggplot2)
# 1) Load example counts + metadata
data(airway)
se <- airway
# Factors: cell line (~batch-like) and dex (treated vs untrt)
se$dex <- relevel(se$dex, ref = "untrt")
# 2) Build DESeqDataSet, filter very low counts
dds <- DESeqDataSet(se, design = ~ cell + dex)
keep <- rowSums(counts(dds) >= 10) >= 3
dds <- dds[keep,]
# 3) Fit model (NB with empirical dispersion)
dds <- DESeq(dds)
# 4) Extract results for the contrast of interest
res <- results(dds, contrast = c("dex", "trt", "untrt")) # treated vs control
res <- lfcShrink(dds, coef = "dex_trt_vs_untrt", type = "apeglm") # effect-size shrinkage
# 5) QC & visualization
vsd <- vst(dds, blind = FALSE)
# PCA colored by condition
plotPCA(vsd, intgroup = "dex")
# MA plot of shrunk effects
plotMA(res, ylim = c(-4,4))
# Volcano with ggplot2
res_df <- as.data.frame(res)
res_df$neglog10padj <- -log10(res_df$padj)
res_df$hit <- with(res_df, !is.na(padj) & padj < 0.05 & abs(log2FoldChange) > 1)
ggplot(res_df, aes(x = log2FoldChange, y = neglog10padj)) +
geom_point(alpha = 0.4) +
geom_hline(yintercept = -log10(0.05), linetype = 2) +
geom_vline(xintercept = c(-1, 1), linetype = 2) +
labs(x = "log2 fold change (shrunk)", y = "-log10(FDR)", title = "Volcano plot")
# 6) Tidy significant table
sig <- subset(res_df, padj < 0.05)
sig <- sig[order(sig$padj), c("log2FoldChange","lfcSE","stat","pvalue","padj")]
head(sig, 10)Notes on this workflow
design = ~ cell + dextreats cell line as a batch‑like covariate to reduce false positives.vst(orrlog) is for visualization/QC, not for the DE fit itself.lfcShrink(..., type="apeglm")gives more stable effect sizes for ranking and plotting.
3) Alternative bulk frameworks
If you would rather stay in Python than switch to R for the DESeq2 step, the whole model above has a native Python implementation — see the step-by-step PyDESeq2 tutorial, which covers count matrices, dispersion fitting, LFC shrinkage and plotting without an R install.
edgeR (R, quasi‑likelihood GLM; great for multifactor designs)
# install.packages("BiocManager"); BiocManager::install("edgeR")
library(edgeR)
counts_mat <- counts(dds) # reuse counts from above for illustration
group <- colData(dds)$dex
batch <- colData(dds)$cell
y <- DGEList(counts = counts_mat)
keep <- filterByExpr(y, group = group) # adaptive low-count filter
y <- y[keep,, keep.lib.sizes=FALSE]
y <- calcNormFactors(y, method = "TMM")
design <- model.matrix(~ batch + group)
y <- estimateDisp(y, design)
fit <- glmQLFit(y, design)
qlf <- glmQLFTest(fit, coef = "grouptrt") # treated vs control
topTags(qlf, n = 10)limma‑voom (R, fast and flexible; works exceptionally well with many samples)
# BiocManager::install(c("limma"))
library(limma)
v <- voom(counts_mat, design = design) # uses the same 'design' from edgeR block
fit <- lmFit(v, design)
fit <- eBayes(fit)
topTable(fit, coef = "grouptrt", number = 10)4) Going beyond a simple two‑group comparison
- Multiple groups: specify
design = ~ batch + conditionand test pairwise or custom contrasts. - Interactions:
design = ~ batch + genotype * treatmentthen test the interaction term (e.g.,results(..., name = "genotypeB.treatmenttreated")in DESeq2). - Paired:
design = ~ subject + condition; the contrast tests within‑subject changes. - Time course: model time as a factor or spline; in limma you can use duplicateCorrelation for repeated measures.
5) Common QC you should always look at
- Library sizes & complexity (barplots; proportion of reads mapped).
- Sample‑to‑sample distances (Euclidean on VST/rlog; heatmap).
- PCA/UMAP on transformed counts—should separate by biology, not by batch.
- Mean–variance trend (voom plot) to ensure modeling assumptions are OK.
# Sample distance heatmap (VST) example
library(pheatmap)
d <- dist(t(assay(vsd)))
pheatmap(as.matrix(d), clustering_distance_rows = d, clustering_distance_cols = d)QC plots are the part collaborators actually ask to see, and they travel badly as screenshots. If you are working in a notebook, the ipynb → HTML converter turns the whole QC section into a single self-contained file with the figures inline — no kernel needed at the other end. For a fixed lab-record copy, ipynb → PDF does the same without a local LaTeX install.
6) Single‑cell RNA‑seq: do it right
Two solid strategies
-
Pseudobulk (recommended when you have replicates) Aggregate counts per sample × cell type (or cluster), then run bulk DE (DESeq2/edgeR/limma).
- Captures between‑sample variability → valid p‑values.
- Easy to add covariates (donor, batch).
- Use the same steps as bulk, just do them per cell type.
Sketch in R (using a SingleCellExperiment
sce):# BiocManager::install(c("SingleCellExperiment","scuttle")) library(SingleCellExperiment); library(scuttle) # ids says how to group cells; here by sample and cell_type pb <- aggregateAcrossCells(sce, ids = colData(sce)[, c("sample", "cell_type")]) # pb assays now contain summed counts; build DE model per cell_type or with interaction -
Cell‑level tests (when pseudobulk is impossible) Use methods like Wilcoxon rank‑sum (Scanpy), or model‑based (MAST). Control for library size and latent factors.
Scanpy example (Python, cluster markers):
import scanpy as sc adata = sc.read_h5ad("my_data.h5ad") # adata.X should be counts or use adata.layers['counts'] sc.pp.normalize_total(adata, target_sum=1e4) # for ranking/visualization sc.pp.log1p(adata) sc.tl.leiden(adata, resolution=0.5) sc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon") sc.pl.rank_genes_groups(adata, n_genes=20)Condition DE within a cluster:
cluster = "2" ad = adata[adata.obs["leiden"] == cluster].copy() sc.tl.rank_genes_groups(ad, groupby="condition", groups=["treated"], reference="control", method="wilcoxon") sc.get.rank_genes_groups_df(ad, group="treated").head()
What logfoldchanges actually is in Scanpy (checked on a real dataset)
Added 2026-10-19. Everything in this subsection was executed on 10x Genomics’ public PBMC 3k
dataset (sc.datasets.pbmc3k(), 2,700 cells) with scanpy 1.12.4, anndata 0.13.4, numpy 2.5.4
and pandas 3.0.6; the output blocks are pasted from that run, not typed.
The snippet above skips the steps between loading and clustering, so the run that produced the output below had to add them. This is the pipeline that was actually executed, with the one line that keeps a copy of the normalized-but-not-logged values for the comparison further down:
adata = sc.datasets.pbmc3k() # raw 10x counts, 2700 cells x 32738 genes
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=3)
sc.pp.normalize_total(adata, target_sum=1e4)
adata.layers["norm"] = adata.X.copy() # normalized, not logged — used below
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=2000)
sc.pp.pca(adata, n_comps=30, mask_var="highly_variable")
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=30, random_state=0)
sc.tl.leiden(adata, resolution=0.5, random_state=0, flavor="igraph", n_iterations=2)
sc.tl.rank_genes_groups(adata, groupby="leiden", method="wilcoxon")(flavor="igraph" because the default leidenalg flavor is not installed in that
environment; it gave 8 clusters.) Asked for cluster 0, sc.get.rank_genes_groups_df
returns this:
names scores logfoldchanges pvals pvals_adj
CCL5 28.466553 5.961505 3.040742e-178 4.170073e-174
NKG7 23.936359 4.918181 1.281658e-126 8.788332e-123
B2M 19.863453 0.733833 8.431016e-88 3.854099e-84
IL32 19.377666 2.669144 1.191286e-83 4.084324e-80
GZMA 19.228132 4.025735 2.152405e-82 5.903617e-79If you come from DESeq2, you will read logfoldchanges as log2(mean expression in the
cluster / mean expression outside). It is not that number. Three candidate definitions
were recomputed for those five genes from the same normalized matrix and compared with what
Scanpy reports:
gene scanpy_lfc A_log2_ratio_of_means B_scanpy_formula C_diff_of_log_means_log2 mean_norm_in mean_norm_out
CCL5 5.962 3.689 5.962 4.639 51.397999 3.984000
NKG7 4.918 2.269 4.918 3.746 42.792000 8.878000
B2M 0.734 0.649 0.734 0.730 282.230011 180.044998
IL32 2.669 1.489 2.669 2.266 21.267000 7.578000
GZMA 4.026 2.466 4.026 2.265 10.218000 1.850000
max |scanpy_lfc - B| = 4.291534423828125e-06
max |scanpy_lfc - A| = 2.64912486076355
max |scanpy_lfc - C| = 1.7609531192757912Column B reproduces Scanpy’s value to within 4.3 × 10⁻⁶ on all five genes. It is
log2((expm1(mean of log1p values in group) + 1e-9) / (expm1(mean of log1p values outside) + 1e-9)):
the mean is taken on the log scale first, then un-logged. Because expm1(mean(log)) is a
geometric-style mean, it is pulled down by the zeros that dominate single-cell data, and the
two groups rarely have the same fraction of zeros, so the ratio drifts away from a ratio of
arithmetic means. For CCL5 that drift is 2.3 log2 units: Scanpy reports a 62-fold change
where the ratio of mean normalized counts is 13-fold.
How often does it matter? Over the 5,690 genes with mean normalized count above 0.1 on both sides:
B - A (Scanpy minus ratio-of-means), log2 units: median -0.077, 5th pct -0.388, 95th pct +0.238
|B - A| > 1 log2 unit (i.e. > 2x) in 88 genes; sign differs in 642 genes
sign differs AND ratio-of-means |LFC| > 0.5 (the two summaries disagree in direction by more than 1.4x): 43 genes
five largest disagreements:
gene scanpy_lfc ratio_of_means_lfc mean_norm_in mean_norm_out
NFE2L2 -0.108 2.808 2.195000 0.313
NKG7 4.918 2.269 42.792000 8.878
AIM2 -0.858 1.758 0.373000 0.110
PF4 -0.706 -3.091 0.119000 1.017
CCL5 5.962 3.689 51.397999 3.984Most genes agree within ±0.4 log2 units, which is why this rarely gets noticed. The tail is the problem: NFE2L2 has a 7× higher mean normalized count in the cluster and Scanpy reports it as slightly down. The per-cell values show why:
NFE2L2: per-cell normalized counts, inside cluster 0 vs outside
in cells= 370 nonzero= 19 (5.1%) mean=2.195 median=0.000 max=713.25 top5=[713.25, 9.890000343322754, 9.0, 8.640000343322754, 6.869999885559082] top5 share of sum=92.1% mean(log1p)=0.1071
out cells= 2330 nonzero= 166 (7.1%) mean=0.313 median=0.000 max=16.89 top5=[16.889999389648438, 16.420000076293945, 14.619999885559082, 13.329999923706055, 12.479999542236328] top5 share of sum=10.1% mean(log1p)=0.1150One cell in the cluster carries a normalized count of 713 — about 88% of the cluster’s total of roughly 812 (370 cells × mean 2.195); the top five cells together are 92.1% — so the arithmetic mean is 7× higher inside, while the gene is detected in a smaller fraction of cells inside (5.1% vs 7.1%) and the mean of the log values is slightly lower inside. The arithmetic mean sees the outlier; the mean of logs sees the detection rate. For 43 genes the two summaries disagree in sign while the ratio-of-means version is larger than 0.5 log2 units. These are differences between two descriptive summaries of the same matrix; nothing here tests which genes are significantly different, and none of it involves replicates, covariates or shrinkage the way the DESeq2 example above does.
What to do with that:
- Do not apply a DESeq2-style
|log2FC| > 1filter to Scanpy’s column and expect the same genes a pseudobulk run would give. Thescoresandpvals_adjcolumns come from the Wilcoxon test and are fine for ranking; it is the effect-size column that is on a different scale. - If you need a fold change you can compare with bulk results, compute it yourself from
the normalized-but-not-logged layer saved above (
adata.layers["norm"]) as log2 of the ratio of group means, or go the pseudobulk route above and let DESeq2 or edgeR estimate it. - Pass
pts=Truetorank_genes_groups. The returned frame then carries the fraction of cells expressing each gene in and outside the group, which is the number most people actually want next to a single-cell marker:
names scores logfoldchanges pvals pvals_adj pct_nz_group pct_nz_reference
CCL5 28.466553 5.961505 3.040742e-178 4.170073e-174 0.986486 0.213305
NKG7 23.936359 4.918181 1.281658e-126 8.788332e-123 0.913514 0.205150
B2M 19.863453 0.733833 8.431016e-88 3.854099e-84 1.000000 0.999571
IL32 19.377666 2.669144 1.191286e-83 4.084324e-80 0.948649 0.491416
GZMA 19.228132 4.025735 2.152405e-82 5.903617e-79 0.732432 0.115880The formula is in Scanpy’s source (scanpy/tools/_rank_genes_groups.py, the expm1
branch) and the check above is the only evidence this page offers for it; the run is
deterministic (random_state=0), so the numbers should reproduce exactly with the same
package versions.
Tip: If you have multiple donors per condition, pseudobulk generally yields the most reliable inferences for scRNA‑seq DE because it respects biological replication.
7) Multiple testing, thresholds, and effect sizes
- Report both FDR (e.g., padj < 0.05) and effect size (|log2FC| ≥ 1 is a common, not universal, threshold).
- Prefer shrunk LFCs (DESeq2:
lfcShrink) for ranking/plotting; use unshrunk for exact algebraic contrasts if needed. - Avoid hard CPM/TPM cutoffs after modeling; do expression filtering before fitting using robust rules (e.g., edgeR
filterByExpr).
8) Gene set enrichment (quick start)
Once you have a ranked list, run GSEA/ORA against pathways (GO/KEGG/MSigDB). In R, fgsea works well:
# BiocManager::install("fgsea")
library(fgsea)
# ranks: named vector of gene scores (e.g., signed -log10 p; or log2FC)
ranks <- res_df$stat; names(ranks) <- rownames(res_df)
# pathways <- your list of gene sets as a named list
fgseaRes <- fgsea(pathways, ranks, minSize=15, maxSize=500)
fgseaRes[order(fgseaRes$pval),][1:10, c("pathway","NES","padj")]9) Frequent pitfalls & how to dodge them
- No replicates → you can’t estimate dispersion well. Get replicates or treat as exploratory only.
- Batch‑confounded design (all cases sequenced on one lane, all controls on another) → you cannot disentangle effects.
- Using TPM/FPKM for DE tests → don’t. Provide raw counts to DE tools; they handle normalization.
- Filtering after testing → inflates false positives. Filter low expression before modeling.
- Over‑interpreting p‑values → focus on biological effect sizes and pathway context.
- Mixing technical with biological replicates → do not average technical replicates; sum counts or include them as nested factors.
10) Handy snippets (copy/paste)
DESeq2 custom contrast (multi‑group):
# Suppose condition has levels: ctrl, drugA, drugB
res_AB <- results(dds, contrast = c("condition", "drugA", "drugB"))DESeq2 interaction test (does treatment effect differ by genotype?):
# design = ~ batch + genotype * treatment
# Test the interaction term directly (naming may vary by factor levels)
results(dds, name = "genotypeKO.treatmenttreated")tximport for Salmon/Kallisto → gene‑level counts:
# BiocManager::install(c("tximport","readr"))
library(tximport); library(readr)
samples <- read_csv("samples.csv") # columns: sample, condition, path_to_quant
files <- setNames(samples$path_to_quant, samples$sample)
tx2gene <- read_csv("tx2gene.csv") # columns: transcript, gene
txi <- tximport(files, type = "salmon", tx2gene = tx2gene)
dds <- DESeqDataSetFromTximport(txi, colData = samples, design = ~ batch + condition)
dds <- DESeq(dds)edgeR user‑defined contrasts (limma-style):
library(limma); library(edgeR)
design <- model.matrix(~ 0 + condition) # no intercept
colnames(design) <- levels(condition)
y <- estimateDisp(DGEList(counts), design)
fit <- glmQLFit(y, design)
contr <- makeContrasts(DrugA_vs_Ctrl = DrugA - Ctrl, levels = design)
qlf <- glmQLFTest(fit, contrast = contr[,"DrugA_vs_Ctrl"])
topTags(qlf)11) A short checklist you can tape next to your monitor
- Do I have ≥3 biological reps per group?
- Did I record batch/covariates and include them in the design?
- Did I filter lowly expressed genes before modeling?
- Did I use raw counts for the DE fit (not TPM/FPKM)?
- Did I check QC (library size, PCA, sample distances)?
- Are my contrasts answering the scientific question?
- Did I report FDR and effect sizes (shrunk log2FC)?
- Did I perform pathway analysis for biological context?
- Are my conclusions robust to batch and confounding?
Closing thoughts
Differential expression analysis is conceptually simple but statistically rich. If you (1) plan your design, (2) model with the correct likelihood (NB for counts), (3) control FDR, and (4) keep an eye on effect sizes and QC plots, you will produce results that are both statistically defensible and biologically interpretable. The code above is a practical starting point you can adapt to bulk and single‑cell projects alike.
The design formula is the part worth spending your time on. Everything downstream — shrinkage, FDR, enrichment — is comparatively mechanical, and a formula that omits a real covariate cannot be rescued by any of it.
Related reading
- DESeq2 in Python: Step-by-Step PyDESeq2 Tutorial — the same model, run entirely in Python.
- DNA Molecular Weight in Python: A Runnable ng/µL → nM Workflow — the upstream quantification step, and why library pooling molarities disagree by 7%.
- Install Jupyter Notebook on macOS — for setting up the Python side of this workflow.