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.
Get the book — Practical Guide to Cluster Analysis in R (PDF)

Introduction

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:

df <- scale(mtcars)
head(round(df, 2)[, 1:6])
                    mpg   cyl  disp    hp  drat    wt
Mazda RX4          0.15 -0.10 -0.57 -0.54  0.57 -0.61
Mazda RX4 Wag      0.15 -0.10 -0.57 -0.54  0.57 -0.35
Datsun 710         0.45 -1.22 -0.99 -0.78  0.47 -0.92
Hornet 4 Drive     0.22 -0.10  0.22 -0.54 -0.97  0.00
Hornet Sportabout -0.23  1.01  1.04  0.41 -0.84  0.23
Valiant           -0.33 -0.10 -0.05 -0.61 -1.56  0.25

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

minmax <- function(x) (x - min(x)) / (max(x) - min(x))
df_minmax <- apply(mtcars, 2, minmax)
head(round(df_minmax, 2)[, 1:6])
                   mpg cyl disp   hp drat   wt
Mazda RX4         0.45 0.5 0.22 0.20 0.53 0.28
Mazda RX4 Wag     0.45 0.5 0.22 0.20 0.53 0.35
Datsun 710        0.53 0.0 0.09 0.14 0.50 0.21
Hornet 4 Drive    0.47 0.5 0.47 0.20 0.15 0.44
Hornet Sportabout 0.35 1.0 0.72 0.43 0.18 0.49
Valiant           0.33 0.5 0.38 0.19 0.00 0.50

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:

percentize <- function(x) (rank(x) - 1) / (length(x) - 1)
df_rank <- apply(mtcars, 2, percentize)
head(round(df_rank, 2)[, 1:6])
                   mpg  cyl disp   hp drat   wt
Mazda RX4         0.60 0.45 0.40 0.39 0.66 0.26
Mazda RX4 Wag     0.60 0.45 0.40 0.39 0.66 0.35
Datsun 710        0.76 0.16 0.16 0.19 0.61 0.19
Hornet 4 Drive    0.66 0.45 0.55 0.39 0.24 0.48
Hornet Sportabout 0.45 0.79 0.85 0.65 0.31 0.58
Valiant           0.42 0.45 0.52 0.29 0.02 0.65

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

A base R heatmap of the scaled mtcars data, with a row dendrogram of cars on the left and a column dendrogram of specs on top; high values are red, low values are yellow.

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 same base R heatmap recolored with a red-yellow-blue palette, with blue and pink row side colors marking two car groups and purple and orange column side colors marking two spec groups.

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

An enhanced gplots heatmap.2 of the scaled mtcars data with a blue-white-red color scale and a color key in the top-left corner.

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)

A pheatmap of the scaled mtcars data with the cars clustered into four groups separated by gaps on the left and the specs clustered along the top, using a blue-white-red color scale.

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

A pheatmap of the scaled mtcars data with two annotation bars on the left coloring each car by its number of cylinders and its transmission type.

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 color
  k_col = 2,        # number of column groups to color
  colors = "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 missing
heatmap(
  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 order
  col = c("grey90", "#3a86d4"),                # present = grey, missing = azure
  margins = c(7, 2), labRow = NA,
  main = "Missing values in airquality"
)

A missing-value heatmap of the airquality dataset in R: rows are observations, columns are variables, and each cell is shaded by whether its value is missing, with most of the highlighted (missing) cells falling in the Ozone column.

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 legend
    column_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.")
}

A ComplexHeatmap of the scaled mtcars data labeled with a Variables column title and a Samples row title, with row and column dendrograms.

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 columns

col <- 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 ComplexHeatmap
expr <- 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

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.

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

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

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

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

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
library(pheatmap) df <- scale(USArrests) pheatmap(df, cutree_rows = 3)
library(pheatmap)
df <- scale(USArrests)
pheatmap(df, cutree_rows = 3)
  1. 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.)

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

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Clustering {Heatmaps} in {R:} Heatmap, Pheatmap \& {More}},
  date = {2026-06-24},
  url = {https://www.datanovia.com/learn/machine-learning/clustering/heatmap},
  langid = {en}
}
For attribution, please cite this work as:
“Clustering Heatmaps in R: Heatmap, Pheatmap & More.” 2026. June 24. https://www.datanovia.com/learn/machine-learning/clustering/heatmap.