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 matrix
pbmc.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 genes
pbmc <- 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
                 orig.ident nCount_RNA nFeature_RNA percent.mt
AAACATACAACCAC-1     pbmc3k       2419          779  3.0177759
AAACATTGAGCTAC-1     pbmc3k       4903         1352  3.7935958
AAACATTGATCAGC-1     pbmc3k       3147         1129  0.8897363
AAACCGTGCTTCCG-1     pbmc3k       2639          960  1.7430845
AAACCGTGTATGCG-1     pbmc3k        980          521  1.2244898
AAACGCACTGGTAC-1     pbmc3k       2163          781  1.6643551

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:

summary(pbmc@meta.data[, c("nFeature_RNA", "nCount_RNA", "percent.mt")])
  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)

Three violin plots side by side for the pbmc3k cells. nFeature_RNA: most cells between roughly 500 and 1,300 genes with a thin tail above 2,500. nCount_RNA: most cells between about 1,000 and 4,000 counts. percent.mt: most cells under 3 percent with a sparse tail of cells reaching above 5 and up to 22 percent. Each cell is a point overlaid on the violin.

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.

library(Seurat)
library(patchwork)

plot1 <- FeatureScatter(pbmc, feature1 = "nCount_RNA", feature2 = "percent.mt")
plot2 <- FeatureScatter(pbmc, feature1 = "nCount_RNA", feature2 = "nFeature_RNA")
plot1 + plot2

Two scatter plots of pbmc3k cells. Left: counts per cell against mitochondrial percentage, a weak negative correlation of -0.13, with a cluster of cells low on both axes and a few high-mitochondrial cells off to the upper left. Right: counts per cell against genes per cell, a tight near-linear relationship with correlation 0.95.

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.

library(Seurat)
pbmc.data <- Read10X("_data/filtered_gene_bc_matrices/hg19")
pbmc <- CreateSeuratObject(pbmc.data, project = "pbmc3k",
                           min.cells = 3, min.features = 200)
pbmc[["percent.mt"]] <- PercentageFeatureSet(pbmc, pattern = "^MT-")

before <- ncol(pbmc)
pbmc <- subset(pbmc, subset = nFeature_RNA > 200 & nFeature_RNA < 2500 & percent.mt < 5)
after <- ncol(pbmc)

c(before = before, after = after, removed = before - after)
 before   after removed 
   2700    2638      62 

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

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.

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.

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.

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.

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

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.

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.

library(Seurat)
pbmc.data <- Read10X("_data/filtered_gene_bc_matrices/hg19")
pbmc <- CreateSeuratObject(pbmc.data, project = "pbmc3k",
                           min.cells = 3, min.features = 200)
pbmc[["percent.mt"]] <- PercentageFeatureSet(pbmc, pattern = "^MT-")

sum(pbmc$percent.mt >= 5)            # 57 cells fail the mitochondrial cut
pbmc <- subset(pbmc, subset = nFeature_RNA > 200 & nFeature_RNA < 2500 & percent.mt < 5)
ncol(pbmc)                           # 2638 cells remain

57 of the 2,700 cells carry 5% or more mitochondrial counts; after the full filter, 2,638 high-quality cells remain.

A. Doublets — two cells captured in one droplet. B. Dying or stressed cells leaking cytoplasmic mRNA. C. The most deeply sequenced, highest-quality cells.

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.

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).

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Seurat {Quality} {Control:} {Load} \& {Filter} a {scRNA-Seq}
    {Dataset} (Pbmc3k)},
  date = {2026-06-29},
  url = {https://www.datanovia.com/learn/bioinformatics/single-cell/scrnaseq-quality-control.html},
  langid = {en}
}
For attribution, please cite this work as:
“Seurat Quality Control: Load & Filter a scRNA-Seq Dataset (Pbmc3k).” 2026. June 29. https://www.datanovia.com/learn/bioinformatics/single-cell/scrnaseq-quality-control.html.