Conversion · 10x HDF5 (filtered_feature_bc_matrix.h5) → h5ad (AnnData)
How to Convert 10x HDF5 to h5ad (and Why IDs Go Missing)
read_10x_h5 looks like a one-line import, but its two default arguments quietly decide which features and which gene identifiers survive into your AnnData object.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read
- 10x HDF5 (filtered_feature_bc_matrix.h5)
- .h5 · coordinates: n/a
- h5ad (AnnData)
- .h5ad · coordinates: n/a
You reach for this conversion the moment a Cell Ranger run needs to leave the 10x/Seurat world and enter Scanpy, scvi-tools, or anything else built on AnnData. filtered_feature_bc_matrix.h5 already has everything read_10x_h5() needs in one file: barcodes, features, and the counts matrix, so there's no reason to reconstruct it from the three separate MTX/TSV files.
Two defaults decide what actually makes it into the AnnData object, and neither one raises a warning. gex_only=True filters the matrix down to Gene Expression features at read time, before you've written a single line of downstream code, so Antibody Capture, CRISPR Guide Capture, and Custom features from a CITE-seq or multiome run are gone unless you explicitly ask to keep them. Separately, var_names default to gene symbol, not the Ensembl ID that Cell Ranger's reference GTF actually assigned, and symbols are not 1:1 with IDs.
The failure that bites people months later: two samples were processed with different Cell Ranger reference packages, one with versioned Ensembl IDs and one without, or one run was CITE-seq and the h5ad only has RNA. Nobody notices at conversion time because read_10x_h5() returns a valid, non-empty AnnData object either way. It only surfaces when you try to merge two h5ad files by var_names and get a near-empty intersection, or a collaborator asks where the protein counts went.
The commands
Type your file names once; every command below updates.
01scanpy
pythonimport scanpy as sc adata = sc.read_10x_h5("sample.h5")Reads the 10x HDF5 matrix into an AnnData object with cells as obs (rows) and genes as var (columns). Assumes gene-expression-only data; defaults gex_only=True, so any Antibody Capture, CRISPR Guide Capture, or Custom features in the file are dropped here, silently.
02scanpy
pythonadata = sc.read_10x_h5("sample.h5", gex_only=False)Use this instead of the plain call whenever the run is CITE-seq or 10x Multiome (ATAC + Gene Expression). Keeps all feature types; check adata.var['feature_types'].value_counts() afterward to confirm what came through before splitting modalities.
03scanpy
pythonadata.var_names = adata.var["gene_ids"] adata.var_names_make_unique()Swaps the AnnData index from gene symbol (the read_10x_h5 default) to the Ensembl gene ID that Cell Ranger's reference GTF actually assigned. Run this before merging with any other dataset, symbols collide and drift across annotation releases in ways Ensembl IDs don't. make_unique() is a defensive no-op if IDs are already unique.
04scanpy
pythonadata.write_h5ad("sample.h5ad")Writes the AnnData object to disk. Assumes var_names and any var columns you care about (gene_ids, feature_types) are plain string/categorical dtypes, not something you built by hand from a non-string index, that's the pattern that causes var_names to come back as integers on the next load.
Coordinates, strand, names, builds
Neither format encodes genomic coordinates or strand, this is a feature-count matrix conversion, not an interval-file conversion, so 0-based/1-based and chromosome naming don't apply here. What does change: orientation matches by convention (both 10x HDF5 and AnnData store cells as rows/obs and genes as columns/var, which is already the transpose of the genes-as-rows convention Seurat and Matrix::dgCMatrix use in R, so this direction needs no manual transpose, but going back to R does). Feature-type scope changes with the gex_only default, dropping non-Gene-Expression features before the h5ad ever exists. Gene identity changes from Ensembl ID (the id field Cell Ranger derived from its reference GTF's gene_id) to gene symbol (the name field) as the primary var_names, with the Ensembl ID preserved only as a secondary var['gene_ids'] column, and Ensembl ID version suffixes may or may not be present depending on which Cell Ranger reference package built the file. Genome metadata is dropped by default for modern references and must be requested explicitly for legacy multi-genome HDF5 files. Categorical var columns can be miscast to integer codes on write if you reassign var_names or var columns from a non-plain-string source before saving.
Check the output before you trust it
01Shape matches Cell Ranger's own cell and feature counts
pythonprint(adata.shape)Expected (n_cells, n_genes) with n_cells matching the 'Estimated Number of Cells' in Cell Ranger's metrics_summary.csv and n_genes matching features.tsv.gz line count (minus any dropped feature types).
02var_names are unique and gene_ids are populated
pythonprint(adata.var_names.is_unique) print(adata.var['gene_ids'].head())Expected is_unique is True; gene_ids shows Ensembl-style IDs (ENSG... for human, ENSMUSG... for mouse), not blank or numeric.
03All expected feature types are present for multimodal runs
pythonprint(adata.var['feature_types'].value_counts())Expected Shows every modality you expect (e.g. 'Gene Expression' and 'Antibody Capture' for CITE-seq); if a modality you expect is missing, gex_only defaulted to True on read.
04var_names survive a round trip through h5ad
pythonadata.write_h5ad('{output}') back = sc.read_h5ad('{output}') print(back.var_names[:5].tolist())Expected Same gene symbols or IDs as before writing, as strings, not an integer index like 0, 1, 2, 3, 4.
05Total counts roughly match Cell Ranger's reported UMI total
pythonprint(adata.X.sum())Expected In the same order of magnitude as the total UMI count implied by metrics_summary.csv's mean reads/UMIs per cell times cell count; wildly off suggests you loaded the raw matrix instead of filtered, or gex_only dropped a modality you were counting on.
06X is sparse, not dense
pythonprint(type(adata.X))Expected scipy.sparse.csr_matrix or csc_matrix. A dense numpy array means something upstream densified the matrix and will blow up memory on a large dataset.
Errors you will see, and what they mean
- Antibody Capture / protein counts missing from adata after read_10x_h5
- Cause: gex_only defaults to True, which filters the matrix down to 'Gene Expression' features at read time and discards Antibody Capture, CRISPR Guide Capture, and Custom features. Fix: Call sc.read_10x_h5(path, gex_only=False) instead, then split modalities using adata.var['feature_types'] afterward.
- ValueError naming the genome, or wrong gene counts, when reading an older Cell Ranger HDF5
- Cause: Legacy (pre-3.0) Cell Ranger HDF5 files can bundle multiple genomes in one file, and read_10x_h5 needs to know which one to filter to. Fix: Pass genome='GRCh38' (or whatever genome name appears in the file's features/genome field) explicitly to read_10x_h5.
- var_names in the loaded h5ad are integers like 0, 1, 2 instead of gene names or IDs
- Cause: The var DataFrame's index was a categorical or otherwise non-plain-string column when the file was written, and got cast to integer codes on save, a known failure mode when var_names is reassigned from a non-string source. Fix: Before calling write_h5ad, confirm adata.var_names.dtype is object/string with adata.var_names.astype(str), and re-check after a round trip through read_h5ad.
- 'Variable names are not unique' warning after loading
- Cause: read_10x_h5 sets var_names to gene symbol by default, and multiple Ensembl gene IDs can map to the same symbol, producing duplicate var_names. Fix: Call adata.var_names_make_unique(), or better, switch var_names to the Ensembl gene_ids column, which doesn't collide.
- Merging two h5ad files by var_names yields almost no overlapping genes
- Cause: The two files were built from different Cell Ranger reference package versions, so one has versioned Ensembl IDs (ENSG00000141510.15) and the other unversioned (ENSG00000141510), or gene symbols changed between annotation releases. Fix: Match on gene_ids after stripping any version suffix (split on '.'), don't merge on gene symbol across reference versions.
Questions people ask
- Why are my antibody capture or CITE-seq counts missing after read_10x_h5?
gex_onlydefaults toTrue, which keeps onlyGene Expressionfeatures and silently drops Antibody Capture, CRISPR Guide Capture, and Custom feature types. Passgex_only=Falseto keep everything the HDF5 file actually contains, then split modalities yourself usingadata.var['feature_types'].- Should var_names be gene symbol or Ensembl ID after conversion?
read_10x_h5()setsvar_namesto gene symbol by default and stashes the Ensembl ID inadata.var['gene_ids']. Symbols collide across genes and change between annotation releases, so if you plan to merge datasets or map to other databases, switchvar_namestogene_idsright after loading, as covered in [[58]].- Do I need to convert to h5ad every time, or can I just call read_10x_h5 directly?
You can call
sc.read_10x_h5()fresh every session, but writing it to h5ad once means you fix thegex_onlyandvar_namesdecisions a single time instead of re-deciding them on every load, and loading h5ad back is faster than re-parsing HDF5.- Does converting to h5ad lose the raw vs. filtered barcode distinction from Cell Ranger?
Yes.
filtered_feature_bc_matrix.h5only contains cell-called barcodes, so the resulting h5ad has no ambient/empty-droplet barcodes. If you need those for background correction with tools like CellBender or SoupX, convertraw_feature_bc_matrix.h5separately, it is a different file, not a parameter.- Why does merging two h5ad files from different Cell Ranger runs produce almost no shared genes?
If the two runs used different reference package versions, one reference may carry versioned Ensembl IDs (ENSG00000141510.15) and the other unversioned IDs (ENSG00000141510), or the gene symbol for the same Ensembl ID may have changed between annotation releases. Match on
gene_idsafter stripping any version suffix, don't trust symbol matching across reference versions.
Related pages
- Convert · How to Convert 10x MTX to h5ad (and Why IDs Go Missing)
- Convert · How to Convert 10x HDF5 to Seurat object (and Why IDs Go Missing)
- Glossary · Effect size
- Glossary · CITE-seq
Related reading on the blog
Sources
- scanpy.read_10x_h5, Scanpy documentation — read_10x_h5 parameters: gex_only, genome, and what gets stored in .var
- AnnData on-disk format documentation — how var DataFrames, their index, and categorical columns are stored in h5ad
- Gene names lost when converting from Seurat RDS to anndata h5ad, GitHub Issue — real-world case of var index being cast to integers on h5ad save