Functional Enrichment: GO, KEGG, and GSEA

You have found the genes that change. Now for the real question: so what biology is actually happening? Enrichment analysis takes your gene list and tells you which biological functions and pathways are involved. This is where a list of genes becomes a story.

🟡 Intermediate ⏱️ ~1.5 hours 📊 R 🌐 Runs free in Colab

Before you start

Learning objectives

By the end of this lesson you will be able to: run GO and KEGG over-representation analysis and GSEA in R, explain how each method differs, and turn a list of differentially expressed genes into a readable story about biological function.

From a gene list to biology

Differential expression often hands you hundreds of changed genes. Reading them one by one is hopeless, and your eye will only recognise the few you already know. Functional enrichment solves this by grouping genes by what they do, then asking a statistical question: is any biological function or pathway showing up in your list far more than you would expect by chance? If "immune response" genes are all over your hits, that is a strong clue about what your experiment did.

Two ways to ask the question

There are two common approaches, and they suit different situations.

  • Over-representation analysis (ORA). You take your list of significant genes (your hits) and ask: is this list unusually full of genes from a particular function or pathway, compared to all genes? It is simple and intuitive, but it throws away everything below your significance cutoff.
  • Gene Set Enrichment Analysis (GSEA). You keep all genes, ranked from most up to most down, and ask: does any group of related genes sit together near the top or the bottom of the ranking? It catches coordinated, subtle shifts that ORA can miss.

Decode the jargonGene set

A named group of genes that share something, for example "all genes involved in DNA repair" or "the genes in the insulin signalling pathway". Enrichment is really just testing your data against thousands of these predefined gene sets to see which ones light up.

▶ Code along in Google Colab

Set the runtime to R (Runtime, then Change runtime type, then R). Heads up: this lesson installs clusterProfiler plus a human annotation database, the heaviest install in the course, so allow about 10 to 15 minutes the first time. That is expected, just let it run, it only happens once per session.

Open the R notebook in Colab →

1Set up and get a ranked gene list

Enrichment needs real gene IDs (so the tools can look up what each gene does), which our small synthetic dataset does not have. So we'll use a ready-made example list of real human genes that ships with the analysis package. It is a named vector: each gene's ID paired with its fold change, sorted from most up to most down. That is exactly the shape your own DESeq2 results take once you map them (we show how at the end).

# clusterProfiler is the standard R toolkit for enrichment
if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager")
BiocManager::install(c("clusterProfiler", "org.Hs.eg.db", "DOSE", "enrichplot"), update = FALSE, ask = FALSE)
library(clusterProfiler); library(org.Hs.eg.db); library(enrichplot)

# a ranked list of real human genes (named vector: gene ID -> fold change)
data(geneList, package = "DOSE")
head(geneList)

The names are Entrez IDs, a standard numeric code for each gene. org.Hs.eg.db is the human annotation database that lets the tools translate those IDs into functions and pathways. (The "Hs" means Homo sapiens; there are equivalent packages for mouse, and other species.)

2Over-representation: GO terms

First the simple approach. We take the clearly significant genes and ask which Gene Ontology terms are over-represented.

# treat genes with a large fold change as our "hits"
sig_genes <- names(geneList)[abs(geneList) > 2]

ego <- enrichGO(gene          = sig_genes,
                OrgDb         = org.Hs.eg.db,
                ont           = "BP",        # Biological Process
                pAdjustMethod = "BH",
                pvalueCutoff  = 0.05,
                readable      = TRUE)       # show gene symbols, not IDs

head(ego)
dotplot(ego, showCategory = 12) + ggtitle("Over-represented biological processes")

The dot plot is the payoff: each row is a biological process, the dot size is how many of your genes fall in it, and the colour is the adjusted p-value. Processes near the top are the ones your changed genes are concentrated in.

Decode the jargonGO and KEGG

GO (Gene Ontology) is a standardized dictionary of what genes do, split into biological processes ("BP"), molecular functions, and cellular locations. KEGG is a database of pathways, the wiring diagrams of interacting genes (for example, "cell cycle" or "insulin signalling"). Swap enrichGO for enrichKEGG to test pathways instead of GO terms.

3GSEA: the whole ranked list

Now the more powerful approach. GSEA uses every gene, in ranked order, and finds gene sets that trend up or down as a group, even if no single gene was a dramatic hit.

