Conversion · Seurat object (RDS) → CSV/TSV count table
How to Convert Seurat object to CSV/TSV count table (Without Losing Your Metadata)
write.csv() on a full assay tries to densify a matrix that's sparse for a reason, export only what you need or you'll watch R run out of memory on a 40k-cell object.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read
- Seurat object (RDS)
- .rds, .RDS · coordinates: n/a
- CSV/TSV count table
- .csv, .tsv, .txt · coordinates: n/a
You need this conversion when the counts matrix has to leave R: handing data to a collaborator on Python, loading it into a tool that only reads plain text, or archiving a GEO submission. The Seurat object itself doesn't survive that handoff intact, it's an S4 container holding the counts/data/scale.data layers, PCA and UMAP embeddings, and per-cell metadata all bundled together, and a CSV of the count matrix carries none of that except the numbers and the row/column names you explicitly write.
What gets lost is structural, not just cosmetic. Metadata (cluster assignments, sample IDs, QC metrics) lives in a separate slot from the assay matrix and has to be exported to its own file; there's no single CSV that holds both a genes-by-cells matrix and a cells-by-covariates table without one truncating the other. Embeddings, graphs, and the normalization history disappear entirely unless you export them on purpose.
The most common way this goes silently wrong is memory, not correctness: the counts layer is stored as a sparse matrix because single-cell data is mostly zeros, and CSV/TSV can only hold dense text. Calling as.matrix() on the full layer before writing can turn a 30 MB in-memory object into 700+ MB dense, and on a full-size dataset that densify step is what actually crashes the R session, not the write.csv() call itself.
The commands
Type your file names once; every command below updates.
01R/SeuratvSeurat v5
rRscript -e "obj <- readRDS('sample.rds'); print(Layers(obj[['RNA']]))"Loads the RDS and lists the layers present in the default assay (counts, data, scale.data) so you know which one you're about to export before you commit to a giant write. In pre-v5 objects this errors; use slotNames(obj[['RNA']]) instead.
02R/SeuratvSeurat v5
rRscript -e "obj <- readRDS('sample.rds'); counts <- GetAssayData(obj, assay='RNA', layer='counts'); write.table(as.matrix(counts), file='sample.csv', sep='\t', quote=FALSE, row.names=TRUE, col.names=NA)"Pulls the raw counts layer, densifies it with as.matrix() (this is the step that eats memory), and writes genes-as-rows / cells-as-columns as tab-delimited text. col.names=NA is required so the header row has one fewer field than the data rows, keeping the gene-name column aligned instead of shifting every value one column left.
03R/Seurat
rRscript -e "obj <- readRDS('sample.rds'); write.csv(obj@meta.data, file='sample.csv', quote=FALSE, row.names=TRUE)"Writes per-cell metadata (cluster ID, sample, nCount_RNA, percent.mt) to its own file. It has to be separate from the counts table: one is genes x cells, the other is cells x covariates, and jamming them into one rectangle either truncates the matrix or duplicates metadata across every gene row.
04R/Seurat
rRscript -e "obj <- readRDS('sample.rds'); sub <- FetchData(obj, vars=c('CD3D','CD8A','nCount_RNA','seurat_clusters')); write.csv(sub, file='sample.csv', quote=FALSE, row.names=TRUE)"Pulls only the genes and metadata columns you actually need into one already-dense, cell-indexed data frame. Assumes those genes exist in the active assay's feature names; use this instead of densify-then-subset when the full matrix is too big to hold in memory twice.
Coordinates, strand, names, builds
This conversion carries no genomic coordinates and no strand information, so chr-naming and 0-based/1-based questions don't apply here (genes as rows have no positional coordinate in the count table itself). What does change is layer selection, numeric representation, and structure. Seurat v5 stores counts, data, and scale.data as separate layers accessed with layer= or the $ operator (obj[['RNA']]$counts); v3/v4 objects use slot= on the same accessor. Pick the wrong layer and you silently export normalized or scaled values labeled as "counts," which breaks any downstream tool (DESeq2, edgeR) that expects raw integers. The count matrix itself is stored sparse (dgCMatrix) because single-cell data is mostly zeros; as.matrix() forces it dense, which is a memory and disk-size event, not a formatting nicety, and CSV/TSV can only hold dense rectangles. Cell metadata (obj@meta.data) lives in a separate structure from the assay matrix and does not travel with write.csv() on the counts layer, so it has to be exported to its own file and re-joined downstream by matching cell barcodes/column names. Gene identifiers also matter: Seurat's default feature names are usually gene symbols, not Ensembl IDs, and duplicate or make.unique()-mangled symbols in the RDS will carry straight into the CSV row names.
Check the output before you trust it
01Header field count matches expected cell count
bashawk -F'\t' 'NR==1{print NF}' {output}Expected NF equals the number of cells in the object (ncol(counts)); if it's off by one, col.names=NA was omitted and every column is shifted.
02Row count matches gene count
bashwc -l {output}Expected Line count equals nrow(counts) + 1 for the header row.
03No Excel date corruption in gene names
bashgrep -E '\b[0-9]{1,2}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b' {output} | headExpected No matches. Gene symbols like MARCH1, SEPT9, or DEC1 should read as text, not as 1-Mar, 9-Sep, or 1-Dec.
04Per-cell total matches between R and the written file
rsum(GetAssayData(obj, assay='RNA', layer='counts')[,1]) == sum(read.table('{output}', header=TRUE, row.names=1, sep='\t', check.names=FALSE)[,1])Expected TRUE, the summed counts for the first cell match exactly between the in-memory matrix and the written file, confirming nothing was truncated or double-written.
05Metadata row count matches cell count
bashwc -l metadata.csvExpected Line count equals the number of cells + 1 header row, matching the column count of the counts file.
Errors you will see, and what they mean
- Error: cannot allocate vector of size 12.3 Gb
- Cause: as.matrix() on the full sparse counts layer tries to allocate a dense array; a matrix that is 30 MB sparse can become 700+ MB dense, and a full atlas-scale object can demand tens of gigabytes at once. Fix: Subset to the genes or cells you need with FetchData() or matrix indexing before densifying, or skip CSV entirely and write the sparse matrix as Matrix Market (MTX) format instead.
- Error: unused argument (slot = "counts")
- Cause: GetAssayData() changed its parameter name between Seurat versions: v3/v4 use slot=, v5 uses layer=. Scripts copied from an older tutorial break on a v5 object and vice versa. Fix: Run packageVersion('Seurat') first, then match the argument name to the version: layer= on v5, slot= on v3/v4.
- Error in as.data.frame.default: cannot coerce class structure("dgCMatrix") to a data.frame
- Cause: Passing the sparse Matrix object straight to write.csv() or as.data.frame() without densifying it first; write.csv() doesn't know how to serialize a dgCMatrix. Fix: Wrap the extracted layer in as.matrix() before writing, and only do this after confirming the matrix is small enough to fit in memory dense.
- Downstream pandas.read_csv() shows every column shifted one to the left, with gene names in the count columns
- Cause: write.table() was called with row.names=TRUE but col.names=TRUE (or the default) instead of col.names=NA, so the header row has one fewer entry than the data rows and every reader misaligns the first column. Fix: Use col.names=NA with row.names=TRUE in write.table()/write.csv() so the header lines up with the row-name column, or write the file without row names and keep gene IDs as a separate first column explicitly.
- Gene symbols like MARCH1 and SEPT9 show up as 1-Mar and 9-Sep after a colleague sends the CSV back
- Cause: Someone opened the CSV in Excel, which auto-converts strings that look like dates, then re-saved it. This is silent: the file still opens and looks fine at a glance. Fix: Never open counts CSV/TSV files in Excel. Inspect them with head, awk, or file instead, and if Excel access is unavoidable, import the column as text rather than double-clicking to open.
Questions people ask
- Why does write.csv() run out of memory when I export a Seurat object?
Because write.csv() and write.table() only accept dense rectangles, so any export path forces as.matrix() on the sparse counts layer first. A matrix that's 30 MB sparse can be 700+ MB dense, and a full-size atlas object can demand tens of gigabytes in one allocation. Subset to the genes or cells you actually need before densifying, or export as Matrix Market (MTX) format instead of CSV.
- Should I export the counts, data, or scale.data layer?
Export counts (raw integers) if the file is going to a tool like DESeq2, edgeR, or anything expecting raw UMI counts. Export data (log-normalized) only if the downstream step explicitly wants normalized values, and treat scale.data as a plotting/PCA intermediate, not something to hand off as a 'count table.' Check Layers(obj[['RNA']]) before you export so you know which one you're pulling.
- How do I keep cell metadata attached to the count table after exporting?
You don't attach it to the same file. Write obj@meta.data to its own CSV, keyed by the same cell barcodes that are the counts matrix's column names, and join them downstream by that key. Cramming metadata columns into the same rectangle as a genes-by-cells matrix either truncates the matrix or duplicates metadata across every gene row.
- Can I just open the exported CSV in Excel to sanity check it?
No. Excel auto-converts gene symbols that look like dates, so MARCH1 becomes 1-Mar and SEPT9 becomes 9-Sep, and it will silently resave that corruption if you touch the file. Inspect the output with head, awk, or file instead, or import the column as text explicitly if you must use Excel.
Related pages
- Convert · How to Convert 10x MTX to CSV/TSV count table (Without Losing Your Metadata)
- Convert · How to Convert CSV/TSV count table to Seurat object (and Why IDs Go Missing)
- Convert · How to Convert 10x MTX to Seurat object (and Why IDs Go Missing)
- Convert · How to Convert h5ad to Seurat object (Without Losing Your Metadata)
- Convert · How to Convert Loom to Seurat object (Without Losing Your Metadata)
- Glossary · Count matrix
Related reading on the blog
Sources
- Seurat v5 Essential Commands — Layer vs slot accessors (GetAssayData, $ operator, Layers()) for counts/data/scale.data
- Seurat - Guided Clustering Tutorial — Sparse vs dense memory footprint benchmark (29.9 MB vs 709.6 MB)
- IntegrateLayers failing due to dense matrix conversion? (Seurat v5) — as.matrix() on large objects allocating tens of gigabytes and triggering memory warnings
- Export Seurat Data into 1 csv file and select specific clusters of interest — Common as.data.frame(GetAssayData()) + write.table()/write.csv() export workflow
- How to transform Seurat object to csv format — readRDS() and version-dependent object structure