write.csv() Exports a Data Frame
Getting a data frame out of R and into a file other programs can open is one call:
write.csv(df, "results.csv", row.names = FALSE)
That's the line to memorize - including the row.names = FALSE, which we'll justify in a moment. The first argument is the data frame, the second is the filename, and the function returns nothing: its entire job is the side effect of creating the file.
You can see exactly what write.csv() produces without touching the disk, because it accepts any connection where it expects a filename - including stdout(), which just prints:
There's your CSV: a header line, one line per row, text values wrapped in quotes, numbers bare. (The quoting is the default quote = TRUE; every mainstream CSV reader handles it, so leave it alone.) Writing to a real file is the same call with "results.csv" in place of stdout().
Why row.names = FALSE Should Be Your Default
Every data frame carries row names - usually just the numbers 1, 2, 3... - and write.csv()'s default is to write them into the file as an extra first column with an empty header. Watch what the default output looks like, and what happens when that file makes the round trip back into R:
The export grew a nameless first column holding "1", "2" - and on re-import, read.csv() had to call it something, so it invented X. That's the mystery X column that puzzles every beginner: it isn't data, it's R's row numbering leaking into your file. Do this a few times through an export-import cycle and you'll accumulate X, X.1, X.2...
Row names almost never carry real information - when they do, the information belongs in a proper column anyway. So make row.names = FALSE a reflex. It should arguably have been the default; it isn't, for backward-compatibility reasons, so the habit is on you.
Where the File Lands
write.csv(df, "results.csv") writes into R's working directory - and if that isn't where you expected, the file seems to vanish. It didn't fail; it's just sitting in whatever folder getwd() prints. This is the same trap as reading, in mirror image, and the same two tools resolve it:
getwd() # the folder your file went to
write.csv(df, "C:/Users/ada/results/out.csv", row.names = FALSE) # or: remove all doubt
Full paths (forward slashes work on every platform, including Windows) make scripts unambiguous. How the working directory gets set and managed is covered in working directory.
write.table() for Other Delimiters - and the Append Question
write.csv() is actually a thin wrapper around the general-purpose write.table(), preconfigured for commas. Call write.table() directly when you need a different shape - most commonly tab-separated:
Tab-separated, no quotes - the format many downstream tools prefer. Note that with write.table() you set everything explicitly; it doesn't inherit write.csv's comma or quoting defaults.
One thing write.csv() refuses to do is append: passing append = TRUE gets you a warning that the setting was ignored, because appending would rewrite the header line into the middle of the file. If you genuinely need to add rows to an existing CSV - say, logging results across runs - drop down to write.table() and suppress the header yourself:
write.table(new_rows, "log.csv", sep = ",",
append = TRUE, col.names = FALSE, row.names = FALSE)
And if you're in the tidyverse, readr::write_csv(df, "out.csv") is the counterpart to read_csv(): same output, faster on large frames, and it never writes row names in the first place - one less argument to remember.
saveRDS() and readRDS(): Exact Round-Trips
CSV is a lossy format. It stores text, full stop - so a Date column becomes the string "2026-08-07", a factor becomes its labels with the level ordering gone, attributes vanish, and every type must be re-guessed on import (usually correctly, sometimes not - leading zeros, we're looking at you). And CSV can only hold one rectangular table: a fitted model, a list, a matrix with dimnames have no CSV representation at all.
When the file is for R - saving today's cleaned data for tomorrow's session - skip text formats entirely:
saveRDS(df, "clean_data.rds") # today
df <- readRDS("clean_data.rds") # tomorrow: identical object, types intact
saveRDS() serializes one R object, any object, exactly. readRDS() gives it back byte-for-byte: factors keep their levels, dates are still dates, nothing is re-guessed. It works on anything - a data frame, a list of data frames, a fitted regression model. The .rds file is compressed binary, so it's typically smaller than the CSV too. The only cost is that nothing but R can open it - which is precisely why the rule of thumb is: CSV to share with humans and other tools, RDS to save for yourself.
save() and load() - and Why RDS Usually Wins
You'll also meet an older pair. save() writes multiple named objects into one .RData file, and load() restores them - into your workspace, under their original names:
save(df, model, params, file = "session.RData")
load("session.RData") # df, model, params silently appear
That convenience is the problem. load() decides the variable names, not you - it plants objects into your environment sight unseen, silently overwriting anything that already had those names. Six months later, nobody remembers what session.RData even contains without loading it to find out.
readRDS() has the honest interface: you choose the name (df <- readRDS(...)), nothing is overwritten behind your back, and one file means one object. Unless you specifically need to bundle several objects into a single file, prefer RDS. (If you do need a bundle, putting the objects in a named list and saveRDS()-ing the list gets you the same effect with none of the surprise.)
What You Take Away
write.csv(df, "out.csv", row.names = FALSE)- the whole export, and yes, always therow.names = FALSE.- The mystery
Xcolumn on re-import is row names leaking into the file; the default wrote them, you shouldn't. - Files land in the working directory -
getwd()finds "lost" exports, full paths prevent them. write.table()handles other delimiters and (withcol.names = FALSE) real appending;write.csv()deliberately can't append.saveRDS()/readRDS()round-trip any R object exactly - types, factors, attributes intact. CSV for sharing, RDS for yourself.- Prefer RDS over
save()/load(): explicit names, no silent overwrites.
Next up: a data type that deserves its own page before it bites you in an import - dates.
Frequently Asked Questions
How do you export a data frame to CSV in R?
write.csv(df, "output.csv", row.names = FALSE). The row.names = FALSE part matters: without it, R writes its internal row numbers as an extra first column, which shows up as a mystery column named X when anyone re-imports the file.
Why does my exported CSV have an X column when I read it back?
Because write.csv's default is row.names = TRUE - it wrote the row numbers as an unnamed first column, and read.csv named that column X on the way back in. Export with row.names = FALSE and the phantom column disappears.
What is the difference between write.csv and saveRDS in R?
write.csv produces a plain-text table anyone can open, but it only stores text - factor levels, Date types, attributes, and non-tabular objects are flattened or lost, and types must be re-guessed on import. saveRDS stores one R object byte-for-byte; readRDS returns it exactly as it was. Use CSV to share with others, RDS to save for your own R sessions.
Where does write.csv save the file?
In R's current working directory, unless you give a full path. Run getwd() to see where that is - if your file seems to have vanished, it's sitting in whatever folder getwd() prints. Passing a full path like "C:/Users/ada/results/out.csv" removes the ambiguity.