SAV to CSV in R

How to convert SAV to CSV in R

Set the options below and copy a script that reads your .sav file and writes a CSV with the value labels still readable.

Short answer

  • haven::read_sav() reads the file, including .zsav. It hands back labelled columns, not factors.
  • haven::as_factor() is the step everyone forgets. Skip it and your CSV holds 1 and 2 rather than Male and Female.
  • readr::write_csv() finishes the job without the row-number column that write.csv adds by default.

How to convert SAV to CSV in R, built from your own paths

Package
Value labels
Scope
convert-sav.R
# Convert an SPSS .sav file to CSV with haven.
# Generated by savtocsv.com/sav-to-csv-in-r

library(haven)
library(readr)

input <- "data/survey.sav"
output <- "data/survey.csv"

df <- read_sav(input)

# Without as_factor() the next line writes 1 and 2, not Male and Female.
df <- as_factor(df)

write_csv(df, output, na = "")

cat("wrote", nrow(df), "rows to", output, "\n")

What these choices are doing

  • read_sav returns labelled vectors, not factors. haven keeps the numeric code and the label together in a haven_labelled column. A CSV writer only sees the code, which is why an untouched export comes out as 1 and 2.
  • as_factor is the whole trick. Applied to a data frame it converts only the labelled columns and leaves plain numerics alone. Drop this line and the export silently reverts to codes.
  • na = "" writes empty cells. The default is the literal string NA, which many spreadsheets then treat as text. An empty field reads as missing nearly everywhere. Fields holding a comma, quote or newline are still quoted.


Which package should you use: haven, foreign or rio

Three packages can open an SPSS file in R, and only one of them is really a separate reader. rio dispatches to haven for .sav and .zsav, so the choice is genuinely between haven and foreign, with rio as a shorter way to write the haven route.

PackageWhat you get backBest forNot ideal for
haven::read_sav()A tibble of labelled columns, value labels and user missing values intactAlmost every case, and the only option for .zsavEnvironments where you cannot install a package, since haven is not part of base R
foreign::read.spss()A list by default, or a data frame with to.data.frame = TRUE, labels applied as factor levels when there are at least as many labels as distinct valuesLocked-down machines, since foreign ships with RCompressed .zsav files, which its manual never mentions, and SPSS strings over 255 characters, which come back split across several columns
rio::import()A data frame by default, produced by haven underneathScripts that read several file formats and want one call for all of themAnyone who wants to see exactly which reader ran and with what arguments

One thing the table cannot show: rio does not apply labels for you. import() returns the same labelled columns haven produces, so as_factor() is still your job. The rio recipe in the generator includes that line for exactly this reason.


Five things that quietly break a SAV to CSV conversion

None of these throw an error. That is what makes them expensive: the script finishes, the file opens, and the problem only surfaces when someone downstream asks why a column of genders is a column of ones and twos.

1. Labels turn back into codes on the way out

An SPSS file stores the number and a separate lookup of what the number means. haven keeps both in a haven_labelled column, but a CSV has one slot per cell, so the writer takes the number. as_factor() resolves the ambiguity in your favour by converting labelled columns to factors first. Applied to a data frame it touches only the labelled columns and leaves plain numerics alone.

2. Variable labels have nowhere to go

Value labels are the ones people notice. The longer variable label, the sentence that says what the question actually asked, lives on an attribute and never reaches the CSV header at all. There is no fix inside a single CSV. The workable answer is a second file: read attr(x, "label") for every column and write a small codebook alongside the data. The codebook checkbox in the generator adds that block.

3. Dates arrive as ten-digit numbers

SPSS counts seconds from 1582-10-14, the start of the Gregorian calendar. haven converts the date formats it recognises into real R dates. The ones it does not recognise stay numeric, and foreign leaves the lot numeric, so an interview date shows up as something like 13887936000. Divide by 86400 and pass origin = "1582-10-14" to as.Date(). A number in the billions is a hint rather than proof: a large ID column looks the same, so check the variable really is a date.

4. foreign warns about record types and splits long strings

The warning about an unrecognized record type 7 subtype is benign on almost every modern file, since record 7 is the extension block newer SPSS versions write for backwards compatibility. It does mean foreign skipped a block it could not parse, so read it as noise rather than as a guarantee. The limit that actually bites is long strings: a variable over 255 characters is imported into consecutive separate variables that you then have to paste back together. haven keeps it as one column.

5. The encoding inside the file is wrong

Accented labels arriving as mojibake mean the encoding declared in the file does not match what is really stored. haven takes an encoding argument and foreign takes reencode, both of which override the declared value. CP1252 is the legacy Windows encoding and a sensible first guess, though it is a guess rather than a rule.


