Single-cell transcriptomics / Seurat and Scanpy

Single-cell analysis workflow: Seurat v5 and Scanpy from QC to cell annotation

This page is written for Seurat 5.5, SeuratObject 5.4 and Scanpy 1.12; the R and Python code was run end to end on 10x PBMC 3k. It focuses on the decisions the official tutorials leave open: how to set thresholds, where old tutorials break on v5, how to choose an integration method and a clustering resolution, how to check annotations, and what to do when memory runs out.

Short answer

The standard single-cell workflow is: read the Cell Ranger filtered matrix; run doublet detection per sample; set QC thresholds with MAD (median absolute deviation), using 3 MAD for the mitochondrial fraction together with a fixed floor (10% is common for human, 5% for mouse); LogNormalize, select 2,000 highly variable genes and run PCA; for multiple samples look at the unintegrated result first and integrate with Harmony, RPCA or scVI only when needed; choose the resolution with clustree and stability across random seeds; annotate with marker genes, cross-checked with SingleR, CellTypist or Azimuth. In Seurat v5 the slot argument no longer works and must be replaced by layer; split layers by sample before IntegrateLayers and run JoinLayers before differential expression.

Step 1

Read filtered_feature_bc_matrix (or the .h5 of the same name); empty droplets have already been removed. raw_feature_bc_matrix is only needed as input to SoupX or CellBender.

v4 code on v5: errors and replacements (tested, SeuratObject 5.4.0)r
# Seurat v5 / SeuratObject 5.4: replacements for v4 code
GetAssayData(obj, slot = "counts")   # Error: The `slot` argument of `GetAssayData()` was deprecated in SeuratObject 5.0.0 and is now defunct.
obj@assays$RNA@counts                # Error: no slot of name "counts" for this object of class "Assay5"

obj[["RNA"]]$counts                  # v5 accessors
LayerData(obj, assay = "RNA", layer = "data")
GetAssayData(obj, layer = "data")    # works only while the assay has one "data" layer

# Old v4 object read into v5: convert the assay class when a tool needs Assay5 (e.g. BPCells, IntegrateLayers)
obj[["RNA"]] <- as(obj[["RNA"]], "Assay5")
# Tool that only accepts v4 assays: convert back (merge layers first)
obj <- JoinLayers(obj); obj[["RNA"]] <- as(obj[["RNA"]], "Assay")

Data from Cell Ranger 7 and later detect more genes

