Chatomics Field GuideWhat They Don't Teach You

Conversion · 10x HDF5 (filtered_feature_bc_matrix.h5) → Seurat object (RDS)

How to Convert 10x HDF5 to Seurat object (and Why IDs Go Missing)

Read10X_h5 hands you a matrix with the wrong row names by default, and nobody notices until a marker gene lookup comes back empty.

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
Seurat object (RDS)
.rds, .RDS · coordinates: n/a

You need this conversion the moment Cell Ranger finishes: filtered_feature_bc_matrix.h5 holds barcodes, features, and the count matrix in one file, and Read10X_h5() reads it directly, no unpacking the three-file MTX bundle (matrix.mtx.gz, barcodes.tsv.gz, features.tsv.gz) required. If the h5 file exists, use it; it is one function call instead of three files that have to stay in sync.

What changes on the way in: Read10X_h5() defaults to use.names = TRUE, which swaps the Ensembl gene ID for the gene symbol as the matrix row name. The Ensembl ID, the feature_type, and the genome fields that live in the h5's /matrix/features/ group do not travel into the Seurat object's rownames at all, they simply aren't there once you've read the file. If Cell Ranger detected more than one feature type (Gene Expression plus Antibody Capture, for CITE-seq), Read10X_h5() returns a list of matrices, not one matrix, and CreateSeuratObject() will not accept the list itself.

The silent failure mode is gene ID collision. Ensembl ID-to-symbol is not one-to-one: annotation updates and alternate names mean one Ensembl ID can correspond to more than one symbol, or vice versa. Seurat's unique.features = TRUE default resolves the clash by appending .1, .2 to the colliding symbol instead of erroring. Your gene count looks fine, nothing crashes, but an exact-string lookup for that gene now fails silently, and if you later join the object's rownames against a versioned Ensembl ID list from biomaRt or a GTF, you get zero matches because Cell Ranger's IDs in the h5 are unversioned.

The commands

Type your file names once; every command below updates.

  1. 01Seuratv5.5.1

    r
    h5.data <- Read10X_h5(filename = "sample.h5", use.names = TRUE, unique.features = TRUE)
    seurat.obj <- CreateSeuratObject(counts = h5.data, project = "sample", min.cells = 3, min.features = 200)
    saveRDS(seurat.obj, file = "sample.rds")

    use.names = TRUE swaps Ensembl IDs for gene symbols as rownames; unique.features = TRUE (default) silently appends .1/.2 to any symbol collision instead of erroring. min.cells = 3 and min.features = 200 drop rare genes and near-empty cells before you ever inspect the raw matrix, so the object you get back is already filtered.

  2. 02Seuratv5.5.1

    r
    h5.data <- Read10X_h5(filename = "sample.h5")
    seurat.obj <- CreateSeuratObject(counts = h5.data[["Gene Expression"]])
    seurat.obj[["ADT"]] <- CreateAssayObject(counts = h5.data[["Antibody Capture"]])
    saveRDS(seurat.obj, file = "sample.rds")

    Assumes the h5 file has two feature types (CITE-seq). Read10X_h5 returns a named list here, not a matrix, so you must extract each element by name; passing the raw list to CreateSeuratObject fails. The second modality is added as a separate assay, it does not merge into the RNA counts matrix.

  3. 03Seuratv5.5.1

    r
    h5.data <- Read10X_h5(filename = "sample.h5", use.names = FALSE)
    seurat.obj <- CreateSeuratObject(counts = h5.data)
    saveRDS(seurat.obj, file = "sample.rds")

    use.names = FALSE keeps the unversioned Ensembl ID as the rowname instead of the gene symbol, so there are no symbol collisions to dedup. You trade readability for stability: every marker-gene plot and lookup downstream now needs a separate ID-to-symbol table.

  4. 04Seuratv5.5.1

    r
    seurat.obj <- readRDS("sample.rds")
    Layers(seurat.obj[["RNA"]])
    sum(duplicated(rownames(seurat.obj)))

    Reads back the object one of the prior commands wrote and checks two things at once: which layers (counts, data, scale.data) actually exist in the v5 assay, and whether any rowname collisions survived. Run this before handing the RDS to a collaborator or a pipeline step.

