Chatomics Field GuideWhat They Don't Teach You

Conversion · Seurat object (RDS) → 10x MTX (Matrix Market)

How to Convert Seurat object to 10x MTX (Commands, Checks, and Pitfalls)

Pick the wrong Seurat layer, or reach for Matrix::writeMM instead of write10xCounts, and your MTX directory looks complete right up until Scanpy or Cell Ranger tries to load it.

By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read

Seurat object (RDS)
.rds, .RDS · coordinates: n/a
10x MTX (Matrix Market)
matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz · coordinates: n/a

You need this conversion whenever a downstream tool doesn't speak Seurat: handing counts to Scanpy, an nf-core pipeline, ArchR, or a collaborator running Python instead of R. It's also the right move for archiving raw counts in a format that doesn't depend on a specific Seurat version to open again, an RDS written by one Seurat version doesn't always load cleanly under another, but a Cell Ranger-style MTX directory reads into almost anything.

A Seurat object is a full analysis, not just a matrix: it bundles counts, normalized data, scaled data, cell metadata, PCA, clustering, and UMAP coordinates together in one S4 container. MTX export keeps none of that except the single layer you point it at. If a collaborator needs the metadata or embeddings too, export those separately (write.csv on seurat_obj@meta.data, or on Embeddings(seurat_obj, "umap")), the MTX directory alone hands them the matrix and nothing else.

The failure that bites people silently is exporting the wrong layer, or the wrong function. scale.data is dense, z-scored, and has negative values; export that instead of counts and Scanpy or Cell Ranger will load it without complaint, but every downstream normalization step then runs on numbers that are already normalized and mean-centered. The other classic mistake is calling Matrix::writeMM() directly on the counts matrix, it writes matrix.mtx, but not barcodes.tsv or features.tsv, so the directory looks like a Cell Ranger output and isn't. A reader that expects all three files fails outright, or worse, silently misaligns cell and gene labels if someone hand-builds stand-in files to patch the gap.

The commands

Type your file names once; every command below updates.

  1. 01Seuratv5

    r
    seurat_obj <- readRDS("sample.rds")
    Layers(seurat_obj[["RNA"]])

    Lists every layer actually present in the RNA assay before you export anything. After merges, integration, or SCTransform you'll often find counts.1/counts.2 or a scale.data layer sitting alongside counts, picking blind is how people export the wrong matrix.

  2. 02DropletUtils

    r
    DropletUtils::write10xCounts(
      path = "sample.matrix.mtx.gz",
      x = seurat_obj[["RNA"]]$counts,
      version = "3"
    )

    write10xCounts is the function actually built and tested for Cell Ranger-style output. This pulls the raw counts layer specifically (not data or scale.data) and writes version 3 format: matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz. Assumes seurat_obj[["RNA"]]$counts is a sparse dgCMatrix, not something you've already converted to dense.

  3. 03Seuratv5

    r
    mat <- Read10X(data.dir = "sample.matrix.mtx.gz")
    dim(mat)

    Reads the output directory back with Seurat's own loader, input being the {output} directory from the previous step. If it errors, a file is missing, misnamed, or truncated; if dim(mat) doesn't match dim(seurat_obj[["RNA"]]$counts), you exported the wrong layer or a stale subset.

  4. 04burgertools

    r
    burgertools::Export10X(seurat_obj, path = "sample.matrix.mtx.gz", assay = "RNA", layer = "counts")

    Convenience wrapper that writes features.tsv, barcodes.tsv, matrix.mtx (with gzip support) plus an optional metadata.tsv in one call. Not part of Bioconductor and less battle-tested than write10xCounts; reach for it only when you specifically want the metadata.tsv side output.

  5. 05zcat

    bash
    zcat sample.matrix.mtx.gz/matrix.mtx.gz | head -3

    Input is the matrix.mtx.gz written into the {output} directory. The three header lines (format banner, then rows/cols/nonzero-count) confirm the file is valid gzip and well-formed before anything downstream tries to load it, a truncated write fails here immediately instead of producing a confusing error three tools later.

Coordinates, strand, names, builds

No genomic coordinates or strand information travel through this conversion, a Seurat counts matrix isn't coordinate-sorted or strand-aware, and neither is MTX. Genome build and chromosome-naming convention (chr1 vs 1) live in whatever reference the counts were originally quantified against; MTX carries no record of it, so note the reference/build used to produce the original matrix somewhere outside the three files, since features.tsv.gz preserves gene IDs but not build provenance. What does change: the Matrix Market coordinate format is 1-indexed internally (row and column indices in matrix.mtx start at 1, per spec) even though nothing in your R code shifts to match it. Matrix orientation is fixed as genes-as-rows, cells-as-columns, a hand-built export that transposes this silently swaps the meaning of every downstream row/column operation. And only one Seurat layer survives per export call (counts, data, or scale.data); cell metadata, PCA, clustering, and UMAP coordinates are dropped entirely unless you export them yourself as separate files.

