Your First RNA-seq Analysis

By the end of this lesson you'll have run a real differential-expression analysis from start to finish - finding which genes change between two conditions - entirely in your browser, with no software to install.

๐ŸŸขโ†’๐ŸŸก Beginner-friendly โฑ๏ธ 2-3 hours ๐Ÿ“Š R ยท DESeq2 ๐ŸŒ Runs free in Colab

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.

  1. Set up - install one package.
  2. Load the data - a count matrix and a table describing the samples.
  3. Run the analysis - let DESeq2 do the heavy statistical lifting.
  4. Read the results - find the genes that changed.
  5. 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.

Open the R notebook in Colab โ†’

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.)

Find dataset links in Resources โ†’

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

Correct. On a log2 scale, โˆ’1 = halved and โˆ’2 = halved again, so one-quarter of the original level. The negative sign means it went down in B.
Right. Testing thousands of genes would flag hundreds as "significant" purely by chance. The adjusted p-value (padj) controls for that, so it is the number you trust.
Exactly. High on the y-axis (very significant) and far from zero on the x-axis (large change): big, confident changes in both directions.

Sources & further reading

  1. 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
  2. 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
  3. 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.

Correct. On a log2 scale, +1 means twice the level. The positive sign means the gene went up in B compared with A.
Right. A small padj says the difference is real, but a tiny fold change means it is small. Good hits usually need both significance (low padj) and a meaningful size of change.
Next in Track 3

From raw counts to differential expression: going deeper with DESeq2 โ†’