Chatomics Field GuideWhat They Don't Teach You

Conversion · h5ad (AnnData) → Seurat object (RDS)

How to Convert h5ad to Seurat object (Without Losing Your Metadata)

anndataR and capseuratconverter handle the transpose and slot mapping for you; deciding which layer actually becomes your counts slot is still on you.

By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Updated 2026-09-13 · 3 min read

h5ad (AnnData)
.h5ad · coordinates: n/a
Seurat object (RDS)
.rds, .RDS · coordinates: n/a

You end up here when a collaborator hands you an h5ad from a scanpy or Cell Ranger-to-scanpy pipeline, but your clustering, integration, or plotting workflow is built in Seurat. It also happens constantly with public datasets: most single-cell atlases ship as h5ad, and your lab's pipeline is R.

The conversion itself is mostly mechanical. AnnData's X (cells as rows, genes as columns) gets transposed into Seurat's convention (genes as rows, cells as columns). obs becomes @meta.data, var becomes feature-level metadata, obsm entries like PCA and UMAP coordinates become @reductions, and each entry in layers becomes a Seurat layer under the same name. uns, the unstructured, dataset-level annotations, has no guaranteed home in Seurat's object model and is the thing most likely to just disappear unless your tool has an explicit mapping for it.

The failure that won't show up as an error: AnnData's X frequently holds log-normalized data, with raw counts sitting in adata.raw.X or a separate layer. If your converter drops X straight into Seurat's counts slot, you get a Seurat object that looks completely normal, it clusters, it plots, but every count-based test downstream (FindMarkers, a pseudobulk DESeq2 run) is now computing on the wrong scale. Nothing crashes. The p-values are just wrong.

The commands

Type your file names once; every command below updates.

  1. 01anndataRvanndataR 1.2.1

    r
    seurat_obj <- anndataR::read_h5ad("sample.h5ad", as = "Seurat")
    saveRDS(seurat_obj, "sample.rds")

    One-shot conversion. anndataR reads the h5ad HDF5 hierarchy directly in R (no reticulate/Python needed), transposes X so genes become rows and cells become columns, and maps obs to meta.data, var to feature metadata, and obsm entries to reduction slots. Assumes the h5ad file is well-formed and that you're fine with whatever ended up in X landing in the default assay's data slot.

  2. 02anndataR

    r
    adata <- anndataR::read_h5ad("sample.h5ad")
    # inspect adata$layers, adata$X, adata$raw before committing
    seurat_obj <- adata$as_Seurat(layers_mapping = list(counts = "counts", data = "X"))
    saveRDS(seurat_obj, "sample.rds")

    Two-step path so you can inspect adata$layers and adata$raw before conversion and pass an explicit layers_mapping. Use this whenever the h5ad has more than one layer (counts, X, csc_counts, dense_X...) and you can't trust which one anndataR would pick by default for the counts slot.

  3. 03capseuratconverter

    r
    capseuratconverter::h5ad2rds("sample.h5ad", ignore_bad_format = FALSE)

    Converts and writes RDS in one call, saving next to the source file with the .h5ad extension swapped for .rds, there's no separate {output} argument. ignore_bad_format = FALSE means it halts on the first formatting problem instead of silently skipping it, which is what you want the first time you run this on a new dataset.

  4. 04capseuratconverter

    r
    seurat_obj <- capseuratconverter::h5ad_to_seurat("sample.h5ad", ignore_bad_format = TRUE)
    saveRDS(seurat_obj, "sample.rds")

    Returns the Seurat v5 object in memory instead of writing straight to disk, so you can fix the counts layer (see coordinate/strand notes) before saving. ignore_bad_format = TRUE skips formatting issues rather than erroring, only use this after you've already seen what h5ad2rds() with FALSE complained about, so you know what you're choosing to ignore.

Coordinates, strand, names, builds

This pair has no genomic coordinates or strand to track, the thing that silently changes shape here is matrix orientation and slot mapping, not base-pair positions. AnnData stores cells as rows and genes as columns; Seurat stores the transpose (genes as rows, cells as columns). Every conversion tool listed here (anndataR, capseuratconverter, zellkonverter) handles this transpose automatically, but if you ever hand-roll a conversion from the raw HDF5, forgetting the transpose is the first thing that will silently give you a gene-count matrix indexed backwards.

Sparse storage also changes representation, not just format: Python holds X and layers as scipy.sparse csr_matrix/csc_matrix, R expects Matrix::dgCMatrix. A correct converter bridges these losslessly; a broken or manual one densifies the matrix, which still "works" but blows up memory and hides the fact that sparsity was lost.

Metadata mapping is the other place things quietly change: obs becomes @meta.data, var becomes the assay's feature-level metadata, obsm entries (PCA, UMAP coordinates) become @reductions, and layers become Seurat layers under whatever name they had in the h5ad. uns (unstructured annotations, dataset-level notes, color palettes, custom parameters) has no fixed home in Seurat's object model and is the most likely thing to be dropped entirely unless the tool you're using has an explicit mapping argument for it. There's no genome build or chromosome-naming concern here since this conversion never touches genomic coordinates.

