Chris Parmer

Chris Parmer

November 13, 2026

scanpy tutorial: Harmony, marker genes and UMAP, hands-on in Dash

TL;DR

  • scanpy is the Python toolkit for analyzing single-cell gene expression data, the measurements of which genes are switched on in each individual cell. Alex Wolf, Philipp Angerer and Fabian Theis's lab started it in 2017, and the scverse community maintains it today, with Philipp Angerer as lead developer.
  • Version 1.13 came out on November 13, 2026. Its headline features are Harmony batch integration built into scanpy, a new Wilcoxon method that finds marker genes about 27 times faster, and UMAP that runs in parallel.
  • There are three breaking changes, and many old parameters are deprecated ahead of scanpy 2.0, which you can preview with one setting.
  • With scanpy and Dash you can build apps like a cell atlas browser for a target discovery team, a clinical trial app that tracks immune cells before and after treatment, or a quality control monitor for a sequencing lab.
Bar chart of the time to find marker genes for 14 cell types in 239,696 cells with a Wilcoxon rank-sum test on four CPUs: 1,209 s with method wilcoxon on scanpy 1.12.5, 909 s with wilcoxon on scanpy 1.13.0 and 45 s with the new wilcoxon_illico method on scanpy 1.13.0, which returns the same genes and scores

What is scanpy?

Every cell in your body carries the same DNA, but each one switches on a different set of genes, and that's what makes a T cell different from a neuron. Single-cell RNA sequencing (scRNA-seq) counts the RNA molecules from each gene in thousands or millions of individual cells, so you get a big table with one row per cell and one column per gene. Drug companies use it to find which cells a disease changes, to check where a drug target is active, and to see how immune cells respond in clinical trial samples.

scanpy turns that table into answers. It's a Python package for every step from raw counts to figures, and it's built on AnnData, a file format and Python object that holds the counts together with everything you learn about each cell and gene. Its README says it "efficiently deals with datasets of more than one million cells". Here are three things you'd do with it, shown on a real public dataset of 239,696 blood cells from five COVID-19 studies. First, a look at that data.

The data: 239,696 blood cells from five COVID-19 studies

Where it comes from. CZ CELLxGENE Discover, run by the Chan Zuckerberg Initiative, is a free library of published single-cell datasets, all stored in the same format. We used its COVID-19 Immune Atlas, which a team put together for a 2021 iScience paper from five studies published in 2020 (in Nature Medicine, Science Immunology, Science, Cell and Nature Communications). Each lab took blood from healthy donors and COVID-19 patients, separated out the white blood cells (PBMCs) and sequenced them one cell at a time. Together that's 97 samples from 78 donors: 103,804 cells from healthy donors, 71,443 from severe COVID-19 and 64,449 from mild cases or recovery.

What format it's in. It's one 1.8 GB .h5ad file, which is an AnnData object saved to disk. Inside are four main parts:

  • X, the expression values the atlas authors processed, and raw.X, the original counts of RNA molecules per gene per cell
  • obs, a table with one row per cell: study, donor, disease, cell type, genes detected and more
  • var, a table with one row per gene, with Ensembl IDs (like ENSG00000118271) and gene names (like LYZ)
  • obsm, the authors' own PCA, t-SNE and UMAP coordinates

How to load it. Download the file and open it with anndata, which comes with scanpy. backed="r" leaves the big matrices on disk until you ask for them, so opening the file doesn't fill your memory:

# curl -O https://datasets.cellxgene.cziscience.com/904c928e-b671-41b2-9354-7256e7dd68fa.h5ad
import anndata as ad
adata = ad.read_h5ad("904c928e-b671-41b2-9354-7256e7dd68fa.h5ad", backed="r")
print(adata)
AnnData object with n_obs × n_vars = 239696 × 23337 backed at '904c928e-...h5ad'
obs: 'Dataset', 'sample', 'disease_original', 'disease_general', 'COVID-19 Condition',
'Lineage', 'Cell.group', 'Cell.class', 'n_genes', 'n_counts', 'percent_mito', ...
var: 'n_cells', 'feature_is_filtered', 'feature_name', 'feature_reference', ...
uns: 'citation', 'data sources', 'organism', 'title', ...
obsm: 'X_pca', 'X_tsne', 'X_umap'

Loading everything into memory needs a bigger machine: X and raw.X each hold 371,860,127 nonzero values. On our 16 GB machine we read only the raw counts and the two tables (see "Running scanpy on Plotly Cloud and Dash Enterprise" below).

