Conversion · 10x MTX (Matrix Market) → h5ad (AnnData)
How to Convert 10x MTX to h5ad (and Why IDs Go Missing)
The default read call quietly swaps your Ensembl IDs for gene symbols, and the duplicate-name patch that follows hides the damage instead of fixing it.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 2 min read
- 10x MTX (Matrix Market)
- matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz · coordinates: n/a
- h5ad (AnnData)
- .h5ad · coordinates: n/a
You hit this conversion the moment you leave Seurat/R for the scverse stack: Cell Ranger hands you filtered_feature_bc_matrix/ with matrix.mtx.gz, features.tsv.gz, and barcodes.tsv.gz, and scanpy wants one .h5ad file. It's a one-line call, which is exactly why people stop paying attention to it.
Two things change on the way in. First, orientation: MTX stores genes as rows and cells as columns; AnnData stores cells as rows and genes as columns. scanpy.read_10x_mtx transposes this for you automatically, so never transpose it yourself afterward or you'll flip it back. Second, and more consequential, read_10x_mtx defaults var_names to gene symbols from column 2 of features.tsv.gz, discarding the Ensembl ID in column 1.
The silent failure: gene symbols are not unique. Isoform reannotation and historical renaming mean several Ensembl IDs can share one symbol. With the default settings, read_10x_mtx runs var_names_make_unique() behind the scenes and appends -1, -2 suffixes to the colliding symbols. Nothing errors. You get a clean-looking AnnData object, and only months later, when you try to join results back to a GTF or a pathway database by ID, do you discover the Ensembl IDs that would have disambiguated those genes are simply gone.
The commands
Type your file names once; every command below updates.
01scanpyv1.7.2
pythonimport scanpy as sc adata = sc.read_10x_mtx('sample.matrix.mtx.gz', var_names='gene_ids')Reads the matrix.mtx.gz/features.tsv.gz/barcodes.tsv.gz directory and transposes to cells-by-genes automatically. var_names='gene_ids' keeps column 1 of features.tsv.gz (Ensembl ID) as the index instead of the default gene-symbol column, so nothing gets collapsed by later deduplication. Assumes Cell Ranger v3+ gzipped output; {input} is the directory, not a single file.
02anndatav0.13.2
pythonadata.write_h5ad('sample.h5ad', compression='gzip')Writes the AnnData object built from the read step to h5ad, storing X as CSR sparse by default and gzip-compressing the file. The input here is the in-memory adata object from the previous command, not the original MTX directory.
03anndatav0.13.2
pythonfrom scipy.sparse import csr_matrix adata.X = csr_matrix(adata.X) adata.write_h5ad('sample.h5ad', compression='gzip')Use this when some upstream step (a normalization or transform) returned a dense numpy array for adata.X. Casts back to CSR before writing so the h5ad doesn't ship a dense multi-gigabyte matrix where a sparse one would do.
04scanpyv1.7.2
pythonadata = sc.read_10x_h5('sample.matrix.mtx.gz') adata.var_names_make_unique()Alternative entry point for Cell Ranger's single filtered_feature_bc_matrix.h5 file instead of the three-file MEX directory. Uses the default gene-symbol var_names, so duplicate symbols get numeric suffixes appended; this masks the ID loss rather than fixing it, so only use it when you deliberately want symbol-based indexing.
Coordinates, strand, names, builds
No genomic coordinates or strand are involved; this is a matrix-layout and ID conversion, not a positional one. The Matrix Market spec itself uses 1-based row/column indices inside matrix.mtx, but that's invisible once scipy/scanpy parse it into 0-based sparse arrays, so don't hand-roll a parser and forget the offset. The orientation flip matters more here: MTX has genes as rows and cells as columns, AnnData has cells as rows and genes as columns, and read_10x_mtx transposes this for you, so applying .T yourself afterward silently un-does it. Cell barcode suffixes like -1 are preserved as-is in obs_names, not stripped. The real metadata risk is which column of features.tsv.gz becomes var_names: gene_ids keeps the Ensembl ID as the unique index, gene_symbols (the default) discards it and pushes duplicates through var_names_make_unique, and the feature_type column (Gene Expression, Antibody Capture, etc.) only survives in adata.var if you read it explicitly rather than relying on defaults.
Check the output before you trust it
01Check the shape matches cell/gene counts
pythonadata.shapeExpected (n_cells, n_genes), cells first, matching the barcode and feature counts from Cell Ranger's own output; if it's genes-first you transposed the matrix again after read_10x_mtx already did it for you.
02Confirm var_names are Ensembl IDs, not symbols
pythonadata.var_names[:5].tolist()Expected Values like 'ENSG00000000003', not symbol strings like 'TSPAN6'; symbols mean you loaded with the default var_names and lost the IDs.
03Check for duplicate var_names
pythonadata.var_names.duplicated().sum()Expected 0 when loaded with var_names='gene_ids'; any nonzero count means you're on gene symbols and need to reload with gene_ids instead of patching after the fact.
04Confirm X is still sparse before writing
pythonimport scipy.sparse as sp sp.issparse(adata.X)Expected True; False means something densified the matrix upstream and your h5ad will be far larger than it needs to be.
05Cross-check obs/var counts against the source files
bashzcat barcodes.tsv.gz | wc -l zcat features.tsv.gz | wc -lExpected First count equals adata.n_obs, second equals adata.n_vars; a mismatch means you read the wrong directory or an unfiltered matrix by accident.
Errors you will see, and what they mean
- KeyError: '1' when reading features.tsv
- Cause: features.tsv/genes.tsv has only one or two columns instead of the expected three (gene_id, gene_symbol, feature_type), often from older Cell Ranger v2 output or a hand-edited file. Fix: Verify the file is tab-separated with three columns before calling read_10x_mtx; regenerate it from the original Cell Ranger output rather than editing it by hand.
- ValueError when the features file has an unexpected column count
- Cause: Someone manually added or removed a column (commonly trying to bolt on gene_ids and feature_types) so the file no longer matches the 3-column schema scanpy expects. Fix: Keep features.tsv.gz exactly as Cell Ranger produced it; don't hand-edit column structure.
- read_10x_mtx can't find matrix.mtx.gz / silently reads the wrong files
- Cause: read_10x_mtx expects gzipped files (Cell Ranger v3+ default); pointing it at an unzipped older v2 directory or manually decompressed files breaks the expected filenames. Fix: Gzip the three files, or point read_10x_mtx at the matching v2/v3 directory so filenames match matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz.
- var_names show up as TSPAN6-1, TSPAN6-2 downstream
- Cause: Default var_names='gene_symbols' plus the automatic make_unique step masked that multiple Ensembl IDs share one symbol, and the original ID that would disambiguate them was dropped on read. Fix: Reload with var_names='gene_ids', or store symbols separately in adata.var['gene_symbols'] and keep gene_ids as the index.
- h5ad file is far bigger than expected
- Cause: adata.X was densified by an earlier processing step (a normalization or a library call that returns a numpy array) before write_h5ad ran, so the sparsity from the original MTX is lost on disk. Fix: Cast back with scipy.sparse.csr_matrix(adata.X) immediately before calling write_h5ad.
Questions people ask
- Why does read_10x_mtx transpose my matrix?
Cell Ranger's MTX output stores genes as rows and cells as columns; AnnData's convention is the opposite, cells as rows and genes as columns. read_10x_mtx transposes automatically on load, so don't transpose the array yourself afterward or you'll undo the fix and end up with genes-as-obs.
- Should I load gene symbols or Ensembl IDs as var_names?
Use var_names='gene_ids'. Gene symbols aren't unique across Ensembl IDs, so loading symbols forces var_names_make_unique() to paper over collisions by appending numeric suffixes and silently dropping the ID that would have told the genes apart. Keep symbols around separately in adata.var['gene_symbols'] if you want them for plotting labels.
- My var_names have suffixes like TSPAN6-1 and TSPAN6-2, what happened?
That's var_names_make_unique() running under the default var_names='gene_symbols'. Two or more Ensembl IDs map to that symbol, and the original ID that could have kept them distinct was discarded on read. Reload with var_names='gene_ids' rather than trying to patch the symbols after the fact.
- What's the difference between read_10x_mtx and read_10x_h5?
read_10x_mtx reads the three-file MEX directory (matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz); read_10x_h5 reads Cell Ranger's single filtered_feature_bc_matrix.h5 file instead. Both produce the same AnnData shape and both default to gene-symbol var_names, so the ID-loss problem and the gene_ids fix apply equally to either function.
- Is it worth gzip-compressing the h5ad file?
write_h5ad(path, compression='gzip') trades a bit of write time for a smaller file on disk, which matters for anything you'll archive or hand to a collaborator. For scratch files you're about to overwrite in the same pipeline run, skip it and save the CPU time.
Related pages
- Convert · How to Convert 10x HDF5 to h5ad (and Why IDs Go Missing)
- Convert · How to Convert 10x MTX to Seurat object (and Why IDs Go Missing)
- Convert · How to Convert CSV/TSV count table to Seurat object (and Why IDs Go Missing)
- Convert · How to Convert h5ad to Seurat object (Without Losing Your Metadata)
Related reading on the blog
Sources
- scanpy.read_10x_mtx documentation — var_names and make_unique parameter behavior, gzip expectation
- 10x Genomics Feature-Barcode Matrices (MEX Format) — Structure of matrix.mtx.gz, features.tsv.gz, barcodes.tsv.gz
- AnnData write_h5ad documentation — Default CSR storage and gzip compression on write
- AnnData file format specification — CSR/CSC sparse matrix encoding inside h5ad
- var_names_make_unique() - scverse discourse — Duplicate gene symbol problem and why gene_ids avoids it
- read_10x_mtx parsing issues - scverse discourse — KeyError/ValueError from malformed features.tsv column counts