Check the output before you trust it

  1. 01Cell and gene counts match the source h5ad

    r
    dim(seurat_obj)  # should read (n_genes, n_cells)
    # compare against adata.shape (n_cells, n_genes) from the Python side, reversed

    Expected nrow(seurat_obj) equals the h5ad's n_vars (genes), ncol(seurat_obj) equals its n_obs (cells), reversed from the AnnData shape, not equal to it.

  2. 02Counts slot actually holds counts

    r
    counts <- GetAssayData(seurat_obj, layer = "counts")
    all(counts@x == floor(counts@x)) && all(counts@x >= 0)

    Expected TRUE. If FALSE, X (or whatever layer landed in counts) was normalized/log data, not raw counts, and every downstream count-based test on this object is invalid.

  3. 03Matrix is still sparse

    r
    class(GetAssayData(seurat_obj, layer = "counts"))

    Expected dgCMatrix (or dgTMatrix/dgRMatrix). A plain matrix or array means the conversion densified the data.

  4. 04Metadata columns survived

    r
    ncol(seurat_obj@meta.data)

    Expected Roughly matches the number of columns in the h5ad's obs data frame (allow a small delta for a few converter-added columns like nCount_RNA).

  5. 05Dimensional reductions carried over

    r
    names(seurat_obj@reductions)

    Expected Includes "pca" and/or "umap" if the h5ad's obsm had X_pca / X_umap. An empty list when the source clearly had embeddings means they were dropped.

  6. 06No duplicate barcode collisions after conversion

    r
    any(duplicated(colnames(seurat_obj)))

    Expected FALSE. Duplicated cell barcodes usually mean the h5ad merged multiple samples without a batch prefix, and Seurat will silently rename or drop cells to resolve it.

Errors you will see, and what they mean

Error in as(x, "CsparseMatrix") / Assay5 objects are not supported by SeuratDisk
Cause: SeuratDisk was written before Seurat v5's Assay5 layer system and doesn't know how to read or write the newer counts/data/scale.data layer structure. Fix: Don't use SeuratDisk for anything written by Seurat v5 or read into a v5 workflow. Use anndataR's read_h5ad(as = "Seurat") or srtdisk, both of which understand Assay5 layers.
FindMarkers or a pseudobulk DESeq2 run gives implausible p-values or every gene comes back significant
Cause: AnnData's X held log-normalized values (common after scanpy.pp.normalize_total + log1p), and the converter put that matrix straight into Seurat's counts slot. Seurat's count-based tests assume integers. Fix: Before converting, check whether the raw counts live in adata.raw.X or a named layer like adata.layers['counts']. Point the conversion's counts slot at that layer explicitly (layers_mapping in anndataR, or fix adata.raw in Python before writing the h5ad) rather than letting the tool default to X.
h5ad2rds() stops with a formatting error and no RDS file is written
Cause: ignore_bad_format = FALSE (the safe default) halts on the first structural problem it finds in the h5ad, an unexpected dtype, a malformed obs column, an unreadable layer. Fix: Read the specific error to see which field failed. If it's metadata you don't need, rerun with ignore_bad_format = TRUE and then manually check @meta.data and Layers(seurat_obj) afterward to see what got skipped, don't assume TRUE means nothing was lost.
Seurat object is enormous in memory / R session runs out of RAM converting a file that was small on disk
Cause: The h5ad stored X or a layer in a Python sparse format (CSR/CSC/Yale) that got expanded into a dense R matrix instead of a dgCMatrix during conversion. Fix: Check class(seurat_obj[["RNA"]]$counts) after conversion. If it's matrix instead of dgCMatrix, the bridge failed to preserve sparsity, re-run with a tool that explicitly bridges scipy.sparse to Matrix::dgCMatrix (anndataR or zellkonverter's basilisk path) rather than one that densifies on read.
Seurat object has layers named X, csc_counts, dense_X, dense_counts all present at once
Cause: The source h5ad genuinely had multiple redundant layers (common in datasets exported by more than one pipeline stage), and the converter carried every one of them over literally with its original name. Fix: Use layers_mapping to select and rename only the layers you actually need (typically counts and one normalized layer), then drop the rest, don't leave four copies of the same matrix sitting in the object.

Questions people ask

Can I convert h5ad to Seurat without installing Python?

Yes, if you use anndataR, it reads the h5ad HDF5 structure natively in R without going through reticulate or a Python environment. zellkonverter is the opposite case: it uses basilisk-managed Python under the hood, and even then it only gets you to SingleCellExperiment, not Seurat directly.

Does converting h5ad to Seurat keep my UMAP and PCA coordinates?

Yes, anndataR maps obsm entries like X_pca and X_umap into Seurat's @reductions slots automatically. Confirm it worked with names(seurat_obj@reductions) after conversion rather than assuming it.

Why does my Seurat counts slot have decimal values after converting from h5ad?

Because AnnData's X held normalized or log-transformed data instead of raw counts, and the converter put X into the counts slot by default. Check adata.raw or your h5ad's layers for the actual integer counts and map that layer explicitly instead.

What's the difference between SeuratDisk and anndataR for this conversion?

SeuratDisk predates Seurat v5's Assay5 layer system and breaks on objects that use it. anndataR and srtdisk are the current options that understand v5 layers; SeuratDisk is only safe for older Seurat v3/v4-style assays.

Can zellkonverter convert h5ad directly to a Seurat object?

No. zellkonverter converts between AnnData and SingleCellExperiment. Reaching Seurat from h5ad through zellkonverter means an extra step: h5ad to SingleCellExperiment, then SingleCellExperiment to Seurat with Seurat's own as.Seurat().

Related pages

Related reading on the blog

Sources

  1. Read/write Seurat objects using anndataR — Conversion methods, layers_mapping, and reduction/metadata handling
  2. capseuratconverter: GitHub Repository — h5ad2rds() and h5ad_to_seurat() functions and ignore_bad_format behavior
  3. srtdisk: Seurat v5 compatible HDF5 converter — Assay5-compatible alternative to SeuratDisk
  4. zellkonverter: Conversion Between scRNA-seq Objects — h5ad to SingleCellExperiment bridge, no direct Seurat path
  5. GitHub Issue: From anndata objects h5ad to seurat — X vs counts slot discussion, why counts must be integers
  6. Seurat v5 Essential Commands — Layers() and layer access patterns in Seurat v5