Coordinates, strand, names, builds

Neither the h5 file nor the Seurat object carries genomic coordinates or strand information; both are expression matrices (features by cells), so 0-based vs 1-based and chromosome naming don't apply here. What does change is the feature identifier system: the h5's /matrix/features/id field holds unversioned Ensembl gene IDs, /matrix/features/name holds gene symbols, and Read10X_h5()'s use.names argument decides which one becomes the Seurat object's rownames, the other is discarded entirely rather than kept as metadata. feature_type and genome (which reference build Cell Ranger aligned against) also live in the h5 features group and do not carry into the Seurat object's rownames or default metadata; if you need to know the build later, that has to come from your Cell Ranger run parameters, not from the RDS file itself.

Check the output before you trust it

  1. 01Cell and gene counts match Cell Ranger's summary

    r
    dim(seurat.obj)

    Expected Features x cells should be in the same ballpark as Cell Ranger's web_summary.html estimated cell count and detected gene count, after accounting for min.cells/min.features filtering.

  2. 02No silent duplicate rownames

    r
    sum(duplicated(rownames(seurat.obj)))

    Expected 0. If it's not 0, unique.features didn't run or you built the object from a matrix that bypassed Read10X_h5's deduplication.

  3. 03Rownames are the ID type you expect

    r
    head(rownames(seurat.obj))

    Expected Gene symbols like TP53, GAPDH if use.names = TRUE; Ensembl IDs like ENSG00000141510 if use.names = FALSE. If you expected symbols and see Ensembl IDs (or the reverse), use.names was set wrong.

  4. 04Renamed collision suffixes are present and traceable

    r
    grep("\\.[0-9]+$", rownames(seurat.obj), value = TRUE)

    Expected Either an empty character(0), or a short list of symbols with .1/.2 suffixes that you can trace back to a specific Ensembl ID via features.tsv.gz before trusting any marker analysis that touches them.

  5. 05v5 assay has the layer you think it has

    r
    Layers(seurat.obj[["RNA"]])

    Expected At minimum "counts". If normalization already ran, also "data". Missing "counts" after a fresh conversion means the wrong matrix (e.g., normalized data) was passed to CreateSeuratObject.

  6. 06Multimodal object has both assays if expected

    r
    Assays(seurat.obj)

    Expected c("RNA", "ADT") for a CITE-seq h5. Only "RNA" showing up means the Antibody Capture list element was never extracted and added as a separate assay.

Errors you will see, and what they mean

Error in as.sparse(x = counts) : unable to find an inherited method for function 'as.sparse' for signature 'x = "list"'
Cause: Read10X_h5() returned a list (the h5 file has more than one feature type) and the whole list was passed straight into CreateSeuratObject instead of one named element. Fix: Index the list first, e.g. CreateSeuratObject(counts = h5.data[["Gene Expression"]]), and add any other modality separately with CreateAssayObject().
A gene you know is in the sample (e.g. ATXN7) is missing from the Seurat object, but ATXN7.1 is there instead
Cause: unique.features = TRUE deduplicated a symbol collision by appending a suffix; the gene wasn't dropped, its rowname changed. Fix: Search rownames with a regex like grep("^ATXN7", rownames(seurat.obj)), then confirm identity against the Ensembl ID in features.tsv.gz before using either row.
Zero rows match when joining Seurat rownames against a biomaRt or GTF-derived Ensembl ID list
Cause: Gene-ID-version mismatch: Cell Ranger's h5 stores unversioned Ensembl accessions, while biomaRt or a newer GTF often returns versioned ones (ENSG00000141510.15). Fix: Strip the version suffix before joining: sub("\\..*", "", ids), or re-pull the annotation without version numbers.
Seurat object has noticeably fewer genes or cells than the h5 file
Cause: CreateSeuratObject's default-style filtering (min.cells = 3, min.features = 200, when set explicitly as in the standard tutorial) silently drops rare genes and low-count cells at object creation, before any QC step you'd normally see. Fix: Set min.cells = 0, min.features = 0 if you want the unfiltered matrix preserved, and apply your own QC thresholds explicitly afterward so the cutoffs are visible in your script.
Downstream function errors with something like Layer 'data' is not found
Cause: A v5 Assay5 object built straight from Read10X_h5 only has a counts layer; NormalizeData() or similar hasn't been run yet, so the data layer doesn't exist. Fix: Run Layers(seurat.obj[["RNA"]]) to see what's actually there, then call NormalizeData() (or the relevant preprocessing step) before anything that expects a data layer.

