Sanity check · Spatial Transcriptomics
How to Sanity-Check Marker Genes and Cell Type Labels in Spatial Transcriptomics
FindMarkers and rank_genes_groups will happily hand you ribosomal genes and dissociation stress as your top "cell type marker", here is the checklist that catches it before it reaches a figure legend.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 5 min read
You clustered your Visium or Xenium data, ran FindAllMarkers() or sc.tl.rank_genes_groups(), and now you have a table of top genes per cluster sitting on your screen. The next step feels obvious: eyeball the top few genes, match them to a cell type you recognize, and write "Fibroblast" or "Tumor epithelium" in the cluster label. That step is where most spatial marker calls quietly go wrong.
FindMarkers and rank_genes_groups are purely statistical. They return whatever genes differ most between one group and the rest, with no concept of biology attached. In dissociated single-cell data that bias already produces ribosomal, mitochondrial and stress-response genes at the top of marker lists often enough to be a known failure mode. In spatial data it gets worse, because a Visium spot is not a cell, it is a 55-micron patch of tissue holding one to ten cells, and because the reference atlases and companion scRNA-seq datasets used to name spatial clusters carry their own processing artifacts into your labels.
This page gives you an ordered set of checks to run on a marker table before you trust it, cheapest first, plus the fixes for the failure modes you'll actually hit. Budget the next hour to run checks 1 through 6 on your current cluster set; save the database cross-check and deconvolution step for the clusters that survive.
What it looks like when it's happening
- Top of a cluster's marker table is dominated by RPS/RPL genes or MT-/mt- genes instead of anything you could name a cell type after
- FOS, JUN, or heat-shock genes (HSPA1A, DNAJB1) sit near the top of every cluster's marker list, not just one, suggesting a shared artifact rather than distinct biology
- VlnPlot for your candidate marker shows most cells or spots at zero with a long right tail from a handful of outliers driving the mean
- The gene that supposedly defines a cluster looks like random salt-and-pepper noise, not a coherent region, when plotted back onto the tissue image with spatial_scatter or SpatialFeaturePlot
- Two clusters that look histologically identical on the H&E image get different top markers, and the difference tracks ribosomal or mitochondrial fraction rather than anything you can name
- A cluster's top 'marker' is MALAT1, which is a nuclear lncRNA QC signal for spot/cell quality, not a cell-identity gene
- Your marker list returns no hits, or only generic hits, when checked against CellMarker or PanglaoDB for the tissue you're working with
- P-values from FindMarkers or rank_genes_groups look absurdly small, like 1e-200, for nearly every gene in a cluster
Why it happens
A Visium spot is a mixture, not a cell. It covers one to ten cells, so the expression profile clustering algorithms compare is already an average across whatever cell types happen to co-occur in that patch of tissue. When FindMarkers/rank_genes_groups looks for genes that separate one cluster's average profile from another's, the genes that win are often whichever cell type is more abundant in the mix, or genes that are simply the most highly expressed transcripts in nearly every cell type present. Ribosomal protein genes and mitochondrial genes fall into that second category: they carry large absolute counts everywhere, so small proportional differences between clusters translate into large fold changes and tiny p-values under a Wilcoxon or t-test run over thousands of cells or spots, even though the difference is a depth or composition artifact, not a marker of identity.
Dissociation-stress genes get into spatial analysis by a less direct route. Visium and Xenium sections are not dissociated, so FOS, JUN and heat-shock genes are not being directly induced in your spots the way they are in scRNA-seq prepared with warm collagenase digestion at 37°C. But the reference data you use to name spatial clusters usually was dissociated: a public scRNA-seq atlas you're pulling markers from, or a matched scRNA-seq run on the same specimen that you're using for label transfer or deconvolution. If that reference was digested warm, its own marker lists carry the stress signature, and that signature rides straight into your spatial cluster names the moment you borrow its markers or run RCTD/Cell2location against it.
There's a statistical mechanism underneath both problems. Cells or spots within a cluster are not independent observations, they come from the same tissue section, the same patient, often the same local neighborhood, so a cell-level Wilcoxon or t-test massively understates the true variance and inflates significance. This is why marker p-values from single-cell-resolution tests routinely look implausible; pseudobulking to the sample level before running a proper DE tool like PyDESeq2 gives a much more honest picture, at the cost of needing multiple biological replicates.
Finally, remember that any RNA-seq measurement, spatial or not, is a single snapshot of a process running on a molecular clock: transcription runs at roughly 10-100 nucleotides per second and translation at roughly 10 amino acids per second. A gene that lights up as a top marker may reflect a transient state, stress from the exact moment of tissue processing, a cell-cycle phase, a local hypoxia gradient, captured at the instant of the assay rather than a stable identity you can build a cell-type label on.
The checks
Run them in order. Each one tells you what healthy looks like and what the problem looks like.
0/8 checked · saved in this browser
Take the top 15-20 genes per cluster from your FindMarkers/rank_genes_groups output and flag anything starting with RPS/RPL (ribosomal), MT-/mt- (mitochondrial), or matching FOS, JUN, HSPA1A, HSPA1B, DNAJB1 (dissociation stress). This takes under a minute and catches the most common failure before you invest in anything downstream.
pythonjunk = adata.uns['rank_genes_groups'] import pandas as pd df = pd.DataFrame(junk['names']).head(20) for cl in df.columns: flagged = [g for g in df[cl] if g.startswith(('RPS','RPL','MT-','mt-')) or g in ('FOS','JUN','HSPA1A','HSPA1B','DNAJB1')] print(cl, flagged)- Healthy
- Top 15-20 genes per cluster are receptor, enzyme, transcription factor or structural genes tied to known cell biology (e.g. CD3E for T cells, KRT8 for epithelium), with at most one or two incidental ribosomal/mito hits.
- Red flag
- Three or more of the top 10 genes for a cluster are RPS/RPL, MT-/mt-, or stress-response genes.
Run scanpy's filter_rank_genes_groups (or the Seurat equivalent of manually filtering on pct.1/pct.2) to require genes be expressed in a minimum fraction of the target cluster, have a minimum fold change, and stay below a maximum expression fraction in the rest of the data.
pythonsc.tl.filter_rank_genes_groups( adata, min_in_group_fraction=0.25, min_fold_change=1, max_out_group_fraction=0.5, )- Healthy
- The surviving marker list per cluster is noticeably shorter and cleaner, dominated by genes with obvious cell-type relevance.
- Red flag
- Almost nothing survives the filter for a given cluster (no gene clears a 25% in-group / 50% out-group specificity bar, meaning the cluster likely isn't a real, distinct population), or the list is unchanged from the raw output, meaning ribosomal/housekeeping genes were already passing on fold-change alone.
Violin-plot each candidate marker split by cluster and look at the shape of the distribution within the cluster itself, not just the difference in means.
rVlnPlot(seurat_obj, features = c("CD3E", "CD8A"), group.by = "seurat_clusters")- Healthy
- Most cells or spots in the cluster sit clearly above background for the marker, and the marker stays near zero in other clusters.
- Red flag
- A bimodal violin where most of the cluster sits at zero and a handful of cells/spots with very high expression are pulling the mean up: the gene is 'significant' because of outliers, not because it defines the cluster.
Plot the candidate marker's expression directly onto the tissue coordinates and compare against the H&E histology or your annotated regions.
pythonsq.pl.spatial_scatter(adata, color="GENE_NAME")- Healthy
- Expression concentrates in an anatomically sensible region that matches the histology, e.g. an epithelial layer, a stromal band, or an immune aggregate.
- Red flag
- Expression looks like random salt-and-pepper noise across the section, tracks tissue edges or fold artifacts, or is diffusely uniform everywhere, none of which is consistent with a spatially organized cell-identity marker.
Correlate the candidate marker's expression per spot or cell against total UMI counts (or total genes detected) for that spot/cell.
- Healthy
- Weak or no correlation between the marker and total counts across the dataset; a true identity marker should decouple from sequencing depth.
- Red flag
- The marker's expression pattern across the tissue matches the total-count or total-gene heatmap almost exactly, you're looking at a depth artifact, not biology.
Compute ribosomal gene fraction and MALAT1 expression per spot/cell and compare distributions across clusters rather than just eyeballing single genes in the marker table.
pythonadata.obs['ribosomal_fraction'] = ( adata[:, adata.var_names.str.startswith(('RPS', 'RPL'))].X.sum(axis=1) / adata.X.sum(axis=1) )- Healthy
- Ribosomal fraction is roughly comparable across clusters (allowing for genuinely protein-synthesis-heavy cell types), and MALAT1 is uniformly high (above roughly 1% of the median) in spots/cells you intend to call real biology on.
- Red flag
- A 'cluster' turns out to be defined almost entirely by high mitochondrial fraction or low MALAT1, that's a damaged-cell or empty-spot QC bucket, not a cell type.
For each marker that survives the checks above, look it up in CellMarker (about 13,600 human markers across 467 cell types) or PanglaoDB (over 6,000 gene-cell-type associations) for the tissue you're working with, and require at least two independent markers to agree on the same identity.
- Healthy
- Markers are found in the database, tied to a cell type that fits the tissue, and at least two markers per cluster point to the same identity.
- Red flag
- The marker is absent from the database, or is listed as expressed broadly across many unrelated cell types (i.e. it's not specific enough to be a real identity marker).
For Visium or Slide-seq data, run a reference-based deconvolution method (RCTD, Cell2location, SPOTlight, Stereoscope or DestVI) against a matched scRNA-seq reference, and compare the predicted per-spot cell-type proportions to what your cluster marker calls implied.
- Healthy
- Spots assigned to a given cluster show one dominant deconvolved cell type that matches the marker-based call.
- Red flag
- Deconvolution shows the 'cluster' is actually a roughly even mixture of two or three cell types, you clustered a mixture region and gave it a single-cell-type name.
What to do about it
Filter marker output computationally before eyeballing it
When: Top markers for a cluster are visibly dominated by ribosomal, mitochondrial, or stress genes and you want a clean shortlist without hand-curating every cluster.
Run scanpy's filter_rank_genes_groups with min_in_group_fraction, min_fold_change, and max_out_group_fraction thresholds (0.25/1/0.5 are the documented defaults) on the raw rank_genes_groups output before you look at the table.
Caveat: Default thresholds are somewhat arbitrary. A real but sparsely detected marker (common with spot dropout or a shallow targeted panel) can get filtered out at a 25% in-group fraction; loosen it for sparse data rather than trusting the defaults blindly.
Require three lines of evidence before naming a cluster
When: A marker clears the statistical test but the violin plot is bimodal, the spatial plot is speckled, or the database check comes back generic.
Only accept a marker-based label once it clears expression-distribution (VlnPlot), spatial coherence (spatial_scatter/SpatialFeaturePlot), and database confirmation together, not any single one in isolation.
Caveat: Slower and manual. Build a small script that runs all three checks per cluster automatically once you have more than a handful of clusters to review.
Deconvolve spot data instead of clustering-then-labeling
When: Working with Visium or Slide-seq, where each spot genuinely contains one to ten cells and a cluster is by construction a mixture.
Run RCTD or Cell2location against a matched scRNA-seq reference and report continuous per-spot cell-type proportions rather than forcing a single hard label per cluster.
Caveat: Needs a good matched reference. A reference from the wrong tissue, wrong disease state, or the wrong dissociation protocol produces confident-looking but wrong proportions.
Rebuild the scRNA-seq reference with cold dissociation if you control it
When: You're generating your own scRNA-seq reference for label transfer or deconvolution, and stress genes (FOS, JUN, heat-shock genes) dominate that reference's own cluster markers.
Switch to cold active protease dissociation at around 6°C rather than warm collagenase at 37°C, which is documented to substantially reduce the conserved stress signature.
Caveat: Not possible for archival or public reference atlases you don't control. If you're stuck with a warm-dissociated public reference, at minimum flag and downweight its stress genes before using it for labeling.
Pseudobulk before trusting cluster-level p-values
When: P-values from FindMarkers or rank_genes_groups look implausibly small (near-zero) for most genes in a cluster, a sign that cell-level non-independence is inflating significance.
Aggregate raw counts to the sample or section level per cluster, then run a proper bulk DE tool such as PyDESeq2 instead of testing at the cell/spot level.
Caveat: Requires enough biological replicates (multiple sections or patients per condition); doesn't work with a single tissue section, where you have no independent replication to pseudobulk over.
When not to "fix" it
Not every ribosomal, mitochondrial, or stress-gene hit is an artifact to strip out. Plasma cells, hepatocytes, and other genuinely protein-synthesis-heavy cell types legitimately carry high ribosomal gene expression as real biology, not contamination, filtering it out just because it's a ribosomal gene will erase a correct marker. Similarly, a tumor's necrotic or hypoxic core, or tissue from an ischemic or treated specimen, can legitimately show elevated mitochondrial and stress transcripts because the tissue itself is under stress, not because of how it was processed; in that case the "artifact" is the finding. And if you're doing spatial domain or niche analysis rather than strict cell typing, a marker that is spatially coherent but fails a cell-type specificity or database check may still be exactly the signal you want, cell-type-specific spatially variable genes identified through dedicated spatial methods routinely surface context-specific markers that general-purpose databases like CellMarker or PanglaoDB simply don't contain.
Five things experienced analysts do here
- Read every marker table with the gene-family flags on. Before trusting the top-N list for a cluster, check it against RP[SL], MT-/mt-, and the FOS/JUN/HSP family first, it takes thirty seconds and catches most naive labeling mistakes before they cost you an hour of database searching.
- Never call a cell type off one gene. Require at least two concordant markers plus a database hit before a label goes in a figure legend; a single gene, however significant, is a hypothesis, not an identification.
- Treat a p-value from FindMarkers/rank_genes_groups as a starting point, not a conclusion. Always run the VlnPlot and spatial plot before you write a cell-type name anywhere a reviewer or pathologist will see it.
- Know your platform's actual resolution before you use cell-type language. Visium and Slide-seq spots are mixtures of one to ten cells, call them regions or domains until you've deconvolved them; Xenium, MERFISH, and CosMx approach single-cell resolution but still depend on segmentation quality you should check separately.
- Track the dissociation protocol behind any reference you borrow markers or labels from. A public scRNA-seq atlas or companion dataset built with warm collagenase digestion will hand you stress genes baked into its own cluster markers, and those ride straight into your spatial labels via label transfer or deconvolution.
Questions people ask
- Why are ribosomal genes (RPS, RPL) showing up as top markers in my spatial clusters?
Ribosomal protein genes carry large absolute counts in nearly every cell type, so small proportional differences between clusters translate into large fold changes and very small p-values under a Wilcoxon or t-test run over thousands of cells or spots. Filter with scanpy's filter_rank_genes_groups (min_in_group_fraction, min_fold_change, max_out_group_fraction), or check ribosomal fraction per cluster directly before trusting the raw marker table.
- Is FOS/JUN expression in my spatial data a dissociation artifact if I never dissociated the tissue?
Visium and Xenium sections aren't dissociated, so the stress induction from warm collagenase digestion doesn't happen directly to your spots. But if the scRNA-seq reference or database you're using to name clusters was built from dissociated cells digested warm, its stress signature can still leak into your spatial labels through label transfer or deconvolution.
- How many marker genes do I need before I trust a cell-type label?
At least two concordant markers that also pass a specificity filter, show spatial coherence on the tissue image, and match a curated database like CellMarker or PanglaoDB for the tissue you're working with. A single significant gene from FindMarkers is a hypothesis, not confirmation.
- Does FindAllMarkers with only.pos=TRUE fix the ribosomal/stress gene problem?
No. only.pos=TRUE just removes downregulated genes from the output for readability; ribosomal and housekeeping genes that are upregulated by fold-change alone will still pass. You still need a specificity filter (like filter_rank_genes_groups' thresholds) on top of it.
- Does marker validation differ between Visium spots and Xenium cells?
Yes. Visium spots contain one to ten cells, so a cluster is a mixture by construction and needs deconvolution (RCTD, Cell2location, and similar) before you can trust a single cell-type label per spot. Xenium, MERFISH, and CosMx approach single-cell resolution, but their targeted gene panels of a few hundred to a few thousand genes mean a marker you'd expect from a whole-transcriptome reference may simply not be on the panel.
Related pages
- Guide · How to Tell If You Overclustered in Spatial Transcriptomics
- Guide · How to Detect Integration Over-Correction in Spatial Transcriptomics
- Guide · How to Log-Transform Counts Without Fooling Yourself in Single-Cell RNA-seq
- Compare · Squidpy vs Seurat: Which One Should You Use?
- Glossary · Pseudobulk
- Glossary · Spatial transcriptomics
Related reading on the blog
Sources
- Exploring Spatial Transcriptomics A Dive into Visium Data Analysis in Python — Spot-mixture reasoning and spatial coherence validation via spatial_scatter
- Scanpy filter_rank_genes_groups documentation — Specificity filter thresholds (min_in_group_fraction, min_fold_change, max_out_group_fraction)
- Scanpy rank_genes_groups documentation — Statistical test options and the case for pseudobulking with PyDESeq2
- Seurat Guided Clustering Tutorial — FindAllMarkers, VlnPlot/FeaturePlot/DoHeatmap validation, and ROC classification power
- Dissociation of solid tumor tissues with cold active protease for single-cell RNA-seq minimizes conserved collagenase-associated stress responses — Cold active protease dissociation fix for dissociation-stress gene artifacts
- Fully-automated and ultra-fast cell-type identification using specific marker combinations from single-cell transcriptomic data — CellMarker and PanglaoDB as curated marker reference databases
- Identifying cell-type-specific spatially variable genes with ctSVG — Context-specific spatial markers missed by general-purpose databases
- Gut mucosa dissociation protocols influence cell type proportions and single-cell gene expression levels — Epithelial cells' sensitivity to dissociation artifacts
Part of the Marker gene sanity series.