Before you start
- You've done From Raw Counts to Differential Expression, so you have a DESeq2 results table.
- A little R, and a free Google account to run the Colab notebook.
Learning objectives
By the end of this lesson you will be able to: read a volcano plot and an MA plot, connect each axis to log2 fold change and statistical significance, and use these plots in R to spot the genes that change most between conditions.
Two plots, two questions
Both plots are drawn from the same results table, but they answer different questions:
- The volcano plot asks: "which genes are both big and trustworthy?" It puts effect size on one axis and confidence on the other, so the genes that matter shoot up into the corners.
- The MA plot asks: "is my data healthy, and are changes spread fairly across expression levels?" It plots fold change against how strongly each gene is expressed, which is a great sanity check.
You'll use the volcano to pick out hits and the MA plot to trust your experiment. Both belong in any RNA-seq report.
▶ Code along in Google Colab
Set the runtime to R (Runtime, then Change runtime type, then R), and run each block as you read. The DESeq2 install takes about 3 to 5 minutes the first time, which is completely normal and only happens once per session.
1Get a results table to plot
We'll rebuild the results from the previous lesson so this notebook stands on its own. We keep both the raw res and the shrunken res_shrunk, because each plot prefers a different one.
library(DESeq2) base <- "https://raw.githubusercontent.com/owkin/PyDESeq2/main/datasets/synthetic/" counts <- as.matrix(read.csv(paste0(base, "test_counts.csv"), row.names = 1)) metadata <- read.csv(paste0(base, "test_metadata.csv"), row.names = 1) metadata$condition <- factor(metadata$condition) metadata <- metadata[colnames(counts), , drop = FALSE] dds <- DESeqDataSetFromMatrix(counts, metadata, design = ~ condition) dds <- DESeq(dds) res <- results(dds, contrast = c("condition", "B", "A")) res_shrunk <- lfcShrink(dds, coef = "condition_B_vs_A", type = "apeglm")
2The MA plot (the health check)
DESeq2 has this one built in. Use the shrunken results so low-count noise doesn't spray dots all over the edges.
plotMA(res_shrunk, ylim = c(-3, 3), main = "MA plot (shrunken)")
How to read it: each dot is a gene. The horizontal axis is the average expression level (low on the left, high on the right). The vertical axis is the log2 fold change. Dots far from the centre line changed a lot; DESeq2 colours the significant ones. A healthy MA plot is a fat cloud hugging the zero line, fanning out a little on the low-expression left side (where there's naturally more noise). If it looks lopsided or tilted, something may be off with normalization.
Decode the jargonWhy "MA"?
The name comes from the early microarray days. M is the log ratio (the fold change, the vertical axis) and A is the average intensity (the expression level, the horizontal axis). The name stuck even though we use it for sequencing now.
3The volcano plot (the hit finder)
There's no single built-in function, so we draw it ourselves. The recipe: fold change on the x-axis, and -log10(padj) on the y-axis so that the most significant genes sit highest.
df <- as.data.frame(res) df <- df[!is.na(df$padj), ] # a gene is a "hit" if it's significant AND changed at least two-fold df$sig <- df$padj < 0.05 & abs(df$log2FoldChange) > 1 plot(df$log2FoldChange, -log10(df$padj), pch = 20, col = ifelse(df$sig, "#a855f7", "#cbd5e1"), xlab = "log2 fold change (B vs A)", ylab = "-log10 adjusted p-value", main = "Volcano plot") # threshold guides: significance line and two-fold lines abline(h = -log10(0.05), lty = 2, col = "#64748b") abline(v = c(-1, 1), lty = 2, col = "#64748b") # label the five most significant genes top <- head(df[order(df$padj), ], 5) text(top$log2FoldChange, -log10(top$padj), labels = rownames(top), pos = 3, cex = 0.7)
4How to read a volcano plot
Picture the plot as four regions:
- Top-right: strongly up in condition B, and highly significant. Your most interesting "up" genes.
- Top-left: strongly down in B, highly significant. Your most interesting "down" genes.
- Bottom middle: little change and low confidence. The quiet majority of genes.
- Far sides but low down: big fold change but weak significance. Treat these with caution, they're often low-count genes (this is exactly what shrinkage helps with).
The dashed lines are your thresholds: above the horizontal line means padj < 0.05, and outside the vertical lines means at least a two-fold change. The coloured dots in the upper corners are the genes you'd carry forward into the next step, functional enrichment.
🔶 Level up: publication-ready volcanoes
For a polished figure with automatic gene labels and styling, the EnhancedVolcano Bioconductor package is the community favourite: BiocManager::install("EnhancedVolcano"), then one call draws a paper-ready plot. The base-R version above is perfect for understanding what's going on under the hood first.
Check your understanding
Sources & further reading
- Love MI, Huber W, Anders S. Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biology 15:550, 2014. doi:10.1186/s13059-014-0550-8
- Zhu A, Ibrahim JG, Love MI. Heavy-tailed prior distributions for sequence count data: removing the noise and preserving large differences (apeglm LFC shrinkage). Bioinformatics 35(12):2084–2092, 2019.
- Conesa A, et al. A survey of best practices for RNA-seq data analysis. Genome Biology 17:13, 2016.
Last reviewed: June 2026.