What it looks like. Here are six cells from obs, one per cell type:

Study

Donor

Disease

Cell type

Technology

Genes detected

Arunachalam (Science)

cov10

COVID-19 severe

Platelet

10x 3' v3

419

Arunachalam (Science)

cov11

COVID-19 severe

NK cell

10x 3' v3

1,926

Guo (Nature Communications)

P1

COVID-19 severe

B cell

10x 3' v2

604

Lee (Science Immunology)

Normal 4

Healthy

CD14+ monocyte

10x 3' v3

2,916

Schulte-Schrepping (Cell)

C19-CB-0021

COVID-19 severe

Neutrophil

10x 3' v3

992

Schulte-Schrepping (Cell)

one_Ten

Healthy

CD4+ T cell

10x 3' v3

2,239

And here are their raw counts for 10 of the 23,337 genes. Most entries are zero (93.4% across the whole file), and each cell type lights up a different set of genes, which is what the rest of the analysis builds on.

Heatmap of raw RNA counts for six example cells and 10 genes: the platelet has 43 PPBP and 16 PF4, the NK cell 75 GNLY and 36 NKG7, the B cell 3 MS4A1, the CD14+ monocyte 128 LYZ and 197 S100A8, the neutrophil 100 S100A8, and the CD4+ T cell 9 CD3D and 13 IL7R; almost every other entry is 0

Three things you can do with scanpy

Check the quality of every cell

Before any analysis, scanpy counts how many genes each cell shows and flags cells that look empty or damaged, so you can filter them out.

Violin plots of genes detected per cell in each of the five COVID-19 studies: medians of 1,841 for Arunachalam, 889 for Guo, 1,849 for Lee, 1,604 for Schulte-Schrepping and 938 for Wilk, which used the Seq-Well technology
import scanpy as sc
qc = sc.pp.calculate_qc_metrics(adata, percent_top=None, log1p=False) # per-cell and per-gene tables
sc.pp.filter_cells(adata, min_genes=200)

How to read this chart:

  • Each violin is one study, and it's wider where more of that study's cells detected that many genes.
  • The box marks the middle half of the cells and the line inside it the median, so the Guo and Wilk studies (medians of 889 and 938 genes) caught fewer genes per cell than the other three (1,604 to 1,849).

Draw a map of all your cells

scanpy compresses thousands of genes into a 2D map (a UMAP) where cells with similar gene activity sit close together, and groups them into clusters, so cell types show up as islands.

UMAP of 239,696 blood cells after batch integration, colored and labeled by the authors' 14 cell types: CD4+ T cells, CD8+ T cells, other T cells, NK cells, B cells, plasmablasts, CD14+ and CD16+ monocytes, conventional and plasmacytoid dendritic cells, neutrophils, platelets, red blood cells and stem cells
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=2000)
sc.pp.pca(adata)
sc.pp.neighbors(adata)
sc.tl.umap(adata)
sc.tl.leiden(adata, flavor="igraph", n_iterations=2) # clusters

How to read this chart:

  • Each dot is one cell, and cells with similar gene activity sit close together, so each island is a group of similar cells.
  • Colors and labels are the cell types the study authors assigned. The axes have no units, and only the closeness of cells means something, not the exact distance between islands.

Find the genes that define each cell type

scanpy compares each group of cells with all the others and ranks the genes that set it apart, which is how you name a cluster or check where a gene is active.

Dot plot of the top two marker genes for each of 14 blood cell types, such as IL7R and LTB for CD4+ T cells, GNLY and PRF1 for NK cells, MS4A1 for B cells, LYZ and S100A9 for CD14+ monocytes and PF4 and PPBP for platelets; dot size is the share of cells expressing the gene
sc.tl.rank_genes_groups(adata, "cell_type", method="wilcoxon_illico")
sc.get.rank_genes_groups_df(adata, group="B cell").head()

How to read this chart:

  • Each row is a cell type and each column a gene, ordered so each cell type's two marker genes sit next to each other along the diagonal.
  • A bigger dot means more cells of that type have the gene, and a darker dot means more of it per cell, compared with that gene's highest value.
  • Read across a row to see what sets a cell type apart, or down a column to see where a gene is active.

LYZ is active in 98.9% of CD14+ monocytes and MS4A1 in 88.4% of B cells, which is why biologists use them to recognize those cells.

scanpy also does trajectory analysis (following cells as they change from one state to another), cell cycle scoring, doublet detection and spatial plotting, and it's the hub of the scverse ecosystem, alongside packages like scvi-tools for deep-learning models and squidpy for spatial data.

