Conversion · 10x MTX (Matrix Market) → Seurat object (RDS)
How to Convert 10x MTX to Seurat object (and Why IDs Go Missing)
Read10X reads a directory, not a file, and the column you pick for gene names quietly decides how many genes you actually have.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read
- 10x MTX (Matrix Market)
- matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz · coordinates: n/a
- Seurat object (RDS)
- .rds, .RDS · coordinates: n/a
You need this conversion the moment Cell Ranger finishes: its output is three flat files (matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz), and every Seurat function downstream, normalization, clustering, FindMarkers, wants a Seurat object, not a folder of text files. The conversion itself is short: Read10X() into CreateSeuratObject() into saveRDS(). The trap is in the defaults hiding inside that first call.
Read10X() takes a directory path, not a matrix file path. Point it at the file itself and it fails immediately, which is annoying but at least loud. What is not loud: gene.column, which defaults to 2 (gene symbols) instead of 1 (Ensembl IDs). Symbols are more readable, but they are not unique, the same symbol can appear on multiple rows of features.tsv.gz (readthrough transcripts, pseudoautosomal genes, stale annotation). Seurat's fix is unique.features = TRUE, which appends .1, .2, .3 to duplicates so rownames stay unique. That silently turns one biological gene into two or three separate rows in your count matrix, each getting a fraction of the reads. If you then search for that gene by symbol later and only find GENE.1, you've lost half its counts to GENE.2 and never noticed.
The other conversion you don't get is metadata. A raw MTX-derived Seurat object holds only counts in the RNA assay: no PCA, no clusters, no UMAP, nothing in @meta.data beyond nCount_RNA and nFeature_RNA. That's expected, not a bug, but it trips people who assume "Seurat object" implies "already analyzed." If you receive an RDS from a collaborator and it does carry PCA/clusters, that's a sign it went through a full pipeline, not a straight MTX conversion.
The commands
Type your file names once; every command below updates.
01seurat
rpbmc.data <- Read10X(data.dir = "sample.matrix.mtx.gz") pbmc <- CreateSeuratObject(counts = pbmc.data, project = "pbmc", min.cells = 3, min.features = 200) saveRDS(pbmc, "sample.rds")Read10X takes the directory containing all three files, not a single file; min.cells/min.features drop genes seen in <3 cells and cells with <200 detected genes before the object is even created. Assumes the directory has exactly matrix.mtx.gz, features.tsv.gz, and barcodes.tsv.gz with matching row/column counts.
02seurat
rcounts <- Read10X(data.dir = "sample.matrix.mtx.gz", gene.column = 1, unique.features = TRUE) pbmc <- CreateSeuratObject(counts = counts, min.cells = 3, min.features = 200) saveRDS(pbmc, "sample.rds")gene.column = 1 uses Ensembl IDs (column 1 of features.tsv.gz) as rownames instead of the default gene symbols in column 2, use this when you need stable, unique identifiers and will map to symbols later, rather than living with .1/.2 suffixes on duplicated symbols.
03seurat
rmtx <- ReadMtx( mtx = file.path("sample.matrix.mtx.gz", "matrix.mtx.gz"), cells = file.path("sample.matrix.mtx.gz", "barcodes.tsv.gz"), features = file.path("sample.matrix.mtx.gz", "features.tsv.gz"), feature.column = 1 ) seurat_obj <- CreateSeuratObject(counts = mtx) saveRDS(seurat_obj, "sample.rds")ReadMtx takes the three files individually instead of a directory, which is the fix when your files don't follow Cell Ranger's exact naming (e.g. genes.tsv.gz instead of features.tsv.gz, or files pulled from different folders). feature.column works the same as Read10X's gene.column.
04seurat
rlibrary(Matrix) mtx <- readMM(file.path("sample.matrix.mtx.gz", "matrix.mtx.gz")) barcodes <- readLines(file.path("sample.matrix.mtx.gz", "barcodes.tsv.gz")) features <- read.delim(file.path("sample.matrix.mtx.gz", "features.tsv.gz"), header = FALSE) rownames(mtx) <- make.unique(features[, 2]) colnames(mtx) <- barcodes seurat_obj <- CreateSeuratObject(counts = mtx, min.cells = 3, min.features = 200) saveRDS(seurat_obj, "sample.rds")Manual load with Matrix::readMM when Read10X's assumptions don't fit, this is where you see explicitly that make.unique() is doing the same suffixing Read10X hides by default, and you control which features column becomes the rownames.
Coordinates, strand, names, builds
There are no genomic coordinates or strand in play here: MTX is a gene-by-cell count matrix, not an interval format, so chr-naming and 0-based/1-based questions don't apply. What does carry real risk is identifier and structure loss.
Row identity: features.tsv.gz column 1 is the Ensembl gene ID (stable, unique, build-specific, tied to whatever GTF/reference Cell Ranger was run against), column 2 is the gene symbol (readable, not unique, and drifts between annotation releases). Read10X()'s gene.column = 2 default plus unique.features = TRUE means duplicate symbols get suffixed to SYMBOL.1, SYMBOL.2 and become distinct rows in the matrix, silently splitting one gene's counts across multiple rows. If two datasets were processed against different reference versions (different Ensembl release, or GENCODE vs RefSeq), the same gene can have different IDs or different duplicate sets, breaking any downstream merge that joins on rownames.
Column identity: cell barcodes carry a -1 suffix appended by Cell Ranger to distinguish samples/GEM wells; Read10X(strip.suffix = TRUE) removes it only if every barcode has the same suffix uniformly. Barcode length and structure also encode chemistry (v2 vs v3), which matters if you're about to merge with a dataset processed with a different chemistry.
Structural/metadata loss: the resulting Seurat object contains only the RNA assay's raw counts (and, in Seurat v5, whichever layers you populated, counts by default). Nothing else survives the conversion: no PCA, no clustering, no UMAP, no QC flags, no sample-level metadata beyond what CreateSeuratObject's meta.data argument is explicitly given. Anything a collaborator computed upstream (doublet calls, cell-type labels) has to be re-attached by hand after the fact.
Check the output before you trust it
01Dimensions match the source files
rdim(seurat_obj) # compare to: length(readLines(gzfile("barcodes.tsv.gz")))Expected ncol(seurat_obj) should equal the barcode count (or be smaller only by exactly what min.features filtered out); nrow(seurat_obj) should equal the features.tsv.gz line count minus anything min.cells dropped.
02No unexpected duplicate-suffixed gene names
rsum(grepl("\\.[0-9]+$", rownames(seurat_obj)))Expected Zero, or a small number you've specifically checked, each `.1`/`.2` hit is a real gene symbol that got split across multiple matrix rows and will under-count in any per-gene analysis.
03Rownames are the ID type you intended
rhead(rownames(seurat_obj))Expected Symbols like "CD3E", "GAPDH" if you used the default gene.column = 2; IDs like "ENSG00000167286" if you set gene.column = 1. If you see the wrong kind, you loaded the wrong column.
04Counts stayed sparse, not dense
rclass(GetAssayData(seurat_obj, slot = "counts"))Expected "dgCMatrix" (or a Seurat v5 layer backed by one), if it prints "matrix" or "data.frame", something upstream densified it and memory use will balloon on a full dataset.
05Barcode suffix is consistent
rtable(sub(".*-", "-", colnames(seurat_obj)))Expected A single suffix value (typically "-1") across all barcodes if this is one GEM well; mixed suffixes mean you loaded a multiplexed/aggregated matrix and should treat suffix as a sample identifier, not noise to strip.
Errors you will see, and what they mean
- Error in Read10X(data.dir = ...) : Directory provided does not exist
- Cause: data.dir was pointed at matrix.mtx.gz itself (or a nonexistent path) instead of the folder that contains all three files. Fix: Pass the directory path, e.g. data.dir = "filtered_feature_bc_matrix/", and confirm it contains matrix.mtx.gz, features.tsv.gz, and barcodes.tsv.gz (or their unzipped equivalents) before calling Read10X again.
- Warning: Feature names cannot have underscores ('_'), replacing with dashes ('-')
- Cause: Seurat's CreateSeuratObject sanitizes feature names on the way in, which is expected, but it means any gene symbol with an underscore no longer matches its original ID elsewhere. Fix: If you need the exact original identifiers for a join later, load counts with the manual readMM approach and keep the unmodified features table separate rather than relying on the sanitized rownames.
- Error: nrow(x) == length(cell.names) is not TRUE (or similar dimension-mismatch error from CreateSeuratObject)
- Cause: The matrix.mtx.gz row/column counts don't match the number of lines in features.tsv.gz or barcodes.tsv.gz, usually because one file was filtered, re-gzipped, or replaced independently of the others. Fix: Re-download or regenerate the full matched triplet from the same Cell Ranger run; don't hand-edit one of the three files without regenerating the matrix header.
- Fewer genes than expected after loading, or a gene you know is in the sample is missing
- Cause: Duplicate gene symbols got merged/suffixed under unique.features = TRUE, and you searched for the bare symbol which now only matches one of the split rows (or none, if it's now GENE.1). Fix: grep rownames(seurat_obj) for the gene prefix (e.g. grep("^GENE", rownames(seurat_obj), value = TRUE)) to find suffixed variants, or reload with gene.column = 1 to work from unique Ensembl IDs instead.
- Error in readMM(...) : unable to find an inherited method for function 'readMM' for signature ...
- Cause: matrix.mtx.gz wasn't actually gzip-compressed (or was double-compressed), so readMM can't parse the header it expects. Fix: Check the file with `file matrix.mtx.gz` at the shell; if it isn't real gzip, re-download it or gunzip/re-gzip it correctly before calling readMM again.
Questions people ask
- Can I convert 10x MTX to a Seurat object without R?
No, Seurat objects are R-specific serialized structures (S4 classes), so creating one requires the Seurat package in R. If your downstream tool is Python-based, convert the MTX files to an AnnData
.h5adinstead with scanpy'ssc.read_10x_mtx(), which reads the same three Cell Ranger files.- Why does Read10X() give me fewer genes than are in features.tsv.gz?
CreateSeuratObject's min.cells filter drops genes detected in too few cells before the object is finalized, and duplicate gene symbols can get merged or suffixed depending on unique.features. Check both before assuming data went missing.
- Should I use gene symbols or Ensembl IDs as Seurat rownames?
Use Ensembl IDs (gene.column = 1) if you'll merge with other datasets, map across annotation versions, or need guaranteed-unique identifiers. Use symbols (the default, gene.column = 2) only if your entire analysis stays within one dataset and you've confirmed how many duplicates get suffixed.
- Does saveRDS() compress the Seurat object?
By default saveRDS() applies gzip compression, so the .rds file is already compressed; you don't need to gzip it separately. Loading a large one back with readRDS() can still be slow, since R has to decompress and reconstruct the whole object in memory.
- Why does Read10X want a directory instead of a file?
Cell Ranger's sparse output is split across three files by design, the matrix plus separate features and barcodes lookups, so Read10X reconstructs the full matrix by reading all three from the same folder and matching them by position, not by name.
Related pages
- Convert · How to Convert 10x HDF5 to Seurat object (and Why IDs Go Missing)
- Convert · How to Convert h5ad to Seurat object (Without Losing Your Metadata)
- Convert · How to Convert Seurat object to SingleCellExperiment (Without Losing Your Metadata)
- Convert · How to Convert SingleCellExperiment to Seurat object (Without Losing Your Metadata)
Related reading on the blog
Sources
- Read10X: Load in data from 10X, Seurat reference documentation — Read10X directory/gene.column/strip.suffix behavior and sparse matrix return type
- PBMC 3K Tutorial: Guided Clustering Tutorial, Seurat — Standard Read10X → CreateSeuratObject → saveRDS workflow and min.cells/min.features thresholds
- ReadMtx: Load in data from remote or local mtx files, Seurat reference — ReadMtx parameters (feature.column, cell.column, unique.features) as an alternative to Read10X
- Dealing with duplicated features, GitHub Seurat Discussion #6002 — How Read10X's unique.features suffixing splits duplicate gene symbols into separate rows