Since Cell Ranger 7.0, --include-introns defaults to true for count, so intronic reads are counted and genes and UMIs per cell shift upward. The nFeature < 2500 cap in the PBMC 3k tutorial comes from data produced by Cell Ranger 1.1 in 2016 (median 817 genes per cell in this page's run); copying it to newer data removes normal cells. Set thresholds from the distribution of your own data.

v5 objects store matrices in layers

The Seurat v5 Assay5 stores counts, data and scale.data as layers; after merge each input object keeps its own layer (for example counts.1, counts.2). The slot argument and @counts syntax from v4 tutorials error out on SeuratObject 5.4; the error messages are in the code block below.

GetAssayData reads a single layer only

When an assay has several data layers, GetAssayData fails with GetAssayData doesn't work for multiple layers in v5 assay and FindMarkers fails with data layers are not joined. Please run JoinLayers. Run JoinLayers before extracting matrices or testing differential expression.

Underscores in gene names are rewritten

CreateSeuratObject replaces underscores in feature names with dashes and warns Feature names cannot have underscores. Apply the same replacement before matching gene names to external tables such as marker lists or GTF annotations.

Step 2

QC removes three kinds of barcodes: those with almost no signal (empty droplets or broken cells), those with unusually many genes and UMIs (mostly doublets), and those with a high mitochondrial fraction (damaged membranes that lost cytoplasmic mRNA). Compute thresholds separately for each sample.

Tested on this page: macOS arm64 (8 cores, 16 GB), Scanpy 1.12.4 / anndata 0.13.4 / Python 3.12, Seurat 5.5.1 / SeuratObject 5.4.0 / R 4.5, 10x PBMC 3k (2,700 cells). Other jobs were running on the same machine during the test, so run times indicate order of magnitude only.

In PBMC 3k the median mitochondrial fraction is 2.03%, the 95th percentile 4.01% and the 99th percentile 5.88%. The tutorial's fixed rule (200 < genes < 2500 and mitochondrial < 5%) keeps 2,638 cells. The MAD rule (5 MAD on UMIs, genes and top-20 share; mitochondrial 3 MAD and > 8%) removes 104 cells and keeps 2,596; the MAD interval is 366 to 1,820 for genes and 708 to 6,811 for UMIs.

On the same data the 3 MAD upper bound for the mitochondrial fraction is only 3.65%. Without the 8% floor, filtering on 3 MAD alone raises the number of removed cells from 104 to 276; most of the extra cells are normal PBMCs with 3.7%–5% mitochondrial reads. This is why the MAD rule needs a fixed floor.

The MAD rule also removes real populations with low RNA content. PBMC 3k contains 19 platelet-like cells with high PPBP and a median of only 397 genes; the MAD rule removes 10 of them, the rest are too few to form a cluster, and the Scanpy workflow ends without a platelet cluster, whereas the fixed-threshold workflow keeps a 13-cell platelet cluster. If platelets, erythroid precursors, neutrophils or other low-RNA cells matter for your question, check the markers of the removed cells before fixing the thresholds.

With several samples, compute MAD per sample. Pooled MAD lets a deeply sequenced sample turn normal cells from a shallow sample into outliers.

MetricRecommended ruleBasisNote
UMI count (nCount / total_counts)Median ±5 MAD on log1p scaleSingle-cell Best Practices, QC chapter5 MAD is lenient so that small populations are not removed by mistake
Genes detected (nFeature / n_genes_by_counts)Median ±5 MAD on log1p scaleSameAn upper cap does not replace doublet detection
Share of counts in the top 20 genesMedian ±5 MADSameA high share indicates low library complexity
Mitochondrial fractionMedian +3 MAD and above a fixed floorSingle-cell Best Practices uses 8%; Osorio and Cai 2021 recommend 10% for human and 5% for mouse3 MAD alone over-filters data whose mitochondrial fraction is low overall
Mitochondrial fraction in single-nucleus data (snRNA-seq)Usually far lower than in single cells; 1%–5% is a typical capNuclei contain no mitochondria; mitochondrial reads come from cytoplasmic contaminationA high-mitochondrial nucleus cluster may be contamination rather than low quality
Osorio and Cai 2021 (Bioinformatics) analyzed 1,349 PanglaoDB datasets with 5.53 million cells: of 121 mouse tissues only whole kidney, whole heart and distal small intestine had a mean mitochondrial fraction above 5%; 13 of 44 human tissues had a mean above 5%.

Step 3

Run doublet detection separately for each capture channel (each 10x channel) and before strict QC. Ambient RNA correction is optional; it matters most for marker and differential expression analysis.

Tested on this page: on the same PBMC 3k data scDblFinder (1.24.10) calls 124 doublets (4.6%) and Scanpy's built-in Scrublet calls only 32 (1.2%). At 0.8% per 1,000 cells, about 58 doublets are expected among 2,700 cells, so the two tools fall on either side of the expectation. Report which tool you used and how many cells it removed, and check on the UMAP whether the called doublets sit between two clusters.

Called doublets can be flagged rather than removed. After clustering, if a small cluster expresses markers of two lineages (for example CD3E and CD14) and has high doublet scores, remove the whole cluster. Single-cell Best Practices also keeps flagged doublets at first.

Janssen et al. 2023 (Genome Biology) compared CellBender, DecontX and SoupX on genotype-mixed samples: background removal improved clustering and cell classification only slightly, improved marker gene detection the most, and CellBender estimated the background level most accurately. For clustering and coarse annotation the step can be skipped; for differential expression or claims that a gene is specific to a cluster, correct for ambient RNA.

ProblemToolInput and timingKey parametersWhen it is needed
DoubletsscDblFinder (R, Bioconductor)Raw counts after empty-droplet removal; one channel at a time via samples=dbr.per1k defaults to 0.008 (0.8% per 1,000 cells); use 0.004 for 10x HT chipsEvery 10x sample; with more than 5,000 cells loaded the doublet rate exceeds 4%
DoubletsDoubletFinder (R)One sample, Seurat object with low-quality clusters removedChoose pK with the BCmvn sweep; estimate nExp from loading density and subtract homotypic doubletsWhen you work in Seurat; do not run it on merged or integrated objects
DoubletsScrublet (built into Scanpy as sc.pp.scrublet)Raw counts; batch_key="sample" for several samplesexpected_doublet_rate defaults to 0.05Python workflows; the automatic threshold is conservative, so inspect the score histogram
Ambient RNASoupX (R)Raw and filtered matrices plus a coarse clusteringautoEstCont estimates the contamination fractionWhen hemoglobin, immunoglobulin or other highly expressed genes appear in clusters that should not express them
Ambient RNACellBender (Python)raw_feature_bc_matrix.h5Since v0.3.0 expected-cells and related parameters are estimated automaticallyWhen markers and differential expression must be accurate; the documentation asks for a GPU (--cuda)

Step 4

LogNormalize (normalize_total + log1p in Scanpy) is a safe default. Ahlmann-Eltze and Huber 2023 (Nature Methods) found that log(y/s + 1) followed by PCA performs as well as or better than SCTransform, Pearson residuals and other more complex transformations.

Complete Scanpy workflow (tested on this page)python
# Scanpy 1.12 (Python >= 3.12): PBMC 3k from Cell Ranger output to annotated clusters
import numpy as np, scanpy as sc
from scipy.stats import median_abs_deviation

adata = sc.read_10x_mtx("filtered_gene_bc_matrices/hg19", var_names="gene_symbols")
# Cell Ranger >= 3: sc.read_10x_h5("filtered_feature_bc_matrix.h5")
adata.var_names_make_unique()

# QC metrics: mitochondrial / ribosomal / hemoglobin genes (mouse: "mt-", ("Rps","Rpl"), "^Hb[^(p)]")
adata.var["mt"] = adata.var_names.str.startswith("MT-")
adata.var["ribo"] = adata.var_names.str.startswith(("RPS", "RPL"))
adata.var["hb"] = adata.var_names.str.contains("^HB[^(P)]")
sc.pp.calculate_qc_metrics(adata, qc_vars=["mt", "ribo", "hb"], percent_top=[20], log1p=True, inplace=True)

# Doublets on raw counts, per capture (batch_key="sample" when several samples are concatenated)
sc.pp.scrublet(adata, random_state=0)

# MAD outliers: 5 MAD on counts / genes / top-20 share, mito 3 MAD AND above a fixed floor
def outlier(x, nmads, upper_only=False):
    med, mad = np.median(x), median_abs_deviation(x)
    return (x > med + nmads * mad) if upper_only else ((x < med - nmads * mad) | (x > med + nmads * mad))
o = adata.obs
bad = (outlier(o.log1p_total_counts, 5) | outlier(o.log1p_n_genes_by_counts, 5)
       | outlier(o.pct_counts_in_top_20_genes, 5)
       | (outlier(o.pct_counts_mt, 3, upper_only=True) & (o.pct_counts_mt > 8)))
adata = adata[~bad & ~adata.obs.predicted_doublet].copy()
sc.pp.filter_genes(adata, min_cells=3)

# Normalize, HVG, PCA
adata.layers["counts"] = adata.X.copy()
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat_v3", layer="counts")  # add batch_key="sample" for multi-sample data
adata.raw = adata                                     # keep all genes (log-normalized) for markers and plots
hv = adata[:, adata.var.highly_variable].copy()       # scale only HVGs; scaling all genes makes X dense
sc.pp.scale(hv, max_value=10)
sc.tl.pca(hv, n_comps=50)

# Graph, clustering, UMAP
sc.pp.neighbors(hv, n_neighbors=15, n_pcs=30)
for r in (0.2, 0.4, 0.6, 0.8, 1.0):
    sc.tl.leiden(hv, resolution=r, flavor="igraph", n_iterations=2, key_added=f"leiden_{r}")
sc.tl.umap(hv)

# Markers on log-normalized expression of all genes
adata.obs["leiden"] = hv.obs["leiden_0.6"].values
sc.tl.rank_genes_groups(adata, "leiden", method="wilcoxon", use_raw=False)
sc.pl.rank_genes_groups_dotplot(adata, n_genes=5)
Complete Seurat v5 workflow (tested on this page)r
# Seurat v5: same workflow in R
library(Seurat)
counts <- Read10X("filtered_gene_bc_matrices/hg19")      # Cell Ranger >= 3: Read10X_h5("filtered_feature_bc_matrix.h5")
obj <- CreateSeuratObject(counts, project = "pbmc3k", min.cells = 3, min.features = 200)
obj[["percent.mt"]] <- PercentageFeatureSet(obj, pattern = "^MT-")   # mouse: "^mt-"

# Doublets: scDblFinder on the raw counts of each capture, before strict QC
library(scDblFinder); library(SingleCellExperiment)
set.seed(1)
sce <- scDblFinder(SingleCellExperiment(list(counts = LayerData(obj, layer = "counts"))))
obj$dbl_class <- sce$scDblFinder.class               # several captures: scDblFinder(sce, samples = "sample")

# MAD thresholds instead of the 200 / 2500 / 5% values of the PBMC3k tutorial
mad_out <- function(x, n, upper = FALSE) {
  m <- median(x); d <- mad(x)
  if (upper) x > m + n * d else x < m - n * d | x > m + n * d
}
bad <- mad_out(log1p(obj$nCount_RNA), 5) | mad_out(log1p(obj$nFeature_RNA), 5) |
       (mad_out(obj$percent.mt, 3, upper = TRUE) & obj$percent.mt > 8)
obj <- subset(obj, cells = colnames(obj)[!bad & obj$dbl_class == "singlet"])

obj <- NormalizeData(obj)                            # LogNormalize, scale.factor = 1e4
obj <- FindVariableFeatures(obj, nfeatures = 2000)
obj <- ScaleData(obj)                                # HVGs only by default
obj <- RunPCA(obj, npcs = 50)
ElbowPlot(obj, ndims = 50)
obj <- FindNeighbors(obj, dims = 1:20)
obj <- FindClusters(obj, resolution = c(0.2, 0.4, 0.6, 0.8, 1.0))
obj <- RunUMAP(obj, dims = 1:20)

library(clustree)                                    # attach it; clustree::clustree() alone fails at ggsave
clustree(obj, prefix = "RNA_snn_res.")
Idents(obj) <- "RNA_snn_res.0.6"
markers <- FindAllMarkers(obj, only.pos = TRUE, min.pct = 0.25, logfc.threshold = 0.25)

SCTransform needs glmGamPoi

Without glmGamPoi, SCTransform reports could not find glmGamPoi installed and falls back to a slower implementation; 2,638 cells took 106.7 s on this page, and the gap grows with tens of thousands of cells. Install it with BiocManager::install("glmGamPoi").

After SCT, test differential expression on RNA or after PrepSCTFindMarkers

SCT residuals are for dimensionality reduction and clustering only. For FindMarkers after multi-sample SCT, run PrepSCTFindMarkers first to correct sequencing depth across samples, or switch back to the RNA assay and its LogNormalize data layer.

Highly variable genes: 2,000 by default, 3,000 as a common upper value

Seurat and Scanpy both default to 2,000. Scanpy's flavor="seurat_v3" needs raw counts (layer="counts"); with several samples add batch_key so genes are selected per sample and then ranked together, which keeps batch-specific genes out of the HVG set. The Luecken 2022 benchmark found that HVG selection improves integration.

Number of PCs: find the elbow and err on the high side

In PBMC 3k PC1 explains 2.9% of the variance; after the 7th PC each PC explains only 0.18%–0.24% and the curve is essentially flat; the first 10, 30 and 50 PCs explain 7.6%, 11.0% and 14.2% in total. The Seurat tutorial considers anything from 7 to 12 PCs defensible. For complex tissues or tens of thousands of cells use 20–50 PCs; too few PCs merge rare populations into large ones, while a few extra PCs change little. JackStraw is slow and is no longer a routine step in the official tutorial.

MethodCommandUse whenTested on PBMC 3k
LogNormalize / normalize_total + log1pNormalizeData(); sc.pp.normalize_total(target_sum=1e4) + sc.pp.log1pDefault; CellTypist and similar tools expect this inputSeurat core workflow (normalization to UMAP) 15.9 s
SCTransform v2SCTransform(obj, vst.flavor = "v2")Large differences in sequencing depth, or to skip ScaleData; run SCT per sample before integration106.7 s without glmGamPoi; ARI 0.73 against the LogNormalize clustering
Pearson residualssc.experimental.pp.highly_variable_genes(flavor="pearson_residuals") + normalize_pearson_residualsAlternative to the log transform in Scanpy10 clusters at res 0.6; ARI 0.63 against the log workflow

Step 5

Start without integration and color the UMAP by sample. If cells of the same type from different samples already mix, integration is unnecessary. Integration only changes the embedding (a harmony, integrated.rpca or similar reduction); the expression matrix is unchanged, and differential expression always uses the original RNA counts and data.

Tested on this page (Seurat 5.5.1, harmony 2.0.5, PBMC 3k randomly split into two "samples"): running IntegrateLayers(method = HarmonyIntegration) without split fails with attempt to set an attribute on NULL; after split Harmony completes. After integration, subsetting a subcluster leaves the layers joined, and running IntegrateLayers again fails with the same error; it succeeds after splitting the subset again. Calling split on a merged object that already has one layer per sample fails with The following layers are already split. CSDN authors report that CCAIntegration without split fails with no applicable method for 'Assays' applied to an object of class "NULL".

Tested on this page (Scanpy 1.12.4, harmonypy 2.0.2): sc.external.pp.harmony_integrate fails with ValueError: Value passed for key 'X_pca_harmony' is of incorrect shape. harmonypy 2.x already returns Z_corr as cells × PCs, and the Scanpy wrapper transposes it again. The code below calls harmonypy directly.

Seurat v5: split, integration, JoinLayers and re-integrating a subclusterr
# Seurat v5 multi-sample integration: split -> per-layer preprocessing -> IntegrateLayers -> JoinLayers
obj <- merge(s1, y = list(s2, s3), add.cell.ids = c("s1", "s2", "s3"))   # already one layer per object: counts.1, counts.2, counts.3
# Object built from one matrix, or after JoinLayers: split by sample first
# obj[["RNA"]] <- split(obj[["RNA"]], f = obj$sample)
# Splitting a merged object again fails: The following layers are already split: 'counts.1', 'counts.2' Please join before splitting
obj <- NormalizeData(obj); obj <- FindVariableFeatures(obj); obj <- ScaleData(obj); obj <- RunPCA(obj)

# Unintegrated baseline first
obj <- FindNeighbors(obj, dims = 1:30, reduction = "pca")
obj <- FindClusters(obj, resolution = 0.5, cluster.name = "unintegrated_clusters")
obj <- RunUMAP(obj, dims = 1:30, reduction = "pca", reduction.name = "umap.unintegrated")

obj <- IntegrateLayers(obj, method = HarmonyIntegration, orig.reduction = "pca", new.reduction = "harmony")
# alternatives: method = RPCAIntegration (more conservative), CCAIntegration, FastMNNIntegration, scVIIntegration
obj <- FindNeighbors(obj, reduction = "harmony", dims = 1:30)
obj <- FindClusters(obj, resolution = 0.5, cluster.name = "harmony_clusters")
obj <- RunUMAP(obj, reduction = "harmony", dims = 1:30, reduction.name = "umap.harmony")

obj <- JoinLayers(obj)          # before FindMarkers / FindAllMarkers / GetAssayData

# Re-clustering a subset: layers are joined now, so split again before IntegrateLayers
sub <- subset(obj, idents = c("0", "3"))
sub[["RNA"]] <- split(sub[["RNA"]], f = sub$sample)
sub <- NormalizeData(sub); sub <- FindVariableFeatures(sub); sub <- ScaleData(sub); sub <- RunPCA(sub)
sub <- IntegrateLayers(sub, method = HarmonyIntegration, orig.reduction = "pca", new.reduction = "harmony_sub")
sub <- FindNeighbors(sub, reduction = "harmony_sub", dims = 1:20); sub <- FindClusters(sub, resolution = 0.3)
sub <- JoinLayers(sub)
Scanpy: Harmony and scVIpython
# Scanpy: Harmony (harmonypy) and scVI on the same AnnData
import scanpy as sc
sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat_v3", layer="counts", batch_key="sample")
hv = adata[:, adata.var.highly_variable].copy()
sc.pp.scale(hv, max_value=10); sc.tl.pca(hv, n_comps=50)
# sc.external.pp.harmony_integrate(hv, key="sample") fails with harmonypy 2.x:
#   ValueError: Value passed for key 'X_pca_harmony' is of incorrect shape ... (50,) while it should have had (n_cells,)
# harmonypy 2.x already returns Z_corr as cells x PCs, the Scanpy 1.12.4 wrapper transposes it again
import harmonypy
ho = harmonypy.run_harmony(hv.obsm["X_pca"], hv.obs, "sample")
Z = ho.Z_corr
hv.obsm["X_pca_harmony"] = Z if Z.shape[0] == hv.n_obs else Z.T
sc.pp.neighbors(hv, use_rep="X_pca_harmony", n_pcs=30)
sc.tl.leiden(hv, resolution=0.5, flavor="igraph", n_iterations=2)

# scVI: needs raw counts; a GPU shortens training, CPU works for tens of thousands of cells
import scvi
scvi.model.SCVI.setup_anndata(hv, layer="counts", batch_key="sample")
m = scvi.model.SCVI(hv, n_latent=30)
m.train()                                                      # max_epochs scales down automatically for large data
hv.obsm["X_scVI"] = m.get_latent_representation()
sc.pp.neighbors(hv, use_rep="X_scVI")

How to check for over-integration

Cluster before and after integration and check three things: whether each cluster's markers still belong to one lineage; whether cell states expected only in one condition still form their own cluster; and whether the sample proportions in every cluster have been flattened to the same value. Seurat recommends comparing the clusters and marker expression of several integration methods with the unintegrated result.

The integrated assay of v4 tutorials is not for differential expression

IntegrateData in v4 creates an integrated assay, and old tutorials often ran FindMarkers on it. Integrated values are corrected low-dimensional reconstructions and are not suitable for testing gene expression differences. IntegrateLayers in v5 only creates a reduction; differential expression is done on the RNA assay after JoinLayers.

ScenarioFirst choiceAlternativeCost of the wrong choice
A few samples from one experiment on one platform, mild batch effectsNo integration, or HarmonyRPCAForcing CCA can erase real differences in composition between samples
Control versus treatment (disease versus normal) where condition-specific cell states are expectedHarmony or RPCA (described by Seurat as more conservative)scVIOver-integration merges treatment-specific states into control clusters, and the later differential analysis finds no signal
Several labs or platforms, atlas-scale data, many batches with different cell compositionscVI; scANVI when part of the cells are labeledScanoramaHarmony under-corrects and one cell type splits into clusters by source
Several hundred thousand cells or moreHarmony (fast, low memory) or scVI (GPU)Seurat sketch, then integrateCCA becomes expensive in memory and time on large data
Luecken et al. 2022 (Nature Methods) benchmarked 68 method and preprocessing combinations on 13 integration tasks with more than 1.2 million cells: scANVI, Scanorama, scVI and scGen performed well on complex tasks, while linear methods such as Harmony performed well on simple batch tasks.

Step 6

Resolution sets how coarse or fine clusters are, and there is no single correct value. Scan a range of resolutions and pick the lowest one at which the cluster structure is stable and every cluster has interpretable markers; for finer subtypes, subset a large cluster and recluster it.

On PBMC 3k, at res 1.5 the number of clusters varies between 16 and 18 across seeds and ARI falls to 0.75, so the extra clusters are unstable. With 30 PCs and res 0.4 the five seeds give nearly identical results (ARI 0.99). At the same resolution, 10 PCs yield 1–3 more clusters than 30 PCs, so report the number of PCs together with the resolution.

In a clustree plot, a cluster receiving arrows from two clusters of the previous level means the resolution is high enough that cells are being reassigned. In this page's PBMC 3k clustree plot such crossings start between res 0.4 and 0.6, consistent with the 0.5 used in the Seurat tutorial. Seurat's rule of thumb is res 0.4–1.2 for about 3,000 cells, with higher values for more cells.

clustree fails with Unknown guide: edge_colourbar

Tested on this page (clustree 0.5.1, ggraph 2.2.2, ggplot2 4.0.3): calling clustree::clustree() without attaching the package fails at ggsave or print with Unknown guide: edge_colourbar. Run library(clustree), which also attaches ggraph, before plotting.

Set flavor explicitly for Leiden in Scanpy

In Scanpy 1.12.4, leaving out flavor still uses leidenalg and emits a FutureWarning: The `igraph` implementation of leiden clustering is *orders of magnitude faster*. Use flavor="igraph", n_iterations=2 as in the official tutorial. sc.tl.louvain is deprecated and fails with ModuleNotFoundError when the louvain package is not installed.

Reselect highly variable genes after subsetting

After subsetting a large cluster such as T cells, rerun FindVariableFeatures, ScaleData and RunPCA before reclustering; the HVGs and PCs of the full dataset may not include the genes that separate subtypes. For multi-sample data, split and integrate again as described in the previous section.

Settingres 0.2res 0.4res 0.6res 0.8res 1.0res 1.5
Seurat, dims 1:10 (Louvain)7910111113
Scanpy, 10 PCs (Leiden, 5 seeds)6 (ARI 0.96)8–9 (0.85)9–10 (0.89)11–12 (0.83)12–13 (0.85)16–18 (0.75)
Scanpy, 30 PCs (Leiden, 5 seeds)5 (0.95)7 (0.99)8 (0.86)9 (0.90)9 (0.89)12–14 (0.75)
Tested on PBMC 3k. Values in parentheses are the mean pairwise adjusted Rand index (ARI) between 5 random seeds; closer to 1 means more stable. Seurat and Scanpy give different numbers of clusters because they build the graph and cluster it differently.

Step 7

Automatic annotation tools can only assign labels present in their reference. Let an automatic tool propose a label for each cluster, check every cluster with a dot plot of canonical markers, and when they conflict, trust the markers and the tissue context.

Tested on this page (CellTypist 1.7.1, Immune_All_Low, majority_voting, over_clustering set to the 8 Leiden clusters at res 0.6): the voted labels agree with the marker-based calls for all 8 clusters: Tcm/Naive helper T (1,165 cells), Tem/Trm cytotoxic T (280), B cells (332), Classical monocytes (465), CD16+ NK (157), Non-classical monocytes (168), DC (27) and Megakaryocytes/platelets (13). Per-cell predictions produce 41 different labels, so reading only per-cell results splits one cluster into many small subtypes.

At this resolution naive and memory CD4 T cells are not separated (both are in the 1,165-cell cluster), and CCR7 and LEF1 are high only in part of it. To separate them, recluster this cluster on its own, or raise the resolution and check with CCR7 and S100A4.

CellTypist (tested on this page)python
# CellTypist on log1p data normalized to 10,000 counts per cell (adata after normalize_total + log1p)
import celltypist
from celltypist import models
models.download_models(model=["Immune_All_Low.pkl"])
pred = celltypist.annotate(adata, model="Immune_All_Low.pkl",
                           majority_voting=True, over_clustering=adata.obs["leiden"])
adata.obs["celltypist"] = pred.predicted_labels["majority_voting"]
adata.obs["celltypist_conf"] = pred.probability_matrix.max(axis=1)   # low values = label not in the model
SingleR per-cluster annotation (not run on this machine)r
# SingleR + celldex (Bioconductor). On macOS arm64 bioconda has no SingleR build:
# install with BiocManager::install(c("SingleR", "celldex")) inside the R environment
library(SingleR); library(celldex)
ref <- celldex::MonacoImmuneData()                    # immune; HumanPrimaryCellAtlasData() for broad tissue types
pred <- SingleR(test = LayerData(obj, layer = "data"), ref = ref,
                labels = ref$label.fine, clusters = obj$seurat_clusters)   # per-cluster mode
obj$singler <- pred$pruned.labels[match(obj$seurat_clusters, rownames(pred))]
plotScoreHeatmap(pred)                                # check that the best score clearly beats the second

Reading marker tables

pct.1 and pct.2 in Seurat's FindAllMarkers are the fractions of cells expressing the gene in the cluster and in all other clusters. A good marker has a high avg_log2FC and a pct.1 well above pct.2; ranking by log2FC alone picks genes expressed in very few cells. In this page's Seurat result, CD79A in the B cell cluster has pct.1 0.936 and pct.2 0.041.

Clusters whose top markers are ribosomal genes

In the Scanpy result the top 5 markers of the large T cell cluster are LDHB and four RPS genes. High ribosomal gene expression is one feature of naive T cells but is not enough for annotation on its own; check with CD3E, IL7R and CCR7.

Install presto when FindAllMarkers is slow

Without presto, Seurat uses an R implementation of the Wilcoxon test and suggests installing presto. Since Seurat 5.6.0, FindAllMarkers can test all clusters in one presto call.

ToolLanguageReference and inputStrengthsLimitations
SingleR + celldexRcelldex references (HumanPrimaryCellAtlas, Blueprint/ENCODE, Monaco immune and others); log-normalized matrixCan annotate per cluster (clusters= argument); returns pruned.labels and a score heatmapReferences are mostly bulk data from purified cells and lack tissue-specific subtypes and disease states; bioconda has no SingleR build for macOS arm64
CellTypistPythonBuilt-in models (default Immune_All_Low); log1p matrix normalized to 10,000 counts per cellmajority_voting votes within clusters and gives stable resultsModels are mostly immune; for non-immune tissues choose the matching organ model
AzimuthR (Seurat) / web appReference atlases from the Seurat team (PBMC, lung, kidney, bone marrow and others), loaded from the web by defaultMulti-level labels; works directly with Seurat objectsOnly covers tissues with a reference; the R package is still v0.5.0
Manual markersBothMarkers from the literature and databases such as CellMarkerCan identify clusters missing from referencesDepends on experience; the same marker can mean different things in different tissues

Two routes

Both routes can do the same analysis. Above about 100,000 cells, or with deep learning tools such as scVI, the Python route uses less memory; choose Seurat when you need R packages such as SingleR, CellChat or Monocle.

For conversion use anndataR from scverse (in Bioconductor; latest GitHub release v1.3.2 on 2026-10-07; this page tested version 1.2.2 from bioconda). The SeuratDisk repository has had no commits since November 2023 and has 156 open issues; common problems include Convert filling X from scale.data first so raw counts are not transferred, and failures reading LZF-compressed h5ad files.

Tested on this page (anndataR 1.2.2): write_h5ad on a v5 object without JoinLayers does not fail; it only warns Skipping Layer "counts.A" with unexpected dimensions, and the resulting h5ad contains no expression matrix. After JoinLayers the file contains counts and data layers with X empty, so adata.X has to be set in Python. Reading an h5ad written by Scanpy turns X and the layers into Seurat layers and keeps the obs columns as well as X_pca and X_umap. anndataR does not convert varp or Seurat's Neighbors and Images. zellkonverter, which goes through SingleCellExperiment, is another workable route.

Converting between h5ad and Seuratr
# R: anndataR 1.2 (Bioconductor). rhdf5 is a separate install, otherwise:
#   Error: HDF5AnnData requires the rhdf5 package
# BiocManager::install(c("anndataR", "rhdf5"))
library(Seurat); library(anndataR)

obj <- read_h5ad("pbmc3k_processed.h5ad", as = "Seurat")   # h5ad -> Seurat: X and layers become layers, obsm -> reductions
obj <- JoinLayers(obj)                                       # split layers are skipped with a warning only
write_h5ad(obj, "from_seurat.h5ad")                          # Seurat -> h5ad: layers "counts" and "data", X left empty

# Python: X is empty after the Seurat -> h5ad direction, point it at a layer
import anndata as ad
adata = ad.read_h5ad("from_seurat.h5ad")
adata.X = adata.layers["counts"].copy()                      # or layers["data"] for log-normalized values
StepSeurat v5 (R)Scanpy 1.12 (Python)
ReadRead10X / Read10X_h5sc.read_10x_mtx / sc.read_10x_h5
QC metricsPercentageFeatureSet(pattern = "^MT-")sc.pp.calculate_qc_metrics(qc_vars=["mt"])
DoubletsscDblFinder, DoubletFindersc.pp.scrublet
NormalizationNormalizeData / SCTransformsc.pp.normalize_total + sc.pp.log1p
Highly variable genesFindVariableFeatures(nfeatures = 2000)sc.pp.highly_variable_genes(n_top_genes=2000)
IntegrationIntegrateLayers (CCA, RPCA, Harmony, FastMNN, scVI)harmonypy, scVI, Scanorama
ClusteringFindNeighbors + FindClusters (Louvain by default)sc.pp.neighbors + sc.tl.leiden
MarkersFindAllMarkerssc.tl.rank_genes_groups
Automatic annotationSingleR, AzimuthCellTypist
Large dataBPCells on disk + SketchDataread_lazy, backed mode, Dask
Storage.rds / .qs.h5ad / .zarr

Resources

The memory bottleneck in single-cell analysis is dense matrices. Count matrices are sparse; once ScaleData or sc.pp.scale is applied to all genes, it creates a dense matrix of cells × genes.

Tested on this page: the filtered PBMC 3k sparse matrix takes 16.8 MB; the same matrix stored densely (2,607 × 13,607 × 8 bytes) takes 270.6 MB, 16 times more. At that ratio, scaling all genes for 100,000 cells × 20,000 genes needs about 16 GB for that one matrix alone.

When memory runs out, try these in order: run ScaleData on HVGs only (the Seurat default; do not pass features = rownames(obj)); delete scale.data and SCT assays you no longer need before saving; in Seurat keep counts on disk with BPCells, analyze a 50,000-cell sketch with SketchData and project back to all cells with ProjectData; in Python use anndata's read_lazy or backed mode and load only the subset you need.

If R reports vector memory limit of 16.0 Gb reached on macOS, set R_MAX_VSIZE=32Gb in ~/.Renviron. Multithreaded Seurat also hits the future.globals.maxSize limit; for large datasets set options(future.globals.maxSize = 4e9) or higher as in the official vignette.

Large datasets: BPCells + sketch (R) and read_lazy (Python)r
# Seurat v5 + BPCells: counts stay on disk, analyze a 50,000-cell sketch, project back
library(Seurat); library(BPCells)
options(future.globals.maxSize = 4e9)
mat <- open_matrix_10x_hdf5("filtered_feature_bc_matrix.h5")
write_matrix_dir(mat, dir = "bpcells_counts")              # one-time conversion to the on-disk format
obj <- CreateSeuratObject(open_matrix_dir("bpcells_counts"))
obj <- NormalizeData(obj); obj <- FindVariableFeatures(obj)
obj <- SketchData(obj, ncells = 50000, method = "LeverageScore", sketched.assay = "sketch")
DefaultAssay(obj) <- "sketch"
obj <- FindVariableFeatures(obj); obj <- ScaleData(obj); obj <- RunPCA(obj)
obj <- FindNeighbors(obj, dims = 1:50); obj <- FindClusters(obj, resolution = 1)
obj <- RunUMAP(obj, dims = 1:50, return.model = TRUE)
obj <- ProjectData(obj, assay = "RNA", full.reduction = "pca.full", sketched.assay = "sketch",
                   sketched.reduction = "pca", umap.model = "umap", dims = 1:50,
                   refdata = list(cluster_full = "seurat_clusters"))

# Python side: open .h5ad without loading X (anndata >= 0.12)
import anndata as ad
a = ad.experimental.read_lazy("big.h5ad")   # lazy obs/var/X; subset first, then .to_memory()
a = ad.read_h5ad("big.h5ad", backed="r")    # older backed mode: read-only X on disk
ScaleMemory (measured or reported)SourceSuggested machine
2,600 cells (PBMC 3k)Scanpy full script peak 0.98 GB; Seurat full script (including SCTransform and integration tests) peak 3.86 GB, Seurat object 100 MBTested on this pageAny laptop
27,000 cells (PBMC 3k tiled 10 times)Scanpy scaling HVGs only: peak 2.64 GB; scaling all 13,607 genes: peak 4.03 GBTested on this page (duplicated cells, memory only)16 GB RAM
81,000 cells (tiled 30 times)Scanpy scaling HVGs only: peak 4.42 GBTested on this page32 GB RAM
300,000 cells, basic workflowSeurat about 40 GB, Scanpy about 10 GBMeasured by Biomamba (CSDN)64–128 GB, or switch to Scanpy or BPCells
1.3 million cells (BPCells on disk)Seurat object about 596 MB, analysis on a 50,000-cell sketchSeurat sketch vignetteDepends on sketch size; 32–64 GB is workable

Tested on this page

Tested on this page: macOS arm64 (8 cores, 16 GB), Scanpy 1.12.4 / anndata 0.13.4 / Python 3.12, Seurat 5.5.1 / SeuratObject 5.4.0 / R 4.5, 10x PBMC 3k (2,700 cells). Other jobs were running on the same machine during the test, so run times indicate order of magnitude only.

ItemScanpySeurat
Read2,700 cells × 32,738 genes2,700 × 13,714 after CreateSeuratObject(min.cells = 3, min.features = 200)
QCFixed thresholds keep 2,638; MAD rule keeps 2,596Fixed thresholds keep 2,638
DoubletsScrublet 32 (1.2%)scDblFinder 124 (4.6%)
Input to clustering2,607 cells × 13,607 genes (fixed thresholds + Scrublet doublets removed)2,638 cells
Code blocks on this page (MAD + doublet removal)2,567 cells; 30 PCs, res 0.6: 7 clusters (no platelet cluster)2,564 cells; dims 1:20, res 0.6: 9 clusters
Clustering30 PCs, Leiden res 0.6: 8 clustersdims 1:10, res 0.5: 9 clusters
AnnotationCellTypist voted labels agree with markers (8 of 8 clusters)FindAllMarkers 11.5 s (without presto)
Run timeWhole script 186 s, of which Scrublet and QC 114 s (including first-time numba compilation)Core workflow 15.9 s; SCTransform v2 106.7 s
Peak memory0.98 GB3.86 GB (including SCTransform and integration tests)
  • Errors reproduced on Seurat 5.5.1: GetAssayData(slot = ) is defunct; @counts does not exist; GetAssayData and FindMarkers fail with multiple layers; IntegrateLayers without split fails with attempt to set an attribute on NULL; re-integrating a subcluster fails with the same error.
  • RunUMAP in Seurat 5.5.1 uses R's uwot with the cosine metric by default, so results will not match old tutorials that called Python umap-learn through reticulate.
  • SingleR was not run on this machine: bioconda has no bioconductor-singler build for macOS arm64; only function and argument names were checked.
  • CellBender and scVI need a GPU or long CPU time, and BPCells was not installed on this machine; for these three only argument names and documentation were checked.
  • anndataR 1.2.2 was run in both directions: rhdf5 has to be installed separately, and h5ad files written without JoinLayers contain no expression matrix.

Chinese community

The items below come from CSDN and Zhihu columns. Only posts with original error messages or the author's own measurements, consistent with official documentation or this page's tests, are included.

Re-integrating a subcluster fails with "NULL是不能有属性的"

At least two CSDN authors (July and August 2024) recorded the same error: in a Chinese locale it reads 错误于names(groups) <- "group": NULL是不能有属性的, in English attempt to set an attribute on NULL; one of them hit it when integrating an extracted immune subcluster with Harmony. The cause is that the layers are joined, and the fix, given in Seurat GitHub Discussion #9045, is to split first. This page reproduced the error and the fix on Seurat 5.5.1.

A few samples do not always need batch correction

A CSDN author compared no integration, SCTransform and Harmony on four mouse prefrontal cortex samples; without integration the samples already mixed well, and the conclusion was to look at the data before deciding. This matches Seurat's advice to compare against the unintegrated result.

Memory measured at 300,000 cells

Biomamba on CSDN (December 2024) measured the basic workflow (normalization to UMAP) on 25,000 to 300,000 cells: at 300,000 cells Seurat used about 40 GB and Scanpy about 10 GB; the post also notes that monocle2 pseudotime on 50,000 cells can peak above 300 GB. The author runs a server rental business; the numbers come with test scripts and are useful as an order of magnitude.

Memory saved by sparse matrices

The Zhihu column post 单细胞分析 | Seurat基础流程 | 保姆级教程 measured the raw PBMC 3k matrix at 709.6 MB dense and 29.9 MB sparse, a factor of 23.7. This page measured a factor of 16 on the filtered matrix. Both show that memory problems come mainly from steps that make the matrix dense.

Hand it to an agent

You can describe the analysis in one sentence and let Scientify's research agent run it in a cloud computer.

Example instruction: "Analyze the six 10x samples in data/ (3 control, 3 treated): QC each sample with the MAD rule and run scDblFinder, show the unintegrated UMAP, integrate with Harmony and scVI and compare them; choose the resolution with clustree and multi-seed ARI; annotate with CellTypist and marker dot plots, and output cell-type proportions and treated-versus-control differential genes for every cluster."

In a cloud computer with Scanpy preinstalled, the agent installs the other packages it needs, runs QC and doublet detection per sample and records how many cells each step removes; after integration, clustering and annotation it outputs UMAPs, the clustree plot, marker dot plots, a proportion table and differential gene tables. When scVI needs a GPU it rents one for the task. The workspace keeps scripts, parameters, logs and h5ad files, so the run can be reproduced; the agent reviews its results adversarially, for example checking whether a treatment-specific cluster disappeared after integration and whether each annotated cluster has consistent markers. The task keeps running after you shut down your computer.

You still need to check: whether the QC thresholds suit your tissue, whether integration erased the biological differences you care about, whether annotation labels agree with the tissue context and the literature, and whether differential analysis uses samples, not cells, as replicates.

Sources

FAQ

What mitochondrial threshold should I use?

There is no universal value. For each sample compute the median plus 3 MAD of the mitochondrial fraction and combine it with a fixed floor, taking the more lenient of the two: 10% is common for human tissue and 5% for mouse (Osorio and Cai 2021), and Single-cell Best Practices uses 8%. For cell types with naturally high mitochondrial content such as cardiomyocytes or renal tubules, judge together with UMI counts and the clustering; for single-nucleus data the cap is usually 1%–5%.

Seurat v4 code fails on v5. What should I change?

Replace slot = with layer =, and obj@assays$RNA@counts with obj[["RNA"]]$counts or LayerData(). Run JoinLayers on multi-sample objects before GetAssayData and FindMarkers, and split by sample before IntegrateLayers. The v4 integration workflow of SplitObject plus IntegrateData becomes split plus IntegrateLayers.

How do I choose the clustering resolution?

Scan a range (for example 0.2 to 1.5), use clustree to see at which resolution a cluster starts receiving arrows from several parent clusters, and compare results across random seeds with ARI. Pick the lowest stable resolution at which every cluster has clear markers; for subtypes, recluster large clusters on their own. On PBMC 3k in this page the stable range is 0.4–0.6.

How does Harmony relate to Seurat's IntegrateLayers?

IntegrateLayers is the unified interface of Seurat v5; with method = HarmonyIntegration it calls the harmony package. You can also call RunHarmony(obj, group.by.vars = "sample") directly. Both only create a new embedding and leave the expression matrix unchanged.

How much server memory does single-cell analysis need?

Estimate from the number of cells. Up to 10,000 cells a 16 GB laptop is enough; for 50,000 to 100,000 cells 64 GB is advisable; at 300,000 cells the basic workflow takes about 40 GB in Seurat and about 10 GB in Scanpy; for larger datasets use BPCells with sketching or lazy reading in Scanpy. Downstream analyses such as pseudotime or cell communication can need far more memory than the basic workflow.

How do I convert a Seurat object to h5ad?

Install anndataR and rhdf5, run JoinLayers, then write_h5ad(obj, "out.h5ad"); the written file keeps expression in layers["counts"] and layers["data"] with X empty, so set adata.X after reading it in Python. For the other direction use read_h5ad("x.h5ad", as = "Seurat"). SeuratDisk has had no commits since November 2023.

Hand your single-cell analysis to Scientify

The research agent runs this workflow in an isolated cloud computer with Scanpy preinstalled: per-sample QC and doublet detection, comparison of integration methods, resolution selection, annotation and differential analysis. It rents a GPU when needed and keeps every script, parameter and log. New users get $5 of free credit.