Chatomics Field GuideWhat They Don't Teach You

Conversion · 10x MTX (Matrix Market) → CSV/TSV count table

How to Convert 10x MTX to CSV/TSV count table (Without Losing Your Metadata)

Densifying a sparse 10x matrix for a spreadsheet is usually the wrong call, here's how to do it safely on the rare occasion you can't avoid it.

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
CSV/TSV count table
.csv, .tsv, .txt · coordinates: n/a

You need this conversion when a collaborator, a legacy pipeline, or a tool outside the R/Python single-cell ecosystem can only read flat text, not an AnnData or Seurat object. Cell Ranger's native output is three separate files, matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz, and most single-cell tools are built to read that sparse triplet directly. Converting to CSV is rarely something your own analysis needs; it's something you do to hand data to someone or something else.

The conversion is lossy in two different ways. First, sparsity: more than 90% of a gene x cell matrix is zeros, and a CSV/TSV has to write every one of them out, so a 30,000-gene by 10,000-cell matrix becomes on the order of hundreds of MB of digits at minimum, more with floating-point counts. Second, structure: an AnnData or Seurat object carries cell metadata, feature type, and any downstream results (PCA, clusters, embeddings) alongside the counts. A flat file keeps only the numbers, once you write the CSV, that context is gone unless you export it separately.

The failure that actually bites people is Excel, not the conversion script. Gene symbols like MARCH1, SEPT7, and DEC1 get auto-converted to dates the instant the file is opened, and nothing in the CSV or the conversion code warns you, the corruption happens downstream, in a spreadsheet, after you've already sent the file. The second most common silent failure is gene-symbol collision: reading with var_names='gene_symbols' but skipping make_unique=True merges two different genes that happen to share a symbol into one row, and you end up with a CSV that has fewer rows than genes and no error at all.

The commands

Type your file names once; every command below updates.

  1. 01scanpyv1.7.2 / 1.8.3.dev

    python
    import scanpy as sc
    import pandas as pd
    
    adata = sc.read_10x_mtx('sample.matrix.mtx.gz', var_names='gene_symbols', make_unique=True, gex_only=True)
    df = pd.DataFrame(adata.X.todense(), index=adata.obs_names, columns=adata.var_names)
    df.to_csv('sample.csv')

    read_10x_mtx() loads the three-file directory into an AnnData object. var_names='gene_symbols' indexes rows by gene symbol instead of Ensembl ID (riskier: symbols can repeat); make_unique=True appends -1/-2 suffixes so duplicate symbols don't collide; gex_only=True drops Antibody Capture / CRISPR Guide Capture rows if present. .todense() materializes every zero into RAM, so this assumes the matrix is small enough to fit in memory whole.

  2. 02scanpyv1.7.2 / 1.8.3.dev

    python
    import scanpy as sc
    import pandas as pd
    
    adata = sc.read_10x_mtx('sample.matrix.mtx.gz', var_names='gene_symbols', make_unique=True, gex_only=True)
    sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor='seurat_v3')
    adata_sub = adata[:, adata.var.highly_variable]
    df = pd.DataFrame(adata_sub.X.todense().T, index=adata_sub.var_names, columns=adata_sub.obs_names)
    df.to_csv('sample.csv', sep='\t')

    This is the version to actually send a collaborator. It subsets to the top 2,000 variable genes (10x recommends the 2,000-5,000 range for this kind of export) before densifying, so a 30,000-gene matrix doesn't turn into a multi-hundred-MB file. Output is transposed to genes-as-rows, cells-as-columns and written tab-separated, which survives Excel's comma-parsing quirks better than true CSV.

  3. 03seuratvlatest (reference page current)

    r
    library(Seurat)
    
    data <- Read10X(data.dir = 'sample.matrix.mtx.gz')
    seurat_obj <- CreateSeuratObject(counts = data)
    dense_counts <- as.matrix(seurat_obj@assays$RNA$counts)
    write.csv(dense_counts, 'sample.csv')

    Read10X() returns a sparse dgCMatrix using column 2 of features.tsv (gene symbol) as row names by default. as.matrix() densifies it at the same RAM cost as .todense() in Python. If features.tsv.gz mixes feature types (Gene Expression plus Antibody Capture), Read10X() returns a named list instead of one matrix, check class(data) before assuming this works unmodified.

  4. 04dropletutilsvdevel

    r
    library(DropletUtils)
    
    sce <- read10xCounts('sample.matrix.mtx.gz')
    counts_dense <- as.matrix(counts(sce))
    write.csv(counts_dense, 'sample.csv')

    read10xCounts() loads the matrix into a SingleCellExperiment, keeping colData (cell metadata) and rowData (gene annotations) attached right up until as.matrix() strips them out. Use this route if you want to inspect or export that metadata to a separate file before you throw it away by densifying the counts.

Coordinates, strand, names, builds

No genomic coordinates or strand are involved in this conversion, both mtx and csv sit at the gene/feature x cell level, not the base-pair level. What actually changes: (1) indexing, MTX uses 1-based feature/cell indices in a sparse coordinate list, while the CSV is purely positional, so a hand-rolled parser that gets the offset wrong shifts every row silently; (2) identifiers, features.tsv.gz carries both an Ensembl gene ID and a gene symbol, but a CSV keyed on symbol alone drops the ID and can collide on duplicate symbols; (3) chromosome and genome-build metadata were never in any of these three files to begin with, so there's nothing to lose there; (4) everything else, cell QC metrics, feature type (Gene Expression vs Antibody Capture vs CRISPR Guide Capture), and any PCA/clustering/UMAP results, lives in the AnnData/Seurat/SingleCellExperiment wrapper and does not survive a flat CSV/TSV export at all.

