Seurat Quality Control: Load & Filter a scRNA-Seq Dataset (pbmc3k)
Turn a raw 10x matrix into a clean Seurat object — inspect genes, counts and mitochondrial content per cell, then filter empty droplets and dying cells before you cluster
Bioinformatics
A practical guide to quality control for single-cell RNA-seq in R with Seurat. Load the canonical pbmc3k 10x dataset with Read10X, build a Seurat object, and compute the three QC metrics every analysis screens on — genes per cell (nFeature_RNA), counts per cell (nCount_RNA), and mitochondrial percentage (percent.mt). Visualize them with violin and scatter plots, choose sensible filter thresholds with a plain-language rationale, and subset to a clean set of cells ready for normalization and clustering.
Published
June 29, 2026
Modified
July 7, 2026
TipKey takeaways
A 10x run gives you a gene × cell count matrix, but not every column is a real cell — empty droplets, doublets, and dying cells are mixed in. Quality control removes them before you analyze.
Load the matrix with Read10X() and build a Seurat object with CreateSeuratObject(), which already drops the emptiest droplets (min.features) and never-detected genes (min.cells).
Screen every cell on three QC metrics: genes per cell (nFeature_RNA), counts per cell (nCount_RNA), and the mitochondrial fraction (percent.mt, computed with PercentageFeatureSet).
Look before you cut: a VlnPlot of the three metrics plus a FeatureScatter of how they relate tell you where the real cells end and the junk begins — thresholds are read off the distribution, not memorized.
Filter with subset(), then report what you kept. On pbmc3k, nFeature_RNA 200–2,500 and percent.mt < 5% keeps 2,638 of 2,700 cells.
Introduction
Your 10x Genomics single-cell run comes back as a sparse gene × cell count matrix — thousands of columns, one per cell barcode. But a barcode is not the same as a cell. Some droplets were empty and captured only ambient RNA; some caught two cells at once (a doublet); some cells were already dying when they were encapsulated, leaking cytoplasmic mRNA and leaving behind a tell-tale spike of mitochondrial transcripts. Feed that mix straight into clustering and you get phantom cell types and smeared boundaries.
Quality control is the first thing you do, and it is simple: measure a few properties of every cell, look at their distributions, and drop the cells that sit in the junk tails. This lesson does exactly that on pbmc3k — the canonical 2,700-cell dataset of peripheral blood mononuclear cells that the Seurat tutorial is built on — so the workflow transfers directly to your own 10x data.
Load the data into a Seurat object
The 10x output is three files in one folder: the sparse matrix (matrix.mtx.gz), the cell barcodes (barcodes.tsv.gz), and the gene names (features.tsv.gz). Read10X() reads the folder and returns the count matrix; CreateSeuratObject() wraps it in the object every downstream step works on.
library(Seurat)# Read the 10x folder -> a sparse genes x cells count matrixpbmc.data <-Read10X("_data/filtered_gene_bc_matrices/hg19")dim(pbmc.data) # genes x cells, before any filtering
[1] 32738 2700
# Build the Seurat object: drop genes seen in < 3 cells and cells with < 200 genespbmc <-CreateSeuratObject(pbmc.data, project ="pbmc3k",min.cells =3, min.features =200)pbmc
An object of class Seurat
13714 features across 2700 samples within 1 assay
Active assay: RNA (13714 features, 0 variable features)
1 layer present: counts
The raw matrix has 32,738 genes across 2,700 cells. The two constructor arguments do a first, cheap pass of cleanup: min.cells = 3 removes genes detected in fewer than three cells (they carry no usable signal and only bloat the matrix), and min.features = 200 removes droplets expressing fewer than 200 genes (almost certainly empty). That leaves 13,714 genes across 2,700 cells — the same 2,700 here because the filtered 10x matrix was already barcode-called, but on raw output min.features alone can drop thousands of empty droplets.
The three QC metrics
CreateSeuratObject() automatically stores two metrics for every cell: nCount_RNA (total counts, the library size) and nFeature_RNA (number of genes detected). The third — the mitochondrial percentage — you add yourself. A high mitochondrial fraction is the classic signature of a stressed or dying cell: as the membrane breaks down, cytoplasmic mRNA leaks out while the mitochondrial transcripts stay trapped, so their share of the total climbs.
PercentageFeatureSet() computes it from the gene names. In this human dataset the mitochondrial genes all start with MT-, so a "^MT-" pattern matches them.
library(Seurat)pbmc.data <-Read10X("_data/filtered_gene_bc_matrices/hg19")pbmc <-CreateSeuratObject(pbmc.data, project ="pbmc3k",min.cells =3, min.features =200)# Fraction of counts from mitochondrial genes (human: gene symbols start with "MT-")pbmc[["percent.mt"]] <-PercentageFeatureSet(pbmc, pattern ="^MT-")head(pbmc@meta.data) # the per-cell QC table
All three metrics now live in pbmc@meta.data, one row per cell. Summarize them before plotting — the numbers tell you what “normal” looks like for this dataset:
nFeature_RNA nCount_RNA percent.mt
Min. : 212.0 Min. : 546 Min. : 0.000
1st Qu.: 690.0 1st Qu.: 1756 1st Qu.: 1.537
Median : 816.0 Median : 2196 Median : 2.031
Mean : 845.5 Mean : 2365 Mean : 2.217
3rd Qu.: 952.0 3rd Qu.: 2762 3rd Qu.: 2.643
Max. :3400.0 Max. :15818 Max. :22.569
The median cell expresses 816 genes from 2,196 counts, with a mitochondrial fraction around 2%. But the maxima — 3,400 genes, 15,818 counts, 22.6% mitochondrial — are far out in the tails. Those extremes are what QC targets.
Visualize the QC metrics
A violin plot shows the full distribution of each metric across all cells — where the bulk sits and how long the tail runs. This is the figure you read your thresholds off.
library(Seurat)VlnPlot(pbmc, features =c("nFeature_RNA", "nCount_RNA", "percent.mt"), ncol =3)
Read it left to right. nFeature_RNA and nCount_RNA are dense low down and thin out upward — a small number of cells express far more genes and counts than the rest, the signature of doublets (two cells, so roughly twice the material). percent.mt hugs the bottom for almost every cell but has a sparse upper tail reaching past 20% — those are the dying cells.
The two scatter plots show how the metrics relate, which sharpens the cuts. FeatureScatter() prints the correlation in its title.
Counts vs genes (right) is tightly correlated (0.95) — as expected, cells with more transcripts detect more genes, and the near-straight line means there is no large population behaving oddly. Counts vs mitochondrial percentage (left) is barely correlated (−0.13): the high-mitochondrial cells are scattered off the main cloud rather than tracking library size, which is exactly why percent.mt is a separate filter and not redundant with nCount_RNA.
Choose filter thresholds
Thresholds are read off the distributions above, not memorized — they depend on the tissue, the chemistry, and the sequencing depth. The logic is always the same three cuts:
A lower bound on genes per cell removes empty droplets and broken cells with almost no transcripts. The min.features = 200 in the constructor already set this floor, so nFeature_RNA > 200 simply confirms it.
An upper bound on genes per cell removes likely doublets — cells expressing far more genes than the population. The pbmc3k violin thins out above ~2,500 genes, so nFeature_RNA < 2500 trims that tail.
An upper bound on mitochondrial percentage removes dying cells. The bulk of pbmc cells sit under 3%, with a clear tail past 5%, so percent.mt < 5 is the natural cut here.
The point is to trim the obvious junk tails, not to chase a perfect number — place each cut at the shoulder of the distribution where the real cells end. For pbmc3k the established thresholds are nFeature_RNA between 200 and 2,500 and percent.mt below 5%.
Filter the cells
subset() keeps only the cells that pass all three conditions.
Filtering removes 62 of 2,700 cells (2.3%), leaving 2,638 for downstream analysis. The breakdown is telling: 57 cells fail on mitochondrial percentage (≥ 5%) and 5 on the gene upper bound (≥ 2,500) — so the mitochondrial filter does most of the work, and none of the cells were lost on the lower gene bound (the constructor had already handled that).
What you would report:Cells were retained if they expressed 200–2,500 genes with fewer than 5% mitochondrial counts, removing 62 of 2,700 droplets (2.3%) as likely empty, doublet, or dying cells; 2,638 high-quality cells remained. The clean pbmc object is now ready to normalize and find variable features.
Common issues
Nothing gets filtered (or everything does). The most common cause is the mitochondrial pattern not matching the gene names. "^MT-" is for human symbols; mouse uses "^mt-" (lowercase), and a dataset annotated with Ensembl IDs has no MT- prefix at all. Check with grep("^MT-", rownames(pbmc), value = TRUE) — if it returns nothing, percent.mt is all zeros and the filter is a no-op.
Read10X errors on the folder. It expects the three files together (matrix.mtx[.gz], barcodes.tsv[.gz], features.tsv[.gz]/genes.tsv[.gz]) in the directory you point it at. Point it at the folder, not a single file, and make sure all three are present and consistently gzipped or not.
Reusing one set of thresholds everywhere.nFeature_RNA < 2500 and percent.mt < 5 are right for pbmc3k, not universal. Metabolically active tissues (heart, liver) naturally run higher mitochondrial fractions; deeper sequencing shifts the count distributions. Always plot first and set the cut from the data in front of you.
Frequently asked questions
NoteWhat do nFeature_RNA and nCount_RNA mean in Seurat?
nCount_RNA is the total number of RNA molecules (UMIs) detected in a cell — its library size. nFeature_RNA is the number of distinct genes detected in that cell. Both are computed automatically by CreateSeuratObject() and stored in pbmc@meta.data.
NoteHow do I calculate the percentage of mitochondrial genes?
Use PercentageFeatureSet() with a pattern that matches the mitochondrial gene names: pbmc[["percent.mt"]] <- PercentageFeatureSet(pbmc, pattern = "^MT-"). Use "^MT-" for human gene symbols and "^mt-" for mouse.
NoteWhat QC thresholds should I use for scRNA-seq?
There is no universal number. Plot nFeature_RNA, nCount_RNA, and percent.mt with VlnPlot(), then set each cut at the shoulder of its distribution where the real cells end. For pbmc3k that is nFeature_RNA between 200 and 2,500 and percent.mt below 5%, but a different tissue or depth will shift those values.
NoteWhy do high-mitochondrial cells need to be removed?
A high mitochondrial fraction is the signature of a stressed or dying cell: as the cell membrane breaks down, cytoplasmic mRNA leaks out while mitochondrial transcripts stay trapped inside the organelle, so their share of the remaining counts rises. Keeping these cells injects technical noise that can masquerade as a cell type.
NoteDoes filtering on counts also remove doublets?
Partly. A high nFeature_RNA/nCount_RNA upper bound removes the most obvious doublets (two cells give roughly double the material), which is what the nFeature_RNA < 2500 cut does here. For thorough doublet removal, dedicated tools (DoubletFinder, scDblFinder) model the doublet distribution explicitly — QC thresholds are a first pass, not a complete solution.
Test your understanding
ImportantYour turn: QC a Seurat object from scratch
Starting from the committed pbmc3k matrix, build the Seurat object, add percent.mt, and report how many cells have a mitochondrial fraction of 5% or more. Then apply the full filter and confirm the final cell count.
TipHint
After adding percent.mt, sum(pbmc$percent.mt >= 5) counts the high-mitochondrial cells. subset() with the three conditions applies the filter; ncol() gives the cell count.
57 of the 2,700 cells carry 5% or more mitochondrial counts; after the full filter, 2,638 high-quality cells remain.
NoteQuick check: which cells does a high percent.mt flag?
A. Doublets — two cells captured in one droplet. B. Dying or stressed cells leaking cytoplasmic mRNA. C. The most deeply sequenced, highest-quality cells.
TipShow answer
B. A high mitochondrial fraction marks dying or stressed cells: cytoplasmic mRNA leaks out as the membrane breaks down, leaving mitochondrial transcripts over-represented. Doublets are flagged by an unusually high nFeature_RNA/nCount_RNA instead.
Conclusion
Quality control is the gate every single-cell analysis passes through first. You loaded the pbmc3k 10x matrix with Read10X(), built a Seurat object, and screened every cell on the three metrics that matter — nFeature_RNA, nCount_RNA, and percent.mt. You read the thresholds off a VlnPlot and a FeatureScatter rather than memorizing them, then used subset() to keep the 2,638 clean cells. The durable rules: plot before you cut, set each threshold at the shoulder of its distribution, and report what you kept. The clean object is ready to normalize, find variable features, and cluster.
Ask Prova“how do I quality-control my own 10x single-cell data in Seurat?” — it answers with R code you can run on your own matrix, computes the QC metrics, and helps you pick thresholds from your distributions. The runtime is the judge.Ask Prova →
Was this page helpful?
Thanks for the feedback!
Prove you can do it. Master the whole Single-Cell RNA-Seq Analysis in R series — track your path, build projects, and earn a certificate.
This lesson is reproducible: every figure and number was produced by the code shown — copy any block and run it to reproduce them. The runtime is the judge.
References
Hao, Y., et al. (2021). Integrated analysis of multimodal single-cell data (Seurat v4). Cell, 184(13), 3573–3587.
Satija, R., Farrell, J. A., Gennert, D., Schier, A. F., & Regev, A. (2015). Spatial reconstruction of single-cell gene expression data. Nature Biotechnology, 33(5), 495–502.
10x Genomics. 3k PBMCs from a Healthy Donor — the canonical pbmc3k filtered gene–barcode matrix (Cell Ranger 1.1.0).