Before you start
- You can read a little R - variables, functions, loading packages. (New to this? Start with the R series or the Introduction.)
- A free Google account, so you can run the code in Google Colab.
- Curiosity. You do not need to understand the math behind the statistics yet - we'll point at it and keep moving.
Learning objectives
By the end of this lesson you will be able to: load a gene-count matrix, run a differential-expression analysis with DESeq2, and read which genes changed, in which direction, and how confidently.
The question we're actually answering
Before any code, let's be clear on what RNA-seq is for. Every cell carries the same DNA, but at any moment it's only reading some of those genes. The genes a cell is actively "reading" get transcribed into RNA. So if you measure how much RNA there is for each gene, you get a snapshot of what the cell is actually doing.
RNA-seq is the technology that counts that RNA, gene by gene, across thousands of genes at once. The classic experiment compares two groups - say, untreated cells vs. cells given a drug - and asks a deceptively simple question:
"Which genes change their activity between the two conditions?"
That's differential expression analysis, and it's the workhorse of modern molecular biology - used to understand diseases, find drug targets, and figure out how cells respond to their environment. That's exactly what you're about to do.
Decode the jargonCount matrix
The starting point of most RNA-seq analyses is a big table: one row per gene, one column per sample, and each cell holds a whole number - how many RNA fragments ("reads") were counted for that gene in that sample. Higher number = the gene was more active. Getting from raw sequencer output to this table (alignment and counting) is its own topic; here we start from the count matrix, which is where most analyses really begin.
The plan
Five steps. Each one is a few lines of code, and we'll explain what every line is doing and - more importantly - why.
- Set up - install one package.
- Load the data - a count matrix and a table describing the samples.
- Run the analysis - let DESeq2 do the heavy statistical lifting.
- Read the results - find the genes that changed.
- Visualize - draw a volcano plot, the signature figure of RNA-seq.
โถ Code along in Google Colab
Open a free notebook, set the runtime to R, and run each block below. Nothing to install on your computer. The one-time DESeq2 install takes about 3 to 5 minutes, which is normal, so let it finish before moving on.
1Set up
We'll use DESeq2, the standard R / Bioconductor package for differential expression - the original method the whole field is built on. Make sure your Colab notebook is set to the R runtime (Runtime โ Change runtime type โ R), then install it once:
# Installing Bioconductor packages takes a few minutes the first time - grab a coffee. if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager") BiocManager::install("DESeq2", update = FALSE, ask = FALSE)
Then load the library:
library(DESeq2)
2Load the data
We'll use a compact, ready-made example count matrix so everything runs in seconds and you can focus on the workflow. (When you're ready for a real published dataset, see the "Level up" box near the end - the steps are identical.)
Two files: a count matrix (genes ร samples) and a metadata table that says which condition each sample belongs to.
base <- "https://raw.githubusercontent.com/owkin/PyDESeq2/main/datasets/synthetic/" # Count matrix: rows = genes, columns = samples - exactly what DESeq2 wants. counts <- as.matrix(read.csv(paste0(base, "test_counts.csv"), row.names = 1)) # Metadata: one row per sample, with a "condition" column (values A and B). metadata <- read.csv(paste0(base, "test_metadata.csv"), row.names = 1) metadata$condition <- factor(metadata$condition) # Keep the metadata rows in the same order as the count columns. metadata <- metadata[colnames(counts), , drop = FALSE] dim(counts) # genes x samples head(counts)
Take a moment to actually look at head(counts) and head(metadata). Getting comfortable looking at your data before analyzing it is one of the most underrated habits in bioinformatics - most real-world mistakes are caught just by eyeballing the table.
Decode the jargonNormalization
You can't compare raw counts directly between samples, because some samples are sequenced more deeply than others (more total reads), making every gene look "higher" just by accident. DESeq2 normalizes to correct for this - adjusting so that differences reflect real biology, not how much data each sample happened to get. The good news: it does this for you automatically in the next step.
3Run the analysis
This is where DESeq2 earns its reputation. In two lines it normalizes the data, models the counts statistically, and prepares everything needed to test each gene.
# Build the DESeq2 dataset. design = ~condition is the question we're asking. dds <- DESeqDataSetFromMatrix(countData = counts, colData = metadata, design = ~ condition) dds <- DESeq(dds) # fit the model - this does the real work
The design = ~ condition part is you telling DESeq2: "the thing I care about is the difference between conditions A and B." That single choice frames the entire analysis.
4Read the results
Now we ask, for every gene: is the difference between A and B big enough - and consistent enough - to be real?
# contrast = compare condition B vs A res <- results(dds, contrast = c("condition", "B", "A")) res <- res[order(res$padj), ] # sort by adjusted p-value head(res, 10) # the 10 most convincing hits
The results table has a few columns that matter enormously:
- log2FoldChange - how much the gene changed, on a log2 scale. +1 means it doubled in B vs A; โ1 means it halved. Sign tells you direction, size tells you magnitude.
- pvalue - the raw probability that you'd see a change this big by pure chance.
- padj - the p-value adjusted for the fact that we tested thousands of genes at once. This is the one you trust, not the raw p-value.
Decode the jargonWhy padj, not pvalue?
If you test 10,000 genes and call anything with p < 0.05 a "hit," you'll get ~500 false positives by chance alone - before any real biology. Adjusting for multiple testing (here, the Benjamini-Hochberg method) controls that flood of false alarms. Rule of thumb: a gene is a credible hit if padj < 0.05. We'll devote a whole lesson to this idea in Track 5 - it's that important.
5Visualize: the volcano plot
The volcano plot is the iconic RNA-seq figure. Each dot is a gene. The x-axis is how much it changed (fold change); the y-axis is how confident we are (significance). The genes that matter - big change and high confidence - fly up into the top corners, which is where the name comes from.
# Turn the results into a plain data frame and drop genes with no p-value df <- as.data.frame(res) df <- df[!is.na(df$padj), ] df$sig <- df$padj < 0.05 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 - your first one!") abline(h = -log10(0.05), lty = 2, col = "#64748b") # the padj < 0.05 line
That's it. You just took a count matrix, ran a real statistical workflow, identified the genes that change between conditions, and made the figure you'll see in thousands of published papers. The exact same five steps scale up to a real experiment with 20,000 genes.
๐ถ Level up: run it on a real published dataset
Ready to use real biology? A great next dataset is the airway experiment (Himes et al., 2014, GEO accession GSE52778): human airway smooth-muscle cells treated with dexamethasone, a steroid used in asthma. It's the classic teaching dataset for exactly this workflow.
The only thing that changes is where the count matrix comes from - load airway counts and metadata instead of the example files, set the design to ~ cell + dex (the treatment column in the airway data is named dex), and every other step is identical. That repeatability is the whole point. (Handy bonus: in R, the airway Bioconductor package ships this exact dataset ready to load.)
Prefer Python? The same analysis with PyDESeq2
DESeq2 lives in R, but PyDESeq2 is a faithful Python re-implementation that gives essentially the same results - handy if you work mostly in Python (e.g. alongside scanpy for single-cell). Here's the identical five-step workflow in Python:
# install once !pip install pydeseq2 import pandas as pd from pydeseq2.dds import DeseqDataSet from pydeseq2.ds import DeseqStats base = "https://raw.githubusercontent.com/owkin/PyDESeq2/main/datasets/synthetic/" counts = pd.read_csv(base + "test_counts.csv", index_col=0).T # samples x genes for PyDESeq2 metadata = pd.read_csv(base + "test_metadata.csv", index_col=0) dds = DeseqDataSet(counts=counts, metadata=metadata, design="~condition") dds.deseq2() stat_res = DeseqStats(dds, contrast=["condition", "B", "A"]) stat_res.summary() res = stat_res.results_df.sort_values("padj") res.head(10)
The columns (log2FoldChange, pvalue, padj) and how you read them are exactly the same as the R version above.
Now do it on real data: the airway dataset
The example matrix above kept things fast so you could see the workflow. Here is the same analysis on a real published experiment: the airway study (Himes et al., 2014, GEO accession GSE52778), human airway smooth-muscle cells treated with dexamethasone, a steroid used in asthma. In R, the Bioconductor airway package ships this exact dataset ready to load, so it is fully reproducible. Run this in Colab with an R runtime, or in RStudio.
# install once (a few minutes the first time) if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager") BiocManager::install(c("airway", "DESeq2")) library(airway); library(DESeq2) data("airway") # a real RNA-seq dataset, ready to use airway$dex <- relevel(airway$dex, "untrt") # make "untreated" the baseline # note the design: cell line is a batch term, treatment is what we test dds <- DESeqDataSet(airway, design = ~ cell + dex) dds <- DESeq(dds) res <- results(dds) summary(res) # how many genes up/down at padj < 0.1 head(res[order(res$padj), ]) # the strongest hits
What real data adds (that the example hid)
This matrix has roughly 64,000 genes, most of them barely expressed, so filtering and the negative-binomial model earn their keep. The design is ~ cell + dex, not just ~ dex: real experiments have structure (here, four cell lines), and ignoring it would bury the signal. The adjusted p-value does real work, thousands of genes cross raw p < 0.05 but far fewer survive FDR. And you can sanity-check the biology: top hits like DUSP1, KLF15, and PER1 are known dexamethasone-responsive genes, so a correct pipeline recovers known biology.
The statistics behind that padj column
Why a raw p-value of 0.05 is not enough when you test thousands of genes at once, and what the adjustment actually does, is the subject of Statistics essentials and The multiple-testing problem. You do not need them to finish here, but they are the "why" behind trusting padj over the raw p-value.
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
- Conesa A, et al. A survey of best practices for RNA-seq data analysis. Genome Biology 17:13, 2016. doi:10.1186/s13059-016-0881-8
- Stark R, Grzelak M, Hadfield J. RNA sequencing: the teenage years. Nature Reviews Genetics 20:631โ656, 2019. doi:10.1038/s41576-019-0150-2
Last reviewed: June 2026.