gse <- gseGO(geneList     = geneList,    # the full ranked list
             OrgDb        = org.Hs.eg.db,
             ont          = "BP",
             pvalueCutoff = 0.05,
             verbose      = FALSE)

head(gse[, c("Description", "NES", "p.adjust")])

# the classic GSEA running-score plot for the top gene set
gseaplot2(gse, geneSetID = 1, title = gse$Description[1])

Decode the jargonNES (Normalized Enrichment Score)

GSEA's headline number. A positive NES means the gene set is shifted toward the top of your ranking (up in your condition); a negative NES means it is shifted toward the bottom (down). The bigger the absolute value, the stronger the coordinated shift. Pair it with p.adjust below 0.05 to decide what to trust.

4Reading the results

Whichever approach you use, the result is a table of biological terms with a few key columns:

  • Description: the name of the GO term or pathway.
  • p.adjust: the significance, corrected for testing thousands of gene sets. Trust terms below 0.05, just like padj for genes.
  • GeneRatio / setSize: how many of your genes are in that term, and how big the term is.
  • NES (GSEA only): the direction and strength of the shift.
  • core_enrichment (GSEA only): the specific genes driving the result, your shortlist for follow-up.

The goal is not a giant table. It is a sentence: "the genes that changed are enriched for X, which makes sense because our treatment does Y." That sentence is the biological result of the whole analysis.

Using your own DESeq2 results

To run this on your own data, you need two small steps: translate your gene symbols into Entrez IDs, and rank them. Here is the bridge from a DESeq2 results table:

# res is your DESeq2 results data frame, with gene symbols as row names
ids <- bitr(rownames(res), fromType = "SYMBOL",
            toType = "ENTREZID", OrgDb = org.Hs.eg.db)

# build a ranked named vector (rank by the test statistic, high to low)
ranks <- res$stat
names(ranks) <- rownames(res)
ranks <- ranks[ids$SYMBOL]
names(ranks) <- ids$ENTREZID
ranks <- sort(ranks, decreasing = TRUE)
# now feed 'ranks' into gseGO() exactly as above

🔶 Level up

Try enrichKEGG and gseKEGG for pathways, and the ReactomePA package for Reactome pathways. The enrichplot package has beautiful visuals (cnetplot, emapplot) that show how terms and genes connect. The free online clusterProfiler book by Guangchuang Yu is the definitive guide.

Check your understanding

What is the difference between over-representation and GSEA?
Over-representation uses only your significant gene list and asks if a function is over-represented in it. GSEA uses the whole ranked list and asks if a group of related genes clusters near the top or bottom. GSEA can catch coordinated, subtle changes that do not pass a per-gene cutoff.
Why do we need real gene IDs (like Entrez) for enrichment?
Because the tools look up what each gene does in annotation databases (GO, KEGG). Made-up names like "gene_1" are not in those databases, so nothing can be matched. Real IDs let the analysis connect your genes to known functions and pathways.
A GO term has a positive NES and p.adjust of 0.001. What does that mean?
The genes in that term are coordinately shifted toward the top of your ranking (up in your condition), and the result is highly significant after correcting for multiple testing. It is a credible, biologically meaningful hit worth reporting.

Sources & further reading

  1. Subramanian A, et al. Gene set enrichment analysis: a knowledge-based approach for interpreting genome-wide expression profiles. PNAS 102(43):15545–15550, 2005.
  2. Wu T, Hu E, Xu S, et al. clusterProfiler 4.0: A universal enrichment tool for interpreting omics data. The Innovation 2(3):100141, 2021. doi:10.1016/j.xinn.2021.100141
  3. Ashburner M, et al. Gene Ontology: tool for the unification of biology. Nature Genetics 25:25–29, 2000.
  4. Kanehisa M, Goto S. KEGG: Kyoto Encyclopedia of Genes and Genomes. Nucleic Acids Research 28:27–30, 2000.

Last reviewed: June 2026.

Why does an over-representation analysis need a background (universe) gene set?
The test asks whether a function appears in your hit list more than expected by chance. That expectation depends on the pool of genes that could have shown up, so the background should be the genes you actually measured.
In GSEA, what is the genes ranked by, and why does the order matter?
GSEA needs the whole list ordered by a meaningful metric, such as log2 fold change or a signed score. It then checks whether a gene set clusters near the top (up) or bottom (down) of that ranking.
Track 3

See the full transcriptomics roadmap →