What's new in scanpy 1.13

Version 1.13 is a big release, with 249 commits since 1.12.5. These are its headline features:

  • Harmony is built in. Harmony removes the technical differences between batches of samples (from different labs, days or machines) so you can analyze them together. It used to need a separate package through sc.external, and now it's sc.pp.harmony_integrate.
  • Marker genes with wilcoxon_illico. A new method for sc.tl.rank_genes_groups, built on the illico library, gives the same results as the classic Wilcoxon test in a fraction of the time.
  • Parallel UMAP. Pass rng=None to sc.tl.umap and it uses all your CPU cores.
  • A preview of scanpy 2.0. scanpy.settings.preset = "scanpy-v2-preview" switches on the defaults planned for 2.0, and a new use and out style says exactly where each function reads and writes its data.
pip install -U scanpy

The demo app: Atlas Lab

We built a Dash app called Atlas Lab that uses these features on the COVID-19 Immune Atlas from CZ CELLxGENE, which combines 239,696 peripheral blood mononuclear cells (PBMCs, the white blood cells in a blood sample) from 78 donors in five published studies. We deployed it to Plotly Cloud. Here's what it does.

Atlas Lab Atlas tab: the 239,696 cells with no integration, where each study forms its own islands in the UMAP; study mixing 0.166 and 87% of neighbors from the same study

Before integration, each study forms its own islands on the map, even for the same cell type, because each lab's equipment and chemistry leave a fingerprint on the data. The cards on the left score how mixed the studies are.

Atlas Lab Atlas tab: UMAP of 239,696 PBMCs after sc.pp.harmony_integrate on scanpy 1.13.0, colored by the five COVID-19 studies, with study mixing 0.411 against 0.166 before, 64% of neighbors from the same study against 87% before, and cell-type agreement 89.3% against 89.9% before

After sc.pp.harmony_integrate, the studies overlap. The share of each cell's neighbors that come from its own study dropped from 87% to 64%, while the share with the same cell type barely moved (89.9% to 89.3%).

Atlas Lab Atlas tab: UMAP after sc.pp.harmony_integrate colored by the authors' 14 cell types, with T cells, NK cells, monocytes, B cells, platelets and plasmablasts in separate groups

Colored by cell type, the same map shows that integration kept T cells, monocytes, B cells and the rarer types in their own groups.

Atlas Lab Live Harmony tab: sc.pp.harmony_integrate run on 30,000 cells in a callback with study and donor as batch variables, harmony2 and theta 2, taking 1.00 s; study mixing 0.633 against 0.220 before, 38% of neighbors from the same study against 82%, cell-type agreement 85.2% against 87.9%; scatter plots of PC 1 and PC 2 before and after, colored by study

The Live Harmony tab runs sc.pp.harmony_integrate inside a Dash callback on 30,000 cells, with the batch variables and settings you pick, and scores the result. Correcting for study and donor together took 1.00 s on our machine.

Atlas Lab Markers tab: wilcoxon took 909.3 s on scanpy 1.13.0 and 1,209.5 s on 1.12.5, wilcoxon_illico took 45.2 s, and all 1,400 top-100 genes match; a table lists the top five genes per cell type from both methods

The Markers tab puts the classic Wilcoxon test and wilcoxon_illico side by side, with the top genes each one found for every cell type.

Atlas Lab Speed tab: seconds per step on 239,696 cells with four CPUs for scanpy 1.12.5 and 1.13.0; Harmony 5.0 s and 6.0 s, neighbor graph 47.0 s and 40.0 s, UMAP with the default seed 193.6 s and 232.3 s, UMAP with rng=None 94.2 s, Leiden 13.8 s and 13.2 s, marker genes with wilcoxon 1,209.5 s and 909.3 s, and with wilcoxon_illico 45.2 s

The Speed tab shows how long each step took on scanpy 1.12.5 and 1.13.0 with the same data and settings.

Putting the new features to the test

We ran the same pipeline on scanpy 1.12.5 and 1.13.0 on a Linux machine with four CPUs and 16 GB of memory (Python 3.13, NumPy 2.5.4, anndata 0.13.4), starting from the same principal components, and checked each new result against the old one.

Marker genes: same answer, 27 times faster. For 14 cell types, 23,337 genes and 239,696 cells, method="wilcoxon" took 1,209.5 s on 1.12.5 and 909.3 s on 1.13.0 (where the default scanpy.settings.n_jobs went from one to four), and method="wilcoxon_illico" took 45.2 s. All 1,400 top-100 genes (100 for each of the 14 cell types) were the same and in the same order, and the scores and log-fold changes matched exactly.