Check the output before you trust it

  1. 01Dimensions match the source files

    bash
    zcat features.tsv.gz | wc -l
    zcat barcodes.tsv.gz | wc -l

    Expected features.tsv.gz line count equals df.shape[0] (genes) and barcodes.tsv.gz line count equals df.shape[1] (cells), or the transpose if you wrote genes as columns.

  2. 02Total UMI count is conserved

    python
    adata.X.sum(), df.values.sum()

    Expected Both sums are equal within floating-point tolerance, densifying changes how counts are stored, never the total number of UMIs.

  3. 03No duplicate gene symbols were silently collapsed

    python
    df.index.duplicated().sum()

    Expected 0. Anything greater than 0 means make_unique=True was skipped and two different genes are sharing one row.

  4. 04Only the feature type you intended made it into the table

    bash
    zcat features.tsv.gz | cut -f3 | sort | uniq -c

    Expected A single Gene Expression category, unless you deliberately meant to include Antibody Capture or CRISPR Guide Capture rows too.

  5. 05The file reads cleanly before it reaches Excel

    Expected Open the CSV/TSV in a plain text editor or less and confirm gene symbols like MARCH1, SEPT7, and DEC1 still read as text, not dates, check this before handing the file to anyone who will open it in Excel.

Errors you will see, and what they mean

MemoryError (Python) / "cannot allocate vector of size X Gb" (R)
Cause: as.matrix() and .todense() have to materialize every zero in the gene x cell matrix. A routine 10x dataset, tens of thousands of genes by thousands to hundreds of thousands of cells, can need several GB just for the dense array, on top of whatever the sparse object already used. Fix: Subset to the top 2,000-5,000 variable genes (or a cell subset) before densifying, or skip densification entirely. Scanpy, Seurat, and DropletUtils all run every standard analysis step natively on the sparse matrix.
MARCH1, SEPT7, and DEC1 show up as 1-Mar, 7-Sep, and 1-Dec after the file is opened
Cause: Excel auto-detects any cell that looks like a date and silently reformats it on open. The CSV file on disk is untouched; the corruption is entirely in how Excel displays it. Fix: Import through Excel's Text Import Wizard with the gene-symbol column forced to Text, hand the file off as TSV with instructions not to double-click it, or key the table on Ensembl gene ID instead of symbol.
The output CSV has fewer rows than the gene count in features.tsv.gz
Cause: var_names='gene_symbols' without make_unique=True lets two different Ensembl gene IDs that happen to share a symbol collapse onto the same row name, silently dropping one gene's counts. Fix: Set make_unique=True so duplicate symbols get a -1/-2 suffix, or index the output on the Ensembl gene ID column instead of the symbol.
Read10X() returns a list, and CreateSeuratObject(counts = data) fails or only picks up one modality
Cause: features.tsv.gz contains more than one feature type (Gene Expression plus Antibody Capture or CRISPR Guide Capture); Read10X() returns a named list of matrices in that case instead of a single dgCMatrix. Fix: Check class(data) after Read10X(). If it's a list, select the modality you want with data$`Gene Expression` before converting, or filter features.tsv.gz down to Gene Expression rows up front.

Questions people ask

Can I just open a 10x matrix.mtx.gz file directly?

No. matrix.mtx.gz is a sparse coordinate-format file with a 3-line header and one row per non-zero value, and it only means anything alongside features.tsv.gz and barcodes.tsv.gz. None of the three are meant to be read outside a tool like scanpy's read_10x_mtx() or Seurat's Read10X(). Convert to CSV/TSV first if you need something a spreadsheet or a plain script can open.

Why did my gene names turn into dates after I opened the CSV?

Excel auto-formats cells that look like MARCH1 or SEPT7 as calendar dates the moment you open the file, with no warning. Import through Excel's Text Import Wizard and force the gene-symbol column to Text, or skip Excel and load the TSV in R or pandas instead.

Will I lose data converting mtx to csv?

The counts themselves are preserved exactly, total UMIs before and after should match. What you lose is everything the AnnData, Seurat, or SingleCellExperiment object carried around the counts: cell QC metrics, feature-type annotations, and any PCA/clustering/UMAP results. Export those separately if you need them downstream.

Should I convert the whole matrix or subset it first?

Subset first unless the destination is another sparse-matrix-aware tool. Densifying tens of thousands of genes across thousands of cells can exceed available RAM and produces a file nobody will actually read row by row. Subsetting to the top 2,000-5,000 variable genes keeps the file small enough to be useful.

CSV or TSV, which should I use?

TSV. Gene descriptions and some annotation fields can contain commas, which breaks naive CSV parsing, and tab-delimited files are what most command-line bioinformatics tools expect by default. Reach for CSV only when the destination explicitly asks for it, like a spreadsheet import.

Related pages

Related reading on the blog

Sources

  1. Feature-Barcode Matrices | 10X Genomics Support — Specification for the matrix.mtx / features.tsv / barcodes.tsv triplet, indexing, and feature types
  2. scanpy.read_10x_mtx, Scanpy documentation — Parameters for var_names, make_unique, and gex_only used in the scanpy commands
  3. Read10X • Seurat — Read10X() behavior, default gene-name column, and multi-modality list return
  4. DropletUtils Bioconductor Package — read10xCounts() and SingleCellExperiment metadata structure