Questions people ask

Why does Read10X_h5 return a list instead of a matrix?

Because the h5 file has more than one feature type, most commonly Gene Expression plus Antibody Capture from a CITE-seq run. Each feature type comes back as its own sparse matrix inside the list, keyed by name (e.g. h5.data[["Gene Expression"]]). Passing the whole list to CreateSeuratObject() fails; you must extract the element for the modality you're building the primary assay from.

Should I set use.names = TRUE or FALSE when reading the h5 file?

TRUE (the default) is right for interactive work and plotting, since gene symbols are what you and your collaborators recognize. But keep a copy of the Ensembl ID (use.names = FALSE, or the id column from a matching features.tsv.gz) somewhere, because any tool or pipeline downstream that expects Ensembl IDs will silently mis-join against symbol rownames.

Why do I have gene names like TP53.1 or ATXN7.2 in my Seurat object?

Seurat's unique.features = TRUE default renamed a duplicated gene symbol to keep rownames unique, which happens when two Ensembl IDs map to the same symbol or an annotation update introduced a naming collision. Look up the original Ensembl ID from the features.tsv.gz id column (or an id/name pair pulled from the h5 directly) to confirm which gene .1 actually is before you drop or merge it.

Why don't my Seurat object's gene IDs match my GTF or biomaRt results?

Cell Ranger's h5 feature IDs are unversioned Ensembl accessions (ENSG00000141510), while biomaRt or a GTF pulled from a different Ensembl release often carries versioned IDs (ENSG00000141510.15). Strip the version suffix with sub("\\..*", "", ids) before joining, or confirm both sources are unversioned.

Does converting to a Seurat v5 object lose the counts if I only load one layer?

No, but a v5 Assay5 object can hold just a data layer without counts if that's all you gave it, and functions that expect raw counts (like most normalization steps) will error or behave unexpectedly if counts isn't there. Check Layers(seurat.obj[["RNA"]]) right after conversion to confirm which layers actually exist before running anything downstream.

Related pages

Related reading on the blog

Sources

  1. Read 10X hdf5 file, Read10X_h5 • Seurat — use.names and unique.features defaults, list return for multimodal h5 files
  2. Using Seurat with multimodal data • Seurat — extracting list elements from Read10X_h5 and adding a second assay with CreateAssayObject
  3. Cell Ranger Feature Barcode Matrices (HDF5 Format) | Official 10x Genomics Support — h5 features group layout: id, name, feature_type, genome
  4. Cell Ranger Feature Barcode Matrices (MEX Format) | Official 10x Genomics Support — features.tsv column layout, ID falls back to name when GTF has no gene_name
  5. Seurat - Guided Clustering Tutorial • Seurat — CreateSeuratObject min.cells and min.features defaults used in the standard workflow
  6. Gene names and ensembl IDs · Issue #2976 · satijalab/seurat — Seurat rownames are just matrix rownames and can be any ID system
  7. Integrating with a reference that uses ENSEMBL IDs while samples use gene symbols · Issue #3535 · satijalab/seurat — one Ensembl ID mapping to multiple symbols and the resulting duplicate rowname problem
  8. CRAN: Package Seurat — current Seurat version and v5 bracket-accessor syntax