Harmony: same answer as before. scanpy 1.12.5 users ran Harmony through the separate harmonypy package. On our data, the built-in function and harmonypy 2.1.0 gave nearly the same result, and their nearest neighbors overlapped by 95%:

Embedding

Study mixing (0 to 1)

Neighbors from the same study

Same cell type as neighbors

No integration

0.166

86.6%

89.9%

harmonypy 2.1.0 (scanpy 1.12.5)

0.416

63.1%

89.3%

sc.pp.harmony_integrate, default

0.411

63.7%

89.3%

sc.pp.harmony_integrate(flavor="harmony1")

0.432

62.0%

89.1%

The release notes say the new numba code made Harmony 6 to 13 times faster, and pull request #4424 measures that against scanpy's own earlier version from the 1.13 pre-releases. Against harmonypy 2.1.0, which is itself fast C++, we measured about the same speed: 5.42 s for scanpy and 4.55 s for harmonypy once warmed up. The original Harmony algorithm (flavor="harmony1") took 45.02 s.

UMAP: about twice as fast with rng=None. UMAP took 193.6 s on 1.12.5 and 94.2 s on 1.13.0 with rng=None. With the default fixed seed, which keeps the picture the same on every run, 1.13.0 took 232.3 s. That's from umap-learn 0.6.0, because the same scanpy with umap-learn 0.5.12 took 186.7 s.

The whole pipeline. Harmony, the neighbor graph, UMAP, Leiden clustering and marker genes took 1,468.8 s (24.5 minutes) on 1.12.5 with its defaults, and 198.7 s (3.3 minutes) on 1.13.0 with rng=None and wilcoxon_illico.

The full changelog

There's a lot more in 1.13. Here are a few lines from the scanpy 1.13.0 release notes, which go on to list dozens more features, performance work and fixes (the names in brackets are the contributors behind each change):