Step by step: how to convert SAV to CSV in R by hand

If you would rather understand the script than copy it, this is the same work in five moves.

  1. Read the file with haven. library(haven) then df <- read_sav("data/survey.sav"). read_sav handles both .sav and the compressed .zsav, and keeps value labels attached to each column.
  2. Decide what the labels should become. Call as_factor(df) to export the text labels, zap_labels(df) to keep the raw numeric codes, or add a _code column beside each labelled variable to keep both.
  3. Check the columns that are secretly dates. haven converts the SPSS date formats it recognises, but anything left sitting in the billions is probably an unconverted date. Confirm the variable really is a date, then use as.Date(x / 86400, origin = "1582-10-14") before writing.
  4. Write the CSV. readr::write_csv(df, "data/survey.csv", na = "") writes no row names and leaves missing cells empty. With utils::write.csv you must pass row.names = FALSE yourself.
  5. Open the result and check one coded column. Look at a variable you know is coded. If it reads Male rather than 1, the label step worked and the rest of the file almost certainly did too.

The generator above writes out the same five moves with your paths already filled in, plus the arguments that only matter for the options you picked.


When R is the wrong tool for this

Scripting the conversion pays off when it runs more than once, or when the CSV feeds something else you are already writing in R. It is a poor trade for a single file someone emailed you: installing R and haven to convert one .sav is twenty minutes for a job that takes a few seconds in a browser.

Next: no R on this machine? The browser SAV to CSV converter applies the same label handling without installing anything, and it never uploads the file. Not sure what is in the file yet? Open the .sav variable viewer first.


Questions

Why does my CSV show 1 and 2 instead of Male and Female?

Because haven::read_sav() gives you a haven_labelled column that holds the code and the label separately, and a CSV writer only ever sees the code. Run haven::as_factor(df) before you write, which converts the labelled columns to factors so the text goes out instead. With foreign::read.spss() the equivalent switch is use.value.labels = TRUE, which is already the default.

Should I use haven or foreign to read a .sav file in R?

haven for anything new. It reads .sav and .zsav, keeps value labels and user-defined missing values in a structured way, and returns a tibble. foreign is worth reaching for when you cannot add a package to the environment, since it ships with a base R install. Its limits are real though: the manual never mentions .zsav, and a string variable over 255 characters comes back split across consecutive columns you have to paste together yourself.

What does the warning about an unrecognized record type mean?

Record type 7 is an extension block that newer SPSS versions write so older versions can still open the file. foreign::read.spss() reports the subtypes it does not know about and reads the data anyway. In practice it is noise on almost every modern file. It does mean foreign skipped a block it could not parse, so it is not a promise that every SPSS feature survived.

Why are my SPSS dates coming out as huge numbers?

SPSS stores date and time values as seconds since 1582-10-14. haven converts the date formats it recognises into R dates, but anything it does not recognise stays numeric, and foreign leaves them numeric across the board. A 2020 date lands near 13.8 billion. For those columns: as.Date(x / 86400, origin = "1582-10-14"). Check the variable is really a date first, since a large ID column looks identical. Tick the date converter option in the generator above and the helper is written into your script.

Can R read a .zsav file?

Yes, with haven. read_sav() reads both .sav and the compressed .zsav, so the code is identical apart from the file extension. The foreign manual does not document .zsav at all, so if you are stuck with foreign, open the file in SPSS or PSPP once and save it uncompressed first.

How do I convert a whole folder of .sav files to CSV in R?

list.files() with pattern = "\\.(sav|zsav)$" and full.names = TRUE, then loop and write one CSV per file with sub() swapping the extension. Switch the Scope control above to A folder and the loop is generated for you. One unreadable file will abort the run, so wrap the read in try() if you would rather skip and continue.

My accented value labels came out as garbage. How do I fix the encoding?

The encoding recorded inside the file does not always match what is really stored, and CP1252 from a Windows machine is a common culprit worth trying early. haven::read_sav() takes an encoding argument that overrides what the file claims; foreign::read.spss() takes reencode. Set the Encoding control above and the argument is added to the script.

How do I convert SAV to CSV in R without installing any packages?

foreign is the only reader that comes with a base R installation, so foreign::read.spss(file, to.data.frame = TRUE) plus utils::write.csv(df, out, row.names = FALSE) needs nothing extra. Pick foreign and utils::write.csv in the generator to get exactly that script.


Where the function signatures come from

Every argument the generator emits was checked against the package documentation rather than recalled: haven read_spss reference, haven as_factor reference, foreign read.spss manual page, rio import reference, and readr write_csv reference. The record type 7 explanation comes from the r-help thread on that warning.