Check the output before you trust it

  1. 01Matrix dimensions match the source layer

    r
    dim(seurat_obj[["RNA"]]$counts)
    dim(Read10X(data.dir = "{output}"))

    Expected Both calls report the same nrow (genes) and ncol (cells). A mismatch means you exported a subsetted object, a stale layer, or a split layer instead of the full counts matrix.

  2. 02features.tsv.gz line count and column shape

    bash
    zcat {output}/features.tsv.gz | wc -l; zcat {output}/features.tsv.gz | head -1 | awk -F'\t' '{print NF}'

    Expected Line count equals nrow of the exported matrix; the first row has 3 tab-separated fields (gene ID, symbol, feature type).

  3. 03Values are raw counts, not normalized or scaled data

    r
    any(seurat_obj[["RNA"]]$counts %% 1 != 0); any(seurat_obj[["RNA"]]$counts < 0)

    Expected Both return FALSE. Non-integer or negative values mean you exported data or scale.data instead of counts.

  4. 04Nonzero entry count matches the MTX header

    bash
    zcat {output}/matrix.mtx.gz | sed -n '3p'

    Expected The third field on this line (nonzero count) equals sum(seurat_obj[["RNA"]]$counts != 0) computed in R, confirms no entries were dropped or duplicated during write.

  5. 05Barcode suffixes are intact and non-duplicated

    bash
    zcat {output}/barcodes.tsv.gz | sort | uniq -d | wc -l

    Expected Returns 0. Any duplicate barcode lines mean cells will collide when a downstream tool joins this matrix against per-cell metadata.

Errors you will see, and what they mean

Read10X() or Scanpy's read_10x_mtx() errors with a dimension mismatch, or loads a dense-looking matrix with negative decimal values
Cause: You exported scale.data (z-scored, mean-centered, dense) instead of counts. It loads fine because nothing checks the sign or type of the values, but it isn't raw counts anymore. Fix: Pull the layer explicitly with seurat_obj[["RNA"]]$counts and confirm class(...) is dgCMatrix and all(counts@x >= 0) before calling write10xCounts().
"molecule info HDF5 file was produced by an older version of Cell Ranger"
Cause: write10xCounts() was pointed at a path ending in .h5 (HDF5 output) and that file was later fed into Cell Ranger's aggr, which has a documented version incompatibility with DropletUtils' HDF5 writer. Fix: Export the sparse directory/MTX format (path without .h5 extension) instead of HDF5 for anything that needs to round-trip through Cell Ranger; reserve HDF5 export for tools that read .h5 directly and don't touch aggr.
Downstream loader says features.tsv / barcodes.tsv not found, even though matrix.mtx is sitting right there
Cause: Matrix::writeMM() was called directly on the counts matrix. writeMM only ever writes the .mtx file, it has no concept of barcodes or features and silently leaves them out. Fix: Never call writeMM() alone for a directory meant to round-trip through a Cell Ranger-style loader. Use write10xCounts() or Export10X(), both of which write all three files together.
seurat_obj[["RNA"]]$counts returns NULL, or Layers() shows counts.1/counts.2 instead of a single counts layer
Cause: After integration, SCTransform, or a split-by-sample workflow, the RNA assay's data can be stored in split, numbered layers rather than one unified counts layer. Fix: Run Layers(seurat_obj[["RNA"]]) first. If you see split layers, run JoinLayers(seurat_obj) before exporting, or export and concatenate each split separately.
Scanpy's read_10x_mtx() raises a filename/format error even though the directory has three files
Cause: write10xCounts() was called with version = "2" (genes.tsv, ungzipped) but the downstream tool expects the current version 3 layout (features.tsv.gz, gzipped). Fix: Pass version = "3" explicitly unless you specifically know the consumer needs the legacy ungzipped genes.tsv naming.

Questions people ask

Does converting a Seurat object to MTX keep my UMAP, clustering, or metadata?

No. MTX export carries only the one matrix layer you point write10xCounts at. Cell metadata, PCA, clustering assignments, and UMAP coordinates all have to be exported separately, for example with write.csv on seurat_obj@meta.data or on Embeddings(seurat_obj, "umap").

Which Seurat layer should I export to MTX: counts, data, or scale.data?

Export counts unless the downstream tool explicitly expects already-normalized values. counts is raw, sparse, and non-negative integers, which is what Cell Ranger-style readers assume. scale.data is dense and z-scored with negative values, and will load without error but silently break any normalization step run on it downstream.

Can I use Matrix::writeMM() instead of write10xCounts()?

Not for this. writeMM() writes only the matrix.mtx file, with no barcodes.tsv or features.tsv. A downstream loader like Read10X() or Scanpy's read_10x_mtx() will either fail to find the missing files or, if you patch them in by hand, silently misalign cells and genes.

Should I use write10xCounts version 2 or version 3 output?

Use version 3 (version = "3"): gzipped files with features.tsv.gz, matching what current Cell Ranger and Scanpy expect. Only use version 2's ungzipped genes.tsv layout if you know a specific downstream tool still requires the legacy naming.

Why does my exported MTX have the wrong number of cells or genes?

Usually you exported a layer captured before a subsetting or filtering step, or the RNA assay has split layers (counts.1, counts.2) from integration that never got joined. Run Layers(seurat_obj[["RNA"]]) and dim() checks before exporting to catch this.

Related pages

Sources

  1. DropletUtils write10xCounts Documentation — Parameters, version 2 vs version 3 output formats, and output file structure
  2. Seurat v5 Essential Commands — v5 layer access syntax (Layers(), $counts, LayerData())
  3. DropletUtils HBC Knowledgebase: write10xCounts — Practical write10xCounts usage from a Seurat object
  4. write10xCounts Cell Ranger Compatibility Issue #40 — HDF5 output incompatibility with Cell Ranger aggr
  5. Export10X Function (burgertools) — Alternative single-call export path from Seurat objects
  6. ReadMtx Documentation — Reading separate MTX/barcodes/features files back into R
  7. Read10X Documentation — Loading a Cell Ranger-style directory back into Seurat for round-trip verification