1.13.0 2026-11-13
Breaking changes
- Remove compatibility helper for passing parameters positionally (P Angerer, #3984)
- Remove use_highly_variable parameter to scanpy.pp.pca and
scanpy.experimental.pp.normalize_pearson_residuals_pca (P Angerer, #4039)
- Remove 'rapids' flavor for scanpy.pp.neighbors, scanpy.tl.louvain, scanpy.tl.umap (P Angerer, #4052)
Features
- Add scanpy.settings.preset setting with the new preset scanpy.Preset.ScanpyV2Preview (P Angerer, #3653)
- Add scanpy.pp.harmony_integrate with Harmony1 and Harmony2 support for batch correction
(S Dicks, P Angerer, #3953)
- Introduce blazing-fast illico as a new wilcoxon method in scanpy.tl.rank_genes_groups (I Gold)
- Add scanpy.pp.bbknn, a native implementation of batch balanced kNN (S Dicks, #4306)
...

Before you upgrade, note that sc.pp.filter_genes_dispersion and sc.pp.normalize_per_cell are gone, reading and writing functions moved to scanpy.io, and copy, inplace, use_rep, batch_key and use_raw are deprecated, with warnings that tell you what to use instead.

Running scanpy on Plotly Cloud and Dash Enterprise

These are the things we learned deploying Atlas Lab, and they apply the same way on Dash Enterprise.

  • Compute the heavy steps ahead of time. Preprocessing and UMAP on 239,696 cells took minutes and about 7.6 GB of memory, so we ran them once and shipped the results with the app as Parquet files. The app runs the fast steps, like Harmony on 30,000 cells, live in callbacks.
  • Warm up numba when the app starts. scanpy's new Harmony is compiled by numba the first time it runs. On Plotly Cloud the first call took 45.4 s and the next took 8.3 s, so the app makes one call when it boots, before anyone opens the page.
  • Add tbb if scanpy runs outside the main thread. scanpy's numba code drops to a slow single-threaded mode when it's called from a background thread and numba has no thread-safe library to use. Add tbb to requirements.txt, and load libtbb.so.12 from the environment's lib/ folder with ctypes before importing scanpy (our app shows how). Dash callbacks under the default gunicorn workers run on the main thread, so they aren't affected.
  • Give long callbacks time. We set gunicorn's worker timeout to 180 s in a gunicorn.conf.py next to app.py, because the default 30 s is too short for a first numba compile.
  • Pin compatible versions if you stay on 1.12. scanpy 1.12.5 doesn't work with umap-learn 0.6.0 (pin umap-learn<0.6), and its sc.external.pp.harmony_integrate fails with harmonypy 2.x. scanpy 1.13 works with both.

Prompts to build it yourself

You can build these apps with a coding agent. These are the prompts that describe what we made.

The three charts at the top of this post:

Download the CZ CELLxGENE "COVID-19 Immune Atlas: Integration of 5 public COVID-19 PBMC
single-cell datasets" h5ad. With scanpy 1.13 and Plotly, make three charts:
1. Violin plots of genes detected per cell for each of the five studies (obs "Dataset").
2. A UMAP of all cells after sc.pp.harmony_integrate on the study, colored by obs "Cell.group",
with each cell type's name at its median position.
3. A dot plot of the top two marker genes per cell type from
sc.tl.rank_genes_groups(method="wilcoxon_illico"): dot size is the share of cells expressing
the gene, color is the mean expression scaled per gene.
Read only raw/X, obs and var (the file is large), and normalize the counts yourself.

The Atlas Lab app:

Build a Dash app called Atlas Lab that shows batch integration of a single-cell atlas with
scanpy 1.13, using the same COVID-19 Immune Atlas.
Offline: normalize_total to 1e4, log1p, 2,000 highly variable genes per study, PCA with 50
components, sc.pp.harmony_integrate(adata, A.obs["Dataset"]), neighbors and UMAP (rng=None) on
the result, and UMAP on the uncorrected PCA. Save the UMAP coordinates and labels for every cell,
and 30,000 random cells with their 50 PCs, as Parquet.
Tabs:
1. Atlas: a Scattergl UMAP of every cell, with radios for the layout (before or after Harmony)
and the color (study, cell type, disease), and cards that score study mixing and cell-type
agreement with the 50 nearest neighbors.
2. Live Harmony: checkboxes for the batch variables (study, donor), harmony2 or harmony1, a
theta slider and a Run button that runs sc.pp.harmony_integrate on the 30,000 cells in the
callback and shows PC 1 against PC 2 before and after.
3. Speed and 4. Markers: charts and tables from a benchmark of rank_genes_groups with wilcoxon
and wilcoxon_illico.
Run Harmony once at startup to warm up numba, add tbb to requirements.txt and set gunicorn's
timeout to 180 s. Use Inter, a light theme and the Plotly brand purple.

FAQ

Glossary

Single-cell RNA sequencing (scRNA-seq). A lab method that counts the RNA molecules from each gene in each individual cell, which shows which genes each cell has switched on.

AnnData. The Python object and .h5ad file format scanpy works with. It holds the cells-by-genes table plus tables of information about the cells (obs) and genes (var).

PBMC. Peripheral blood mononuclear cells, the white blood cells you get from an ordinary blood sample, such as T cells, B cells, NK cells and monocytes.

Raw counts. The number of RNA molecules from each gene counted in each cell, before any processing.

Normalization. Scaling each cell's counts to the same total (we used 10,000) and taking the log, so cells sequenced more deeply don't look more active.

CZ CELLxGENE Discover. The Chan Zuckerberg Initiative's free library of published single-cell datasets, all in the same .h5ad format.

Principal component analysis (PCA). A way to summarize thousands of genes as a few dozen numbers per cell (principal components) that keep most of the differences between cells.

Batch effect. Differences in the data that come from how samples were processed (a different lab, day or machine) rather than from biology.

Harmony. A method from a 2019 Nature Methods paper by Korsunsky and colleagues that removes batch effects. It groups cells into clusters and nudges each cell's principal components until every cluster contains a fair mix of batches. Harmony2 is a newer version of the algorithm and the default in scanpy 1.13.

UMAP. Uniform Manifold Approximation and Projection, a method that draws high-dimensional data as a 2D map where similar cells sit close together.

Leiden clustering. A method that splits the graph of nearest-neighbor cells into groups (clusters) that usually correspond to cell types.

Marker genes. Genes that are much more active in one group of cells than in the rest, used to name cell types.

Wilcoxon rank-sum test. A statistical test that compares two groups by the ranks of their values, so it doesn't assume the data follows a bell curve. scanpy uses it to rank marker genes.

numba. A Python library that compiles Python functions to fast machine code the first time they run.

bluesly
x
instagram
youtube
medium
fcaebook

Product

© 2026
Plotly. All rights reserved.
Cookie Preferences
aicpa/soc2