Clustering Heatmaps in R: heatmap, pheatmap & More
Cluster the rows and columns of a data matrix at once and read the blocks of high and low values — from base heatmap() to pretty, annotated pheatmap() and complex genomic heatmaps
Machine Learning
Draw a clustering heatmap in R, step by step. Start with the base heatmap() function, then make it pretty and annotated with pheatmap() (the signature figure), add row/column annotations and dendrogram colors, and scale up to ComplexHeatmap and interactive heatmaply for gene-expression-style matrices. A heatmap clusters samples and features simultaneously, so blocks of similar values land next to each other.
Published
June 24, 2026
Modified
July 7, 2026
TipKey takeaways
A heatmap turns a numeric matrix into a grid of colored cells — and clusters the rows (samples) and the columns (variables) at the same time, so similar observations and similar variables end up next to each other and blocks of high and low values jump out.
Always scale first. A heatmap colors raw magnitudes, so one large-scale variable will wash out the rest. Standardize the matrix with scale() (or let the function do it with scale = "row").
Base heatmap() ships with R and needs nothing extra; pheatmap() is the everyday workhorse — prettier, with row/column annotations and cutree_rows to box the clusters.
For genomics-scale work, ComplexHeatmap arranges and annotates multiple heatmaps side by side, and heatmaply makes the heatmap interactive (hover for values, zoom).
The clustering is just hierarchical clustering under the hood — the same distance and linkage choices you make for a dendrogram drive the row and column ordering here.
You have run a hierarchical clustering and drawn the dendrogram — but a tree only shows you how the samples group, not why. A heatmap answers the why: it colors every value in the matrix and reorders the rows and columns by clustering, so you can read off which variables are high or low in each cluster of samples. It’s the standard figure for gene-expression studies (genes × samples), but it works for any tidy numeric matrix — cars × specs, customers × metrics, regions × indicators.
The mechanics are simple. Hierarchical clustering is run on the rows and on the columns; both are reordered to put similar things next to each other; a color scale maps each value; and the matrix is drawn. The payoff is that blocks of similarly-colored cells become visible — and those blocks are exactly the variables that characterize each sample cluster.
This lesson walks the practical ladder: base heatmap() (always available), then the pretty, annotated pheatmap() (the figure you’ll actually publish), the enhanced heatmap.2(), and finally the heavy-duty ComplexHeatmap and interactive options for gene-expression-style data. Each block is explained before it runs, every example is self-contained, and we close with which tool to reach for when.
The data
We use the built-in mtcars dataset (32 cars × 11 numeric specs) as a demo matrix. The single most important step is to standardize it: a heatmap colors raw values, and mtcars mixes tiny numbers (wt, in thousands of pounds) with large ones (hp, horsepower). Without scaling, hp and disp would dominate the color scale and the rest of the matrix would look flat. scale() centers each column to mean 0 and scales it to unit standard deviation:
Every value is now a z-score — how many standard deviations a car is above (positive) or below (negative) the average for that spec. That’s what the colors will encode.
Standardize or normalize? Three ways to put variables on one scale
scale() gives you the z-score standardization above, and it is the right default for a heatmap. Two other rescalings show up often, and each answers a different question — pick by what you want the colors to mean.
Min–max normalization squeezes every column into the 0–1 range with (x - min) / (max - min). Reach for it when you want the colors to read as “low to high within the observed range” and the spread doesn’t matter — handy for variables with hard floors and ceilings (rates, proportions, scores out of 100):
Each column now runs from 0 (the lowest car) to 1 (the highest). Unlike z-scores, outliers are not flagged — they just sit at the 0 or 1 edge — so min–max is more forgiving of skew but hides how extreme a value is.
Percentile (rank) normalization replaces each value with its rank position on a 0–1 scale, the most outlier-proof of the three: a single huge value can’t stretch the scale because ranks are evenly spaced. Use it for heavily skewed data where you only care about order, not distance:
Which to reach for in a heatmap:z-score (scale()) is the default — it centers each variable and shows how unusual a value is, which is exactly what makes blocks pop. Switch to min–max when you need a literal 0–1 color scale, and to percentile when a few extreme values would otherwise dominate every color. All three put the variables on a comparable footing; they differ only in what “high” and “low” mean.
R packages for drawing heatmaps
Several R packages draw heatmaps, from one-liners to publication-grade genomic figures:
heatmap() — base R (stats package). A simple static heatmap, no install needed.
heatmap.2() — the gplots package. An enhanced version of the base function (color key, more control).
pheatmap() — the pheatmap package. “Pretty heatmaps” with easy annotations and clean defaults.
heatmaply() — the heatmaply package. An interactive heatmap (hover for values, zoom).
Heatmap() — the ComplexHeatmap Bioconductor package. Draws, annotates and arranges multiple complex heatmaps — the standard for genomic data.
We’ll work up that ladder. Start with base heatmap(), settle on pheatmap() for everyday work, and reach for ComplexHeatmap only when you need to stack several annotated matrices.
Base R heatmap: heatmap()
The built-in heatmap() function (in the stats package) needs no installation. Pass it a numeric matrix; it clusters and reorders both rows and columns and draws the result. Because we already standardized df, we set scale = "none" to keep our own z-scores (the default is scale = "row"):
heatmap(df, scale ="none")
High values are drawn in red and low values in yellow, with the row dendrogram (cars) on the left and the column dendrogram (specs) on top. The blocks are already visible: the high-mpg, low-weight economy cars cluster apart from the heavy, high-hp muscle cars.
You can swap the color scale with the col argument and annotate rows and columns with colored side bars. colorRampPalette() builds a smooth gradient — here a diverging red–yellow–blue from RColorBrewer — and RowSideColors / ColSideColors tag each row and column with a class color:
library(RColorBrewer)col <-colorRampPalette(brewer.pal(10, "RdYlBu"))(256)heatmap( df, scale ="none", col = col,RowSideColors =rep(c("blue", "pink"), each =16),ColSideColors =c(rep("purple", 5), rep("orange", 6)))
The side-color vectors must be as long as the number of rows (32 cars) and columns (11 specs) respectively — here a quick two-block split just to show the mechanism. In practice you’d pass a real grouping variable.
Enhanced heatmaps: heatmap.2()
The heatmap.2() function (in the gplots package) extends the base function with a color key, trace control, and many extra arguments. The bluered() helper builds a smooth blue–white–red diverging scale; we turn off the per-cell trace lines and the density inset for a clean figure:
library(gplots)heatmap.2( df, scale ="none", col =bluered(100),trace ="none", density.info ="none")
Useful extras: labRow / labCol to relabel, and a custom hclustfun to change the linkage — e.g. hclustfun = function(x) hclust(x, method = "ward.D2"). For color generators, gplots also offers colorpanel(n, low, mid, high), redgreen(), greenred(), bluered() and redblue().
Pretty heatmaps: pheatmap()
pheatmap() (the “pretty heatmap” package) is the one you’ll reach for most. It has clean defaults, draws crisp dendrograms, and — the key feature — cuts the row dendrogram into groups with cutree_rows, drawing a gap between each cluster so the blocks are unmistakable. This is the signature figure:
library(pheatmap)pheatmap(df, cutree_rows =4)
The cutree_rows = 4 argument cuts the car dendrogram into four clusters and inserts a gap between them — you can immediately read the four groups of cars and the specs that define each. By default pheatmap() clusters with the euclidean distance and complete linkage; change them with clustering_distance_rows and clustering_method (see the distance measures lesson for the trade-offs).
pheatmap() also annotates rows and columns directly from a data frame. Here we tag each car with its number of cylinders (cyl) and transmission (am) as a colored side bar — the everyday way to overlay known groups on the clustering:
library(pheatmap)# Build an annotation data frame (row names must match df's rows)annotation_row <-data.frame(cyl =factor(mtcars$cyl),am =factor(mtcars$am, labels =c("auto", "manual")))rownames(annotation_row) <-rownames(mtcars)pheatmap( df, cutree_rows =4,annotation_row = annotation_row,main ="mtcars clustering heatmap")
The annotation bars confirm what the clustering found: the cylinder count tracks the car clusters almost perfectly, which tells you cyl is one of the variables that defines the groups.
Interactive heatmaps
A static heatmap is great for print, but on screen you often want to hover for the exact value or zoom into a region. The heatmaply package turns any heatmap interactive (it renders through plotly). The code is a one-liner — pass the scaled matrix and, optionally, the number of row and column groups:
library(heatmaply)heatmaply(scale(mtcars),k_row =4, # number of row groups to colork_col =2, # number of column groups to colorcolors ="RdYlBu")
With the interactive version you can put the mouse over any cell to read its row name, column name and value, and drag to select a region to zoom into. An older alternative, d3heatmap() (d3heatmap package), did the same thing but is no longer actively maintained — prefer heatmaply:
# Legacy alternative (package no longer maintained)library(d3heatmap)d3heatmap(scale(mtcars), colors ="RdYlBu", k_row =4, k_col =2)
Visualize missing values
A heatmap is also a fast way to see where your data is missing before you model it. Map every cell to “present” or “missing” and the gaps — a column that’s mostly empty, missing runs in time order — jump out at a glance. We use the built-in airquality dataset, which carries real NAs in its Ozone and Solar.R columns:
na_matrix <-is.na(airquality) # TRUE where a value is missingheatmap(matrix(as.integer(na_matrix), nrow =nrow(na_matrix),dimnames =list(NULL, colnames(airquality))),Rowv =NA, Colv =NA, scale ="none", # no clustering — keep the original ordercol =c("grey90", "#3a86d4"), # present = grey, missing = azuremargins =c(7, 2), labRow =NA,main ="Missing values in airquality")
Azure cells are missing. Reading down the columns, Ozone carries most of the gaps — exactly what you want to know before you impute or drop rows. For an interactive version that highlights the NAs and lets you hover over each gap, heatmaply::heatmaply_na(airquality) does the same in a single call.
Complex heatmaps for genomic data
When you need to arrange and annotate multiple heatmaps — the classic gene-expression layout where an expression matrix sits beside annotation tracks for gene length, type and chromosome — reach for ComplexHeatmap (a Bioconductor package by Zuguang Gu). Install it once with BiocManager:
if (!requireNamespace("BiocManager", quietly =TRUE)) install.packages("BiocManager")BiocManager::install("ComplexHeatmap")
The core function is Heatmap(). A simple call names the legend and the row/column titles. We guard the example with requireNamespace() so the page renders even where the Bioconductor package is absent:
if (requireNamespace("ComplexHeatmap", quietly =TRUE)) {library(ComplexHeatmap) ComplexHeatmap::Heatmap( df,name ="mtcars", # title of the color legendcolumn_title ="Variables", row_title ="Samples",row_names_gp = grid::gpar(fontsize =7) # text size for row names )} else {cat("ComplexHeatmap is not installed in this render environment.")}
Useful arguments: show_row_names / show_column_names, the clustering controls clustering_distance_rows / clustering_method_rows (same metrics and linkages as hclust), and col for a custom color mapping built with circlize::colorRamp2().
Splitting the heatmap by a grouping variable
ComplexHeatmap can split the rows into blocks — either by k-means (row_km = k, set a seed first for reproducibility) or by a grouping variable. Here we split the cars by their number of cylinders:
library(ComplexHeatmap)# Split rows by a grouping variable (number of cylinders)Heatmap( df, name ="mtcars",row_split = mtcars$cyl,row_names_gp = grid::gpar(fontsize =7))# Or split by k-means into 2 groups (set a seed so it reproduces)set.seed(2)Heatmap(df, name ="mtcars", row_km =2)
Annotating columns
The HeatmapAnnotation() class attaches annotation bars to the top (or side) of the heatmap. After transposing the matrix so samples are in columns, we annotate with two qualitative variables (cyl, am) and one continuous variable (mpg), each with its own color mapping:
library(ComplexHeatmap)mat <-t(df) # variables in rows, cars in columnscol <-list(cyl =c("4"="green", "6"="gray", "8"="darkred"),am =c("0"="yellow", "1"="orange"),mpg = circlize::colorRamp2(c(17, 25), c("lightblue", "purple")))ha <-HeatmapAnnotation(cyl =factor(mtcars$cyl), am =factor(mtcars$am), mpg = mtcars$mpg,col = col)Heatmap(mat, name ="mtcars", top_annotation = ha)
Application to a gene-expression matrix
In gene-expression data, rows are genes and columns are samples, and you usually want extra tracks beside the expression heatmap — gene length, gene type, chromosome. ComplexHeatmap lays these out by adding heatmaps together with +. This is the figure the package was built for:
library(ComplexHeatmap)# Example expression data shipped with ComplexHeatmapexpr <-readRDS(system.file("extdata", "gene_expression.rds", package ="ComplexHeatmap"))mat <-as.matrix(expr[, grep("cell", colnames(expr))])type <-gsub("s\\d+_", "", colnames(mat))ha <-HeatmapAnnotation(df =data.frame(type = type))Heatmap(mat, name ="expression", km =5, top_annotation = ha,show_row_names =FALSE, show_column_names =FALSE) +Heatmap(expr$length, name ="length", width =unit(5, "mm"),col = circlize::colorRamp2(c(0, 100000), c("white", "orange"))) +Heatmap(expr$type, name ="type", width =unit(5, "mm")) +Heatmap(expr$chr, name ="chr", width =unit(5, "mm"),col = circlize::rand_color(length(unique(expr$chr))))
Each + adds an aligned heatmap: the first is the main expression matrix (split into 5 k-means clusters of genes), and the thin ones to its right carry the per-gene metadata. The genes stay row-aligned across all of them, so you can read length, type and chromosome for every cluster at a glance.
Which tool when
Your situation
Reach for
Quick look, nothing installed
base heatmap()
A clean, annotated figure for a report or paper
pheatmap() (with cutree_rows, annotation_row)
You want a color key and gplots extras
heatmap.2()
Hover for values / zoom on screen
heatmaply()
Several annotated matrices, genomic data
ComplexHeatmap
For everyday clustering work, pheatmap() is the sweet spot — pretty, annotated, and a one-liner.
Try it live
Draw your own pretty heatmap. Change cutree_rows to cut the cars into a different number of clusters, or swap scale(mtcars) for scale(USArrests) to cluster US states instead. Click Run.
🟢 With an AI agent
Ask Prova“make a clustering heatmap of my own data frame in R and annotate it by group” — it answers with pheatmap() (or ComplexHeatmap) code you can run on your own matrix, then helps you scale it, choose the clustering distance, and read the blocks. The runtime is the judge.Ask Prova →
Common issues
My heatmap is one color and shows nothing. You forgot to scale. A heatmap colors raw magnitudes, so a single large-scale column (like hp or a gene with huge counts) flattens everything else. Standardize first with df <- scale(your_matrix) and pass scale = "none", or let the function scale per row with scale = "row".
The rows and columns are clustered the way I don’t want. The ordering comes from the distance and linkage used under the hood. In pheatmap() set clustering_distance_rows = "correlation" (or "manhattan", …) and clustering_method = "ward.D2". In base heatmap() pass a custom hclustfun. See distance measures for which to choose.
There are too many row labels and they overlap. With many rows the labels become an unreadable smear. Turn them off (pheatmap(df, show_rownames = FALSE), or Heatmap(df, show_row_names = FALSE)), or shrink them (fontsize_row = 6). For very tall matrices, hide labels and rely on the dendrogram and annotations instead.
Frequently asked questions
NoteHow do I make a heatmap in R?
Pass a numeric matrix to a heatmap function. The base one-liner is heatmap(scale(mydata)); for a prettier, annotated version use pheatmap(scale(mydata), cutree_rows = 4). Always scale() the matrix first so one large-magnitude variable doesn’t dominate the colors. Both functions cluster and reorder the rows and columns automatically.
NoteWhat is the difference between pheatmap and heatmap?
Base heatmap() ships with R and needs no install, but its styling is plain and annotating it is fiddly (you build color side-bars by hand). pheatmap() (“pretty heatmap”) gives cleaner defaults, draws nicer dendrograms, cuts the tree into groups with cutree_rows, and annotates rows and columns directly from a data frame with annotation_row / annotation_col. For most work, prefer pheatmap().
NoteHow does a heatmap cluster by rows and columns?
It runs hierarchical clustering twice — once on the rows and once on the columns — then reorders both so similar rows and similar columns sit next to each other. That’s why blocks of similar color appear: the clustering puts like-with-like. You control it with the distance (euclidean, correlation, …) and the linkage method (complete, average, Ward), exactly as in a dendrogram. To cluster on one axis only, set cluster_cols = FALSE (or cluster_rows = FALSE).
NoteHow do I make a gene expression heatmap in R?
Put genes in rows and samples in columns, scale per row so each gene is comparable, and use pheatmap() for a single annotated matrix or ComplexHeatmap when you need several aligned tracks (expression + gene length + type + chromosome). ComplexHeatmap::Heatmap() lets you stack heatmaps with + and split genes into clusters with row_km or row_split. Install it from Bioconductor with BiocManager::install("ComplexHeatmap").
NoteHow do I make an interactive heatmap in R?
Use heatmaply() from the heatmaply package: heatmaply(scale(mydata), k_row = 4, k_col = 2). It renders through plotly, so you can hover over any cell to read its value and zoom into regions. It accepts the same clustering arguments as a static heatmap.
Test your understanding
ImportantExercise: cut a heatmap into 3 clusters
Standardize the USArrests data and draw a pheatmap that cuts the rows (US states) into 3 clusters.
# scale(USArrests) gives the matrix; cutree_rows = 3 cuts the row tree into 3 groups
Conceptual. Two cars sit in adjacent rows of the heatmap and both rows are mostly the same shade of red. What does that tell you? (Answer: they are similar — adjacent rows mean the row clustering grouped them, and the matching colors mean they have similar values across the variables. The row dendrogram on the side confirms how closely they fuse.)
Conceptual. You draw a heatmap of raw (unscaled) gene counts and the whole figure is a single block of one color except for two bright rows. What went wrong? (Answer: you didn’t scale. Two high-expression genes are dominating the color range and flattening everything else. Scale per row — e.g. scale = "row" or t(scale(t(mat))) — so each gene is on its own z-scale.)
Conclusion
A heatmap is the fastest way to see the result of clustering a data matrix: it runs hierarchical clustering on the rows and columns, reorders both, and colors the values so blocks of high and low stand out. Start by scaling the matrix, draw a quick base heatmap() to check, then move to pheatmap() for a clean, annotated figure with cutree_rows to box the clusters. Reach for heatmaply when you want to hover and zoom, and for ComplexHeatmap when you need to stack several annotated matrices — the standard for gene-expression work. The clustering itself is the same machinery as a dendrogram, so the distance and linkage choices carry straight over.
Related lessons
Build on this:hierarchical clustering — the tree behind the heatmap’s row and column ordering, and how to read it; visualizing dendrograms — color, zoom and prettier trees with dendextend; distance measures — the similarity rule that decides which rows and columns sit together; k-means — the partitioning alternative when you already know how many groups you want; optimal number of clusters — how many groups to cut into.