Spatial transcriptomics · complete workflow · best practices · Squidpy · publication figures
Putting It All Together
This series has covered spatial transcriptomics from first principles (Squidpy post 1) through advanced methods — cell2location, SpatialDE2, Tangram, BANKSY, ligand-receptor analysis, trajectory analysis, and platform comparisons. This final post assembles them into a decision-driven workflow you can apply to any new Visium dataset.
The goal is not to run every tool — it is to know which tools answer which questions, in what order, and at what computational cost.
Figure 1. Left: complete spatial analysis pipeline from raw data to publication figures. Right: approximate runtime and peak memory per tool on a standard 8-core laptop with a 3,500-spot Visium dataset.
Phase 1: Data Loading and QC (30 minutes)
Every analysis starts here. Do not skip the QC plots — spatial data has tissue-specific artifacts (necrotic regions, edge effects, adipose tissue with near-zero counts) that will distort downstream results if not filtered.
import squidpy as sq
import scanpy as sc
import matplotlib.pyplot as plt
adata = sq.read.visium("spaceranger_output/", library_id="sample_01")
# QC metrics
sc.pp.calculate_qc_metrics(adata, inplace=True)
sq.pl.spatial_scatter(adata, color=["total_counts", "n_genes_by_counts"], ncols=2)
# Filter: remove low-count spots (necrosis, edge artifacts)
sc.pp.filter_cells(adata, min_counts=500)
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=10)
# Normalize
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=3000, flavor="seurat_v3")
# Save raw counts for cell2location
adata.layers["counts"] = adata.X.copy()
adata.raw = adata
Phase 2: Clustering and Annotation (1–2 hours)
Build spatial graph first, then standard dimensionality reduction. The spatial graph is required for neighborhood enrichment and LR analysis later.
sq.gr.spatial_neighbors(adata, coord_type="grid", n_rings=2)
sc.tl.pca(adata, use_highly_variable=True)
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=30)
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=0.3)
# Inspect: do clusters correspond to tissue morphology?
sq.pl.spatial_scatter(adata, color="leiden", size=1.5)
sc.pl.umap(adata, color="leiden")
# Annotate clusters with known markers
marker_genes = {
"Cancer": ["EPCAM", "KRT8", "MKI67"],
"T cells": ["CD3E", "CD8A", "CD4"],
"Macrophages": ["CD68", "CD163", "SPP1"],
"Fibroblasts": ["COL1A1", "FAP", "ACTA2"],
"Endothelial": ["PECAM1", "VWF", "CDH5"],
}
sc.pl.dotplot(adata, marker_genes, groupby="leiden")
Phase 3: Choose Your Analysis Path
After annotation, your research question determines what to run next. Use this decision tree:
Q: What cell types are in each spot, at single-cell resolution?
→ cell2location (Post 12) — requires matched scRNA-seq reference
Q: Which genes show non-random spatial patterns?
→ SpatialDE2 (Post 14) or sq.gr.spatial_autocorr for a faster first pass
Q: Are cell types communicating via known ligand-receptor pairs?
→ sq.gr.nhood_enrichment + sq.gr.ligrec (Post 17)
Q: How does gene expression change across tissue regions continuously?
→ PAGA + DPT trajectory (Post 18)
Q: Are there spatial domains not captured by gene expression alone?
→ BANKSY (Post 16) — incorporates neighbor expression into clustering
Q: I have scRNA-seq from a different tissue; can I map those cells spatially?
→ Tangram (Post 15) — optimal transport mapping
Phase 4: Publication Figure Checklist
Before submitting, verify each figure against this checklist:
# 1. Spatial scatter — confirm spot size is set for the journal's figure width
sq.pl.spatial_scatter(adata, color="cell_type", size=1.2,
figsize=(6, 6), dpi=300, save="fig1a.pdf")
# 2. Color palettes — use perceptually uniform maps for continuous data
# Use categorical palettes with enough contrast for 8+ cell types
import matplotlib.pyplot as plt
# Bad: plt.cm.jet (never use in publications)
# Good: "plasma", "viridis", "RdBu_r" for continuous; "tab20" for categories
# 3. Scale bar — always include physical scale
# sq.pl.spatial_scatter adds it automatically from adata.uns["spatial"] metadata
# 4. Export at 300 dpi minimum for print
plt.savefig("figure1.pdf", dpi=300, bbox_inches="tight")
plt.savefig("figure1.png", dpi=300, bbox_inches="tight")
Compute Budget for a Full Analysis
From the benchmark figure, planning a full Visium TME analysis on a laptop (8 cores, 16 GB RAM):
- QC + clustering: ~30 min, <2 GB RAM
- cell2location (1000 epochs): ~45 min, 6 GB RAM — the bottleneck; use GPU if available
- SpatialDE2: ~8 min, 3.5 GB RAM
- sq.gr.ligrec (1000 perms): ~12 min, 2 GB RAM
- PAGA + DPT: ~5 min, 1.5 GB RAM
- Total: ~1.5–2 hours end-to-end
If you are running on a server with a GPU, cell2location drops to under 10 minutes and the full pipeline completes in well under an hour.
What This Series Has Not Covered
Spatial transcriptomics is a fast-moving field. Topics worth following as they mature:
- 3D spatial reconstruction — stacking serial sections with PASTE2 or Spateo
- Spatial proteomics integration — combining Visium with CODEX or IMC protein data
- Spatial multi-sample integration — harmonizing multiple patients/conditions with Harmony or scVI adapted for spatial
- Foundation models — Geneformer and scGPT adapted for spatial context are emerging
All code from this series is available on GitHub. The Squidpy Starter Pack ($19) contains runnable Jupyter notebooks for posts 1–5 with a real Visium PBMC dataset. For the immunotherapy biology behind the cell types you are mapping, see the sister blog NeoantigenLab.