Conversion · Seurat object (RDS) → h5ad (AnnData)
How to Convert Seurat object to h5ad (Without Losing Your Metadata)
Seurat v5's split layers and multiple assays don't collapse into a single AnnData X on their own; pick the assay and layer yourself or the converter will guess wrong.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read
- Seurat object (RDS)
- .rds, .RDS · coordinates: n/a
- h5ad (AnnData)
- .h5ad · coordinates: n/a
You need this conversion the moment your analysis leaves R. Scanpy, scVI, squidpy, and most spatial and trajectory tools built after 2020 expect an AnnData .h5ad file, not an RDS blob. If a collaborator, a core facility, or a published pipeline asks for h5ad, converting the Seurat object you already built is faster than reprocessing raw reads in Python.
What changes on the way over: matrices flip orientation (Seurat stores genes as rows, AnnData stores genes as columns), and Seurat v5's habit of splitting counts, data, and scale.data into per-sample layers (counts.1, counts.2, data.1...) has no single home in AnnData's one X slot. You have to pick which layer becomes X and which become adata.layers[...]; a converter that guesses for you usually guesses wrong for scaled or multi-sample objects.
The failure that bites people silently: a factor column in meta.data converts cleanly to a categorical obs column, which looks fine in scanpy. But if you ever read that h5ad back into Seurat, ReadH5AD() can replace the categorical with plain integer codes and drop the labels. Nothing errors. The column is just numbers named 0 and 1 where it used to say disease and control. Check labels after every round trip, not just after the first conversion.
The commands
Type your file names once; every command below updates.
01zellkonverter
rlibrary(Seurat); library(SingleCellExperiment); library(zellkonverter) seu <- readRDS("sample.rds") sce <- as.SingleCellExperiment(seu, assay = "RNA") writeH5AD(sce, "sample.h5ad")Converts the named assay (RNA here, change to match your object) to a SingleCellExperiment first, then zellkonverter's writeH5AD() hands it to Python's anndata via basilisk. Assumes you've already picked one assay and that its counts/logcounts layers are what you want; run JoinLayers(seu) first if it's a Seurat v5 object with per-sample split layers.
02anndataR
rlibrary(anndataR) seu <- readRDS("sample.rds") adata <- as_AnnData(seu, assay_name = "RNA", layers_mapping = c(counts = "counts", data = "logcounts")) adata$write_h5ad("sample.h5ad")layers_mapping is the part people skip: it tells anndataR exactly which Seurat layer becomes X (counts, here) and which become named layers instead of letting the converter default to whatever it finds first. Factor columns in meta.data become categorical obs columns automatically.
03convert2anndata
bashRscript -e 'convert2anndata::cli_convert()' -i sample.rds -o sample.h5adBuilt for Seurat v5 objects with multiple assays and split layers (counts.1, counts.2, ...); also carries over PCA/UMAP/tSNE reductions and cell-pairing data that simpler converters drop. Reach for this when zellkonverter or anndataR choke on a multi-assay v5 object.
04readseurat
bashreadseurat convert sample.rds sample.h5adPure Python, no R or basilisk environment required; reads the .rds file directly. Useful when your R install can't rebuild the object's class definitions, which happens when the RDS was written by a newer Seurat version than the one you have loaded.
Coordinates, strand, names, builds
Neither format carries genomic coordinates, so there's no 0-based/1-based or chromosome-naming issue here. What actually changes: orientation (Seurat stores genes × cells, AnnData stores cells × genes, so as_AnnData()/writeH5AD() transpose the matrix for you; don't transpose it yourself first or you'll flip it twice). Assay and layer selection has no safe default: Seurat v5's counts, data, scale.data, and any per-sample numbered layers must be explicitly assigned to X and to layers, or you'll end up exporting scale.data as your primary matrix without noticing. Metadata typing changes one-way: R factors become obs categoricals, which is correct AnnData behavior, but reading the h5ad back into Seurat with ReadH5AD() is documented to drop the factor labels and leave numeric codes instead (Seurat issue #1508), so don't treat a round trip as lossless. Dimensional reductions (PCA, UMAP, tSNE) move to obsm as X_pca, X_umap, etc. For spatial Seurat objects specifically, image and pixel-coordinate metadata is not guaranteed to survive conversion (Seurat issue #9617); check .obsm and .uns['spatial'] immediately after converting rather than assuming they came along.
Check the output before you trust it
01Cell and gene counts match
pythonprint(adata.shape)Expected adata.shape is (n_cells, n_genes); n_genes equals nrow(seu) and n_cells equals ncol(seu) for the assay you exported.
02X holds the layer you meant to export
pythonprint(adata.X.max(), adata.X.min())Expected adata.X.max() is roughly in the tens-to-hundreds range for raw counts; a max near 0 with negative values means scale.data leaked into X instead.
03Categorical metadata kept its labels
pythonprint(adata.obs['your_column'].cat.categories)Expected adata.obs['your_column'].cat.categories shows the original factor levels (e.g. 'disease', 'control'), not [0, 1].
04Dimensional reductions carried over
pythonprint(list(adata.obsm.keys()))Expected adata.obsm.keys() includes X_pca and X_umap if the source Seurat object had those reductions computed.
05Cell and gene names line up in the same order
pythonprint(adata.var_names[:5].tolist(), adata.obs_names[:5].tolist())Expected adata.var_names[:5] and adata.obs_names[:5] match rownames(seu)[1:5] and colnames(seu)[1:5] exactly, same order.
06Spatial image and coordinate data survived (spatial objects only)
pythonprint(adata.uns.keys())Expected 'spatial' appears as a key in adata.uns matching the image/coordinate data from seu@images; if missing, reattach coordinates manually before analysis.
Errors you will see, and what they mean
- sceasy::convertFormat() or SeuratDisk fails with missing layer errors on a Seurat v5 object (counts.1, counts.2 not found as a single matrix)
- Cause: Seurat v5 stores per-sample data as separate numbered layers, and older converters expect one counts layer per assay Fix: Run seu <- JoinLayers(seu) before converting, or pass the assay parameter explicitly, e.g. assay = c("RNA", "joined")
- After round-tripping through h5ad, a metadata column that used to say 'disease'/'control' now shows 0 and 1
- Cause: ReadH5AD() is documented (Seurat issue #1508) to replace categorical obs columns from h5ad with numeric codes and drop the original factor levels Fix: Keep the canonical copy of metadata in R, don't rely on the h5ad round trip to preserve labels, and manually reattach factor levels if you must reload
- adata.X contains negative values or looks nothing like count data
- Cause: No layer was specified during conversion, so the tool defaulted to scale.data or log-normalized data instead of raw counts Fix: Specify the layer explicitly, e.g. as_AnnData(seu, assay_name = "RNA", layers_mapping = c(counts = "counts"))
- Filtering on a metadata column silently drops rows; some values show as [Not Available] and others as <NA>
- Cause: The source metadata mixed missing-value encodings before conversion, and the h5ad file preserves whatever string was there instead of a single NA type Fix: Standardize missing values with na_if() in R before converting, and check unique() on every metadata column first
- Spatial image and pixel coordinates are missing from adata.obsm and adata.uns after converting a spatial Seurat object
- Cause: General-purpose Seurat-to-h5ad converters don't guarantee spatial image slots convert (Seurat issue #9617) Fix: Check .obsm and .uns['spatial'] immediately after conversion and reattach coordinates and images manually if they're absent
Questions people ask
- How do I convert a Seurat object to h5ad?
Use zellkonverter (convert to SingleCellExperiment first, then writeH5AD()) or anndataR's as_AnnData(), specifying the assay and layer explicitly rather than trusting a converter's default. Avoid SeuratDisk on Seurat v5 objects; it frequently breaks on split layers.
- Why does sceasy or SeuratDisk fail on my Seurat v5 object?
Seurat v5 splits counts, data, and scale.data across per-sample numbered layers (counts.1, counts.2, ...), which older converters expect as a single matrix. Join the layers with JoinLayers() first, or pass the assay parameter explicitly, e.g. assay = c("RNA", "joined").
- Will my UMAP and PCA survive the conversion?
Yes, dimensional reductions map to AnnData's obsm slot as X_pca, X_umap, and so on with anndataR, zellkonverter, and convert2anndata. Confirm with adata.obsm.keys() after conversion since not every tool carries every reduction.
- Why do my metadata columns turn into numbers when I convert h5ad back to Seurat?
This is a documented Seurat behavior: ReadH5AD() can replace categorical obs columns with numeric codes and drop the original factor labels. Keep your canonical metadata in R and treat any round trip through h5ad as one-way.
- Do I need Python or R to convert Seurat to h5ad?
Not necessarily. R packages (zellkonverter, anndataR, convert2anndata) cover most cases, but readseurat is a pure-Python CLI that reads .rds files directly if you'd rather stay out of R entirely.
Related pages
Related reading on the blog
Sources
- Read/write Seurat objects using anndataR — Matrix transpose and layers_mapping-based conversion
- convert2anndata GitHub repository — CLI conversion tool with Seurat v5 split-layer and multi-assay support
- SeuratDisk issue #183: Categorical variable conversion — Factor-to-categorical conversion details
- Seurat issue #1508: ReadH5AD categorical replacement — Categorical metadata becomes numeric codes on round trip
- srtdisk GitHub repository — Seurat v5-compatible h5Seurat/h5ad converter
- zellkonverter on Bioconductor — SingleCellExperiment-to-AnnData bridge via basilisk
- readseurat GitHub repository — Pure Python CLI reading Seurat RDS files directly