Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

R (4.6)  Module 7 – Importing and exporting data

Excel, SPSS, Stata and SAS files

Read and write Excel workbooks in R with readxl and writexl, import SPSS, Stata and SAS files with haven, keep labels and missing codes, and pick a format.

  • Intermediate
  • 15 minutes
  • Examples run with R 4.6.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Read and write Excel workbooks with readxl and writexl, choosing sheets, cell ranges and column types
  • Convert Excel date numbers into dates with the right origin
  • Import SPSS, Stata and SAS files with haven and keep value labels and user-defined missing values
  • Convert labelled data to factors deliberately, without losing information
  • Choose an exchange format for collaborators who do not use R

Before you start

What you’ll learn · Before you start

What you will learn

  • Read and write Excel workbooks with readxl and writexl, choosing sheets, cell ranges and column types
  • Convert Excel date numbers into dates with the right origin
  • Import SPSS, Stata and SAS files with haven and keep value labels and user-defined missing values
  • Convert labelled data to factors deliberately, without losing information
  • Choose an exchange format for collaborators who do not use R

Before you start

On this page

A field team’s workbook of six rice plots reads into R as 10 rows of text: a title above the table, a note below it, dates that are plain numbers and a yield written as a word. An SPSS survey of the same team reports that people sleep 1.83 hours a night, because two codes that mean “missing” were counted as hours.

On runs of R 4.6.1 with readxl 1.5.0.1, writexl 2.0.1 and haven 2.5.5, you will read exactly the table you want from a workbook, turn Excel day numbers into dates, decode a codes sheet into factors, read SPSS, Stata and SAS files with their labels and missing-value codes, and send each collaborator a file their own tool reads.

A workbook is sheets of cells; read one rectangle

A CSV is one table of text. An Excel workbook is a set of sheets, each a grid of cells that can hold numbers, text, dates or nothing, laid out however a person liked. trial.xlsx has two sheets: data, with a title in row 1, a header in row 3, six plots in rows 4 to 9 and a note in row 11, and codes, which names the three varieties. readxl reads both .xls and .xlsx files and has no external dependencies, so Excel need not be installed:

workbook.R, typed at the R console Python · workbook_session.py
from session import run, version

version()
# R reads workbook.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f workbook.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f workbook.R
> library(readxl)
> excel_sheets("data/trial.xlsx")
[1] "data"  "codes"
> # The first sheet from cell A1, as it is:
> read_excel("data/trial.xlsx")
New names:
• `` -> `...2`
• `` -> `...3`
• `` -> `...4`
• `` -> `...5`
• `` -> `...6`
# A tibble: 10 × 6
   Rice variety trial: plot yields, kharif seaso…¹ ...2  ...3  ...4  ...5  ...6 
   <chr>                                           <chr> <chr> <chr> <chr> <chr>
 1 <NA>                                            <NA>  <NA>  <NA>  <NA>  <NA> 
 2 plot                                            block vari… sown  harv… yiel…
 3 1                                               1     2     46187 46302 41.2 
 4 2                                               1     1     46187 46302 38.5 
 5 3                                               1     3     46187 46303 <NA> 
 6 4                                               2     1     46188 46303 36.9 
 7 5                                               2     3     46188 46304 floo…
 8 6                                               2     2     46188 46304 44   
 9 <NA>                                            <NA>  <NA>  <NA>  <NA>  <NA> 
10 Yields in kg per plot. Plot 5 was flooded befo… <NA>  <NA>  <NA>  <NA>  <NA> 
# ℹ abbreviated name: ¹​`Rice variety trial: plot yields, kharif season`
> # From the header row to the last plot:
> plots <- read_excel("data/trial.xlsx", sheet = "data", range = "A3:F9")
> plots
# A tibble: 6 × 6
   plot block variety sown                harvested yield_kg
  <dbl> <dbl>   <dbl> <dttm>                  <dbl> <chr>   
1     1     1       2 2026-06-14 00:00:00     46302 41.2    
2     2     1       1 2026-06-14 00:00:00     46302 38.5    
3     3     1       3 2026-06-14 00:00:00     46303 <NA>    
4     4     2       1 2026-06-15 00:00:00     46303 36.9    
5     5     2       3 2026-06-15 00:00:00     46304 flooded 
6     6     2       2 2026-06-15 00:00:00     46304 44      

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 workbook_session.py

excel_sheets() lists the sheets by name. read_excel() with only a path reads the first sheet from cell A1, so the title became the name of the first column, readxl invented names such as ...2 for the empty cells beside it, and the header and the note became rows of data: 10 rows, every column text. Naming a cell range: A rectangle of a spreadsheet named by its corners, such as A3:F9 for columns A to F of rows 3 to 9. read_excel(range = ...) reads only that rectangle, so a title or a note around the table stays out., range = "A3:F9", read exactly the header and the six plots. Step through what each way of reading takes:

Interactive · step by step Which rows of the sheet each read takes Step through the reads of the example. The rows of the sheet are on the left; the read in focus is on the right, with what it returned.
Which rows of the sheet each read takes, step 5 of 5. Showing Row 1, Row 3, Rows 4 to 9, Row 11, read_excel(path) (10 rows, every column text), skip = 2 (8 rows: the note included), range = "A3:F9" (6 rows, 6 columns), sheet = "codes". In focus: sheet = "codes".Row 1a title in one cellRow 3the header: plot … yield_kgRows 4 to 9six plots, one wordRow 11a note in one cellread_excel(path)10 rows, every column textskip = 28 rows: the note includedrange = "A3:F9"6 rows, 6 columnssheet = "codes"the second sheet

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. From cell A1. read_excel() with only a path starts at cell A1 of the first sheet. The title in row 1 becomes the name of the first column, and the real header, row 3, becomes a row of data.
  2. Skipping two rows. skip = 2 starts at row 3, so the header names the columns plot, block, variety, sown, harvested and yield_kg.
  3. To the end of the sheet. skip still reads to the last used row. The blank row 10 and the note in row 11 arrive as two more rows, and the note's text turns the plot column into text.
  4. One rectangle. range = "A3:F9" reads exactly the header and the six plots, the rectangle a person sees as the table, and nothing around it.
  5. The other sheet. excel_sheets() listed a second sheet, codes, with the names of the three varieties. sheet = "codes" reads it the same way, from its own cell A1.

skip = 2 is the other common fix, and it fails in a quieter way: it moves the start of the read but not the end, so a blank row and the note arrive as extra rows. Read by sheet name, sheet = "data", rather than by position, so the script keeps working when someone adds or reorders sheets.

Anchor the start, check the end

When the number of rows changes from week to week, read with skip and then keep only the rows whose first column holds a plot number. A fixed range is right only while the table never grows.

Checkpoint

Check: Sheets and cell ranges

  1. Question 1 of 2 read_excel("data/trial.xlsx") with no other argument returned 10 rows of text. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It started at cell A1, so the title became the first column name and the header and the note became rows

    A sheet is a grid of cells, and readxl takes whatever the rows hold. Tell it where the table is with skip, n_max or range.

  2. Question 2 of 2 Which call returned exactly the six plots, with the columns named plot to yield_kg?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: read_excel("data/trial.xlsx", sheet = "data", range = "A3:F9")

    range = "A3:F9" reads one rectangle. skip moves the start but not the end.

Excel has one number type and counts dates in days

The six plots came back with plot as <dbl>, sown as a date-time and harvested as five-digit numbers. Each follows from the way Excel stores cells, not from R. readxl’s guide to cell types lists four kinds of cell: empty, boolean, numeric and text. A whole number is a numeric cell, which R receives as a double, and a date is a numeric cell too, shown as a date only because of its number format. Name the types to stop the guessing:

col_types.R, typed at the R console Python · col_types_session.py
from session import run, version

version()
# R reads col_types.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f col_types.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f col_types.R
> library(readxl)
> plots <- read_excel("data/trial.xlsx", sheet = "data", range = "A3:F9",
+   col_types = c("numeric", "numeric", "numeric", "date", "numeric", "numeric"))
Warning message:
Expecting numeric in F8 / R8C6: got 'flooded' 
> plots
# A tibble: 6 × 6
   plot block variety sown                harvested yield_kg
  <dbl> <dbl>   <dbl> <dttm>                  <dbl>    <dbl>
1     1     1       2 2026-06-14 00:00:00     46302     41.2
2     2     1       1 2026-06-14 00:00:00     46302     38.5
3     3     1       3 2026-06-14 00:00:00     46303     NA  
4     4     2       1 2026-06-15 00:00:00     46303     36.9
5     5     2       3 2026-06-15 00:00:00     46304     NA  
6     6     2       2 2026-06-15 00:00:00     46304     44  
> vapply(plots, function(x) class(x)[1], "")
     plot     block   variety      sown harvested  yield_kg 
"numeric" "numeric" "numeric" "POSIXct" "numeric" "numeric" 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 col_types_session.py

col_types takes one entry per column: "numeric", "text", "date", "logical", "skip", "guess" or "list". The word flooded in the yield of plot 5 cannot be a number, so readxl made it NA and warned with the cell’s address, F8. Give the word to na instead, na = "flooded", and the read is quiet because you have said what the word means.

The harvested cells had lost their date format, so they arrived as the counts of days Excel stores, each an Excel date number: The count of days that Excel stores for a date, in its 1900 or its 1904 date system. A cell without a date format reads into R as that plain number, which as.Date() with the system's origin turns back into a date.. Microsoft’s page on date systems says the 1900 system, the default of Excel for Windows and current Excel for Mac, counts from the start of 1900, and that the 1904 system of older Mac versions is 1,462 days behind it. Excel also counts a leap day that 1900 never had, for compatibility with Lotus 1-2-3, Microsoft explains, so the origin that R’s help for as.Date() gives for dates from Windows Excel is origin = "1899-12-30":

excel_dates.R, typed at the R console Python · excel_dates_session.py
from session import run, version

version()
# R reads excel_dates.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f excel_dates.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f excel_dates.R
> library(readxl)
> plots <- read_excel("data/trial.xlsx", sheet = "data", range = "A3:F9",
+   na = "flooded")
> class(plots$sown)
[1] "POSIXct" "POSIXt" 
> plots$sown <- as.Date(plots$sown)
> plots$harvested
[1] 46302 46302 46303 46303 46304 46304
> # Day numbers of Excel's 1900 date system:
> plots$harvested <- as.Date(plots$harvested, origin = "1899-12-30")
> plots[, c("plot", "sown", "harvested")]
# A tibble: 6 × 3
   plot sown       harvested 
  <dbl> <date>     <date>    
1     1 2026-06-14 2026-10-07
2     2 2026-06-14 2026-10-07
3     3 2026-06-14 2026-10-08
4     4 2026-06-15 2026-10-08
5     5 2026-06-15 2026-10-09
6     6 2026-06-15 2026-10-09
> as.numeric(plots$harvested - plots$sown)
[1] 115 115 116 115 116 116
> # Microsoft's own example, the same day in its two date systems:
> as.Date(40729, origin = "1899-12-30")
[1] "2011-07-05"
> as.Date(39267, origin = "1904-01-01")
[1] "2011-07-05"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 excel_dates_session.py

The sown dates came back as date-times at midnight UTC, and as.Date() made them dates. The harvest numbers 46302 to 46304 became dates with the 1900 origin, and the plots grew for 115 or 116 days. The last two lines check the method on Microsoft’s own example, day 40729 in the 1900 system and day 39267 in the 1904 system, which come out as the same day, 2011-07-05.

The cell holds readxl returns Turn it into a Date with
a date, formatted as one a date-time, <dttm> as.Date(x)
a day number of the 1900 system a number as.Date(x, origin = "1899-12-30")
a day number of the 1904 system a number as.Date(x, origin = "1904-01-01")
a date typed as text text as.Date(x, format = "%d/%m/%Y"), with the format it was typed in

A warning on computers outside UTC

On a computer whose time zone is not UTC, R 4.6.1 on macOS can warn “datetimes before 1902 may not be accurate” the first time it reads the origin "1899-12-30", as R 4.3.0’s news describes. The dates it returns are still right; the runs on this page use UTC.

Checkpoint

Check: Excel numbers and dates

  1. Question 1 of 2 plot holds the whole numbers 1 to 6, yet read_excel() returned it as <dbl>. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Excel has one numeric cell type, and readxl reads it as double

    Convert with as.integer() when an integer matters, and check the result for values that are not whole numbers.

  2. Question 2 of 2 Plot 3 was sown on Excel day 46187 and harvested on Excel day 46303. For how many days did it grow?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 116 days

    46303 − 46187 = 116, as as.numeric(harvested - sown) printed for plot 3. Day numbers subtract like any numbers, whichever date system they come from, as long as both use the same one.

A codes sheet decides labels and their order

Field sheets often store a code, 2, where a report needs a name, Line 21, and keep the names on a sheet of their own. The codes sheet then decides two things: what each code means and the order in which the categories appear. The run reads both sheets, decodes the varieties and writes a clean workbook:

decode_write.R, typed at the R console Python · decode_write_session.py
from session import run, version

version()
# R reads decode_write.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f decode_write.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f decode_write.R
> library(readxl)
> plots <- read_excel("data/trial.xlsx", sheet = "data", range = "A3:F9",
+   na = "flooded")
> codes <- read_excel("data/trial.xlsx", sheet = "codes")
> codes
# A tibble: 3 × 2
   code variety    
  <dbl> <chr>      
1     1 Local check
2     2 Line 21    
3     3 Line 34    
> # The codes sheet decides the labels and their order:
> plots$variety <- factor(plots$variety, levels = codes$code,
+                         labels = codes$variety)
> plots$variety
[1] Line 21     Local check Line 34     Local check Line 34     Line 21    
Levels: Local check Line 21 Line 34
> table(plots$variety)

Local check     Line 21     Line 34 
          2           2           2 
> writexl::write_xlsx(list(plots = plots, codes = codes), "trial-clean.xlsx")
> excel_sheets("trial-clean.xlsx")
[1] "plots" "codes"
> back <- read_excel("trial-clean.xlsx", sheet = "plots")
> vapply(back, function(x) class(x)[1], "")
       plot       block     variety        sown   harvested    yield_kg 
  "numeric"   "numeric" "character"   "POSIXct"   "numeric"   "numeric" 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decode_write_session.py

factor(plots$variety, levels = codes$code, labels = codes$variety) turned the codes into a factor: R's type for categorical data: an integer vector of codes plus a levels attribute that lists the possible values, so the code 2 means the second level. More in “Factors and categorical data” whose levels are the codes sheet’s rows in their order, so every table and chart lists Local check first, the variety the others are compared with. A code that is not on the sheet would become NA without a warning, so count NA values before and after decoding.

writexl::write_xlsx() writes a data frame, or a named list of them as one sheet each, to .xlsx. Read back, the dates and numbers are as they were, and variety is text: a workbook has no factor type, so the levels and their order travel only on the codes sheet. By default writexl writes the values with a bold, centred header row; fonts, fills, number formats and formulas take its wrapper functions, which a data file for analysis does not need.

Key idea

Read the table, name its types, then decode its codes from the sheet that defines them: the codes sheet is the one place that says what a category is called and where it comes in the order.

Checkpoint

Check: Decoding codes into factors

  1. Question 1 of 2 factor(plots$variety, levels = codes$code, labels = codes$variety). What decides the order of the factor's levels?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The order of the rows of the codes sheet

    The run printed Levels: Local check Line 21 Line 34, the order of the codes sheet.

  2. Question 2 of 2 plots$variety was a factor when write_xlsx() wrote it. What type did read_excel() return for it from trial-clean.xlsx?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: character

    Keep the codes sheet beside the data, as the example did, so the factor can be rebuilt in the same order.

SPSS, Stata and SAS files carry labels R has no type for

Statistics packages store categories as numbers with names beside them. In an SPSS file, 1 and 2 in the column smoker carry the value label: The text a statistics package shows for a stored code, such as Yes for 1 or Refused for -9. haven keeps value labels in the labels attribute of a labelled vector. Yes and No, the column carries a variable label, the question “Do you smoke?”, and the code -9, Refused, is marked as a user-defined missing value: A code that an SPSS file marks as missing, such as -9 for Refused. haven's read_sav() turns it into NA unless user_na = TRUE, which keeps the code and marks it as missing.. R has no type that holds all of that, so haven reads such a column as a labelled vector: haven's type for a column of an SPSS, Stata or SAS file: the values, often numeric codes, with value labels such as 1 = Yes beside them and an optional variable label. as_factor() turns it into a factor.. make_survey.R writes six answers to survey.sav, the way SPSS stores them:

make_survey.R: writes survey.sav R · make_survey.R
# Writes survey.sav: six answers to a short survey, coded as SPSS stores them.
library(haven)
survey <- data.frame(id = 1:6)
survey$smoker <- labelled_spss(c(1, 2, 2, -9, 1, 3),
  labels = c(Yes = 1, No = 2, Refused = -9), na_values = -9,
  label = "Do you smoke?")
survey$sleep <- labelled_spss(c(7, 6, -8, 8, -9, 7),
  labels = c("Don't know" = -8, Refused = -9), na_values = c(-9, -8),
  label = "Hours of sleep last night")
write_sav(survey, "survey.sav")

The run reads the file with the defaults, then with user_na = TRUE:

labelled.R, typed at the R console Python · labelled_session.py
from session import run, version

version()
# R reads labelled.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f labelled.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f labelled.R
> library(haven)
> source("make_survey.R")
> s <- read_sav("survey.sav")
> s
# A tibble: 6 × 3
     id smoker    sleep    
  <dbl> <dbl+lbl> <dbl+lbl>
1     1  1 [Yes]   7       
2     2  2 [No]    6       
3     3  2 [No]   NA       
4     4 NA         8       
5     5  1 [Yes]  NA       
6     6  3         7       
> s$smoker
<labelled<double>[6]>: Do you smoke?
[1]  1  2  2 NA  1  3

Labels:
 value   label
     1     Yes
     2      No
    -9 Refused
> attr(s$sleep, "label")
[1] "Hours of sleep last night"
> # Keep SPSS's own missing-value codes:
> u <- read_sav("survey.sav", user_na = TRUE)
> u$sleep
<labelled_spss<double>[6]>: Hours of sleep last night
[1]  7  6 -8  8 -9  7
Missing values: -9, -8

Labels:
 value      label
    -8 Don't know
    -9    Refused
> is.na(u$sleep)
[1] FALSE FALSE  TRUE FALSE  TRUE FALSE

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 labelled_session.py

With the defaults, the -9 of answer 4 and the -8 and -9 of the sleep hours are NA: haven’s help says that, unless user_na = TRUE, user-defined missing values are converted to NA. The labels are kept, the question is in the column’s label attribute, and the answer 3, which has no label, stays the number 3. With user_na = TRUE the codes stay, marked as missing values: is.na() reports them, but the numbers are still -8 and -9. Step through what each call makes of the column:

Interactive · step by step One SPSS column, read four ways Step through the column smoker. The codes the file stores are on the left; the call in focus is on the right, with what it returned.
One SPSS column, read four ways, step 6 of 6. Showing 1 and 2, -9, 3, read_sav(path) (-9 read as NA), user_na = TRUE (-9 kept, marked missing), as_factor() (Refused, Yes, No, 3), zap_missing(). In focus: zap_missing(), as_factor().1 and 2labelled Yes and No-9Refused: user-missing3no label at allread_sav(path)-9 read as NAuser_na = TRUE-9 kept, marked missingas_factor()Refused, Yes, No, 3zap_missing()missing codes become NA

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Codes and their labels. The file stores numbers, and beside them value labels, so 1 means Yes and 2 means No. haven reads the column as a labelled vector that keeps both, with the question as its variable label.
  2. A missing code. -9 means Refused, and the file marks it as a user-defined missing value. read_sav() turns it into NA by default, so it never counts as an answer.
  3. Keeping the code. With user_na = TRUE the -9 stays, still marked as missing. is.na() says TRUE for it, but mean() adds it in, which is how the sleep hours averaged 1.83 instead of 7.
  4. A value with no label. 3 has no label, an answer added after the labels were written. Every read keeps it as the number 3, and as_factor() will name its level "3".
  5. Labels become levels. as_factor() makes a factor whose levels are the labels, in the order of their values. A kept -9 becomes the ordinary level Refused, counted as one more answer.
  6. Missing first, then labels. zap_missing() turns the user-missing codes into NA first. as_factor() of the result counts Refused 0 times and shows the missing answer as NA.

haven reads the other packages the same way. read_dta() reads Stata files, whose missing values are tagged NA such as .r, a missing value that remembers why; read_sas() reads SAS data files, and read_xpt() the SAS transport format. Each returns labelled vectors where the file has labels, and the run below writes and reads both formats.

Checkpoint

Check: Labelled data from SPSS, Stata and SAS

  1. Question 1 of 2 read_sav("survey.sav") with its defaults. What becomes of the code -9, Refused, in smoker?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It becomes NA

    The first run printed NA for the fourth answer, and the labels still listed -9 Refused.

  2. Question 2 of 2 With user_na = TRUE, the sleep hours are 7, 6, -8, 8, -9 and 7, with -8 and -9 marked as missing. What does mean(u$sleep, na.rm = TRUE) return? Round to two decimals.

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1.83 hours (anything from 1.82 to 1.84 counts)

    mean() adds the stored numbers, -8 and -9 included, because they are not NA: 11 ÷ 6 = 1.83. After zap_missing() the mean of 7, 6, 8 and 7 is 7.

Turn labels into factors once, after the missing codes

A labelled vector is a place to stop on the way to a factor, and two orders of the same two steps give different data. The run computes the sleep hours and tabulates the smoking answers both ways:

as_factor.R, typed at the R console Python · as_factor_session.py
from session import run, version

version()
# R reads as_factor.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f as_factor.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f as_factor.R
> library(haven)
> source("make_survey.R")
> u <- read_sav("survey.sav", user_na = TRUE)
> mean(u$sleep, na.rm = TRUE)
[1] 1.833333
> mean(zap_missing(u$sleep), na.rm = TRUE)
[1] 7
> as_factor(u$smoker)
[1] Yes     No      No      Refused Yes     3      
attr(,"label")
[1] Do you smoke?
Levels: Refused Yes No 3
> table(as_factor(u$smoker))

Refused     Yes      No       3 
      1       2       2       1 
> table(as_factor(zap_missing(u$smoker)), useNA = "ifany")

Refused     Yes      No       3    <NA> 
      0       2       2       1       1 
> as_factor(u$smoker, levels = "values")
[1] 1  2  2  -9 1  3 
attr(,"label")
[1] Do you smoke?
Levels: -9 1 2 3

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 as_factor_session.py

mean(u$sleep, na.rm = TRUE) returned 1.83 hours: na.rm drops values that are NA, and the kept codes -8 and -9 are numbers, so they were averaged in. After zap_missing(), which converts user-defined and tagged missing values to ordinary NA, the mean is 7. In the same way, as_factor() of the column read with user_na = TRUE made Refused an ordinary level and counted it as one more answer; as_factor(zap_missing(...)) counted it 0 times and showed the missing answer as NA.

as_factor() levels What it uses The answer 3, which has no label
"default" labels where there are any, values otherwise the level 3
"label" labels only NA
"values" the codes the level 3
"both" [code] label the level 3

as.numeric() to get the codes back

as.numeric() of the factor as_factor() made gives the levels’ positions, 2 for Yes, not the code 1. Keep the labelled vector, or the codes in a column of their own, if anyone needs the codes later.

Older code reads SPSS files with the foreign package, which the R Core Team maintains, and the run shows it too: foreign::read.spss() turned both labelled columns into factors as it read, the hours of sleep included, and warned that it had added “undeclared levels” 6, 7 and 8. With haven you choose when a column becomes a factor, and which columns do.

Key idea

Deal with the missing codes on the labelled vector first, then turn labels into factor levels, once, for the columns that are categories.

Checkpoint

Check: as_factor() and missing codes

  1. Question 1 of 2 After read_sav(..., user_na = TRUE), how does table(as_factor(u$smoker)) count the answer Refused?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: As one answer, the level Refused with a count of 1

    The run printed Refused 1, Yes 2, No 2 and 3 1. A missing answer became a category.

  2. Question 2 of 2 You want a factor of the smoking answers in which Refused counts as missing. Which line gives it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: as_factor(zap_missing(u$smoker))

    Order matters: deal with missing codes on the labelled vector, then turn labels into levels.

Send each collaborator a file their tool reads

The labels, codes and types that R now holds are worth keeping on the way out. haven writes SPSS, Stata and SAS transport files too, with write_sav(), write_dta() and write_xpt():

other_formats.R, typed at the R console Python · other_formats_session.py
from session import run, version

version()
# R reads other_formats.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f other_formats.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f other_formats.R
> library(haven)
> source("make_survey.R")
> s <- read_sav("survey.sav")
> # Stata keeps value labels; its missing values are tagged NA, not codes.
> write_dta(s, "survey.dta")
> read_dta("survey.dta")$smoker
<labelled<double>[6]>: Do you smoke?
[1]  1  2  2 NA  1  3

Labels:
 value   label
    -9 Refused
     1     Yes
     2      No
> x <- labelled(c(1, 2, tagged_na("r")),
+               labels = c(Yes = 1, No = 2, Refused = tagged_na("r")))
> as_factor(x)
[1] Yes     No      Refused
Levels: Yes No Refused
> # SAS transport files keep the values, not the value labels:
> write_xpt(zap_labels(s), "survey.xpt")
> read_xpt("survey.xpt")
# A tibble: 6 × 3
     id smoker sleep
  <dbl>  <dbl> <dbl>
1     1      1     7
2     2      2     6
3     3      2    NA
4     4     NA     8
5     5      1    NA
6     6      3     7
> # Base R's foreign package turns labelled columns into factors as it reads:
> f <- foreign::read.spss("survey.sav", to.data.frame = TRUE)
Warning messages:
1: In foreign::read.spss("survey.sav", to.data.frame = TRUE) :
  Undeclared level(s) 3 added in variable: smoker
2: In foreign::read.spss("survey.sav", to.data.frame = TRUE) :
  Undeclared level(s) 6, 7, 8 added in variable: sleep
> f$smoker
[1] Yes  No   No   <NA> Yes  3   
Levels: Yes No 3
> f$sleep
[1] 7    6    <NA> 8    <NA> 7   
Levels: 6 7 8

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 other_formats_session.py

The Stata file kept the value labels of smoker. A vector built with tagged_na("r"), the kind of missing value haven reads from Stata and SAS files, became the level Refused under as_factor(), because that label belongs to the tag. The SAS transport file holds the values only: the run removed the value labels with zap_labels() before writing it. Which file fits depends on the person who receives it. Answer two questions about yours:

Interactive · answer and compare Which file should your collaborator get? Answer two questions about the person who receives the data. The table marks the formats that fit and says why the others do not.
Which program will your collaborator open the data in?
Must the value labels and the missing-value codes arrive as they are?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five formats for survey and trial data, against the four things a collaborator needs
Format Opens without R Keeps column types Keeps value labels Keeps missing codes
CSV and a data dictionary Holds: text, plus a table of types, labels and codes Good any spreadsheet, editor or language Mixed text only; the dictionary states each type Mixed as rows of the dictionary Mixed only if the codes are written and listed
.xlsx from writexl Holds: sheets of cells: numbers, text and dates Good Excel, other spreadsheets and readxl Mixed numbers and dates survive; the factor came back as text Mixed the labels survive as text and the codes are lost Poor a blank cell, with the reason gone
.sav from write_sav() Holds: SPSS values with their labels Mixed SPSS and other tools that read .sav Good numbers and text with their formats Good value and variable labels came back Good user-defined missing values, with user_na = TRUE
.dta from write_dta() Holds: Stata values with their labels Mixed Stata and other tools that read .dta Good numbers and text with their formats Good the smoker labels came back from survey.dta Mixed tagged NA such as Refused, not SPSS codes
RDS from saveRDS() Holds: one R object, exactly Poor only R reads it Good identical() was TRUE after the round trip Good a labelled vector keeps its attributes Good kept exactly as R held them
How the answers decide
  • “Which program will your collaborator open the data in?” SPSS: rules out .dta from write_dta() and RDS from saveRDS(). SPSS reads its own .sav files with their labels; a .dta or an RDS file needs converting first.
  • “Which program will your collaborator open the data in?” Stata: rules out .sav from write_sav() and RDS from saveRDS(). Stata reads .dta files with their value labels; a .sav or an RDS file needs converting first.
  • “Which program will your collaborator open the data in?” A spreadsheet, such as Excel: rules out .sav from write_sav(), .dta from write_dta() and RDS from saveRDS(). A spreadsheet opens .xlsx and CSV files, not SPSS, Stata or R files.
  • “Which program will your collaborator open the data in?” Python or another language: rules out RDS from saveRDS() and adds a caveat to .sav from write_sav() and .dta from write_dta(). Every language reads CSV. Check that the other tool reads .sav or .dta before you send one, and that it keeps the labels.
  • “Must the value labels and the missing-value codes arrive as they are?” Yes, both: adds a caveat to CSV and a data dictionary and .xlsx from writexl. In a CSV or a workbook the labels and missing codes survive only if a dictionary lists them; a blank cell carries no reason.

For anyone without a statistics package, the safe choice is a CSV, or an .xlsx with a sheet for the codes, with a data dictionary: A table that describes each column of a data file: its name, type, unit, value labels, missing-value codes and level order. It travels with a CSV to carry what the CSV cannot.: one row per column with its name, type, unit, value labels, missing codes and level order. Two limits decide the rest. A worksheet holds at most 1,048,576 rows, Microsoft’s specifications say, so a larger table goes as a summary in the workbook and in full in a format such as Parquet, a column-oriented file format that stores a type for every column. And keep Excel out of the analysis path: Microsoft’s guide on leading zeros says Excel removes them and keeps only 15 digits of long numbers when values are typed or imported, so a file that has been through Excel and back is new input, to be read and checked again.

Excel Viewer (XLSX, XLS, ODS, Numbers) Open a workbook in your browser and see each sheet's cells and ranges before you write the range of a read.

Checkpoint

Check: Choosing an exchange format

  1. Question 1 of 2 A collaborator works in Stata and needs the value labels. Which file do you send?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A .dta file from haven::write_dta()

    Match the file to the tool: .sav for SPSS, .dta for Stata, CSV or .xlsx with a dictionary for everyone else.

  2. Question 2 of 2 A collaborator opens files only in Excel and must know which answers were refused. What do you send?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An .xlsx or a CSV, with a data dictionary that lists every code and its meaning, -9 for Refused included

    Write the codes into the data, or as text such as "Refused", and describe them in a dictionary sheet.

Practise: predict, then check

Eight lines go back over the lesson. Predict each result before you look at the run.

Before you look

Predict the class of plot read with range = "A3:F9", the number of rows that skip = 2 returns, whether day 46187 of the 1900 system and day 46187 − 1462 of the 1904 system are the same date, what factor() makes of a code missing from its levels, and the mean and the factor of a labelled vector holding 5 and the missing code -9. Then type the lines into your own R.

practice.R, typed at the R console Python · practice_session.py
from session import run, version

version()
# R reads practice.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f practice.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f practice.R
> library(readxl)
> library(haven)
> # Predict each result before you look.
> class(read_excel("data/trial.xlsx", range = "A3:F9")$plot)
[1] "numeric"
> nrow(read_excel("data/trial.xlsx", skip = 2))
[1] 8
> as.Date(46187, origin = "1899-12-30") == as.Date(46187 - 1462, origin = "1904-01-01")
[1] TRUE
> factor(c(2, 4), levels = 1:3, labels = c("Local check", "Line 21", "Line 34"))
[1] Line 21 <NA>   
Levels: Local check Line 21 Line 34
> x <- labelled_spss(c(5, -9), labels = c(Refused = -9), na_values = -9)
> mean(x)
[1] -2
> mean(zap_missing(x), na.rm = TRUE)
[1] 5
> as_factor(x)
[1] 5       Refused
Levels: Refused 5

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

plot is numeric, skip = 2 brought back 8 rows with the blank row and the note, the two date systems agreed, the unknown code 4 became NA without a word, the mean of 5 and -9 is -2 until zap_missing() leaves 5, and as_factor() gave the code -9 its label, Refused, as a level.

The questions come next, starting from the idea you have shown least so far, with every answer explained as soon as you check it:

Practice that adapts to you

Practise this lesson

14 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 Which function lists the sheets of a workbook?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: readxl::excel_sheets()

    List the sheets first, then read each by name: sheet = "codes" keeps working when someone reorders them.

  2. Question 2 of 14 Next week's workbook has 8 plots instead of 6, so the note moves two rows down. Which read keeps working without an edit?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: Read with skip = 2, then keep the rows whose plot is a number

    Anchor the read where the table starts, then check the rows by their content. A fixed range is right only for a fixed table.

  3. Question 3 of 14 How many days apart are Excel's 1900 and 1904 date systems, according to Microsoft?

    Type a number.

    Show the answer: Question 3 of 14

    Answer: 1462 days

    Microsoft's page on Excel's date systems gives 1,462 days, four years and one leap day. The run checked it: day 40729 in the 1900 system and day 39267 in the 1904 system are the same date.

  4. Question 4 of 14 Excel's 1900 system numbers the first day of 1900 as day 1 and, as Microsoft explains, treats 1900 as a leap year. Which day number does it give the first day of March that year?

    Type a number.

    A hint

    January has 31 days, and February is counted with 29.

    Show the answer: Question 4 of 14

    Answer: 61 day number

    The 31 days of January and the 29 days counted for February come first, so the first day of March is day 61. From then on, the origin R's help uses for Windows Excel turns every day number into the right date.

  5. Question 5 of 14 The harvest cell of plot 1 had no date format and read as 46302. What does this line give?

    Read the code, then choose one answer.

    as.Date(46302, origin = "1899-12-30")
    Show the answer: Question 5 of 14

    Answer: The harvest day of plot 1, as the run printed it

    The run converted all six harvest day numbers this way and printed growing times of 115 and 116 days.

  6. Question 6 of 14 In factor(x, levels = codes$code, labels = codes$variety), which argument turns the codes 1, 2 and 3 into variety names?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: labels

    levels says which codes exist and in what order; labels says what each is called.

  7. Question 7 of 14 The codes sheet lists codes 1 to 3, but one plot has variety 4. What does factor(variety, levels = codes$code, labels = codes$variety) give for that plot?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: NA, without a warning

    Count missing values after every decoding step: a new code in the data turns into NA with no message.

  8. Question 8 of 14 What does haven return for an SPSS column that has value labels?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: A labelled vector, the values with the labels beside them

    haven keeps what the file holds and leaves the choice of factor to you.

  9. Question 9 of 14 With user_na = TRUE, is.na(u$sleep) is TRUE for the -8 and the -9. Why is mean(u$sleep, na.rm = TRUE) still 1.83?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: mean() works on the stored numbers, which are -8 and -9, not NA

    Use zap_missing() before any arithmetic on a column read with user_na = TRUE.

  10. Question 10 of 14 A Stata file stores a refused answer as the missing value .r. What does haven read it as?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: An NA that remembers its tag r, so a label such as Refused can still name it

    na_tag() shows the tag, and zap_missing() turns tagged NA into ordinary NA when you no longer need the reason.

  11. Question 11 of 14 What does as_factor(x, levels = "values") use as the levels?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: The stored codes, such as -9, 1, 2 and 3

    levels = "label" keeps only labelled values (the 3 became NA); "both" shows code and label together.

  12. Question 12 of 14 After f <- as_factor(u$smoker), whose levels are Refused, Yes, No and 3, a colleague runs as.numeric(f) to get the codes back. What do they get for Yes?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: 2, the position of Yes among the levels

    The run of the line printed 2 3 3 1 2 4. Keep the original codes in their own column if anyone needs them.

  13. Question 13 of 14 Which of these belong in a data dictionary sent with a CSV? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 13 of 14

    Answer:

    • Each column's type and unit
    • The codes that mean missing, with what each means
    • Each code's value label, and the order of the levels

    A dictionary is one row per column; it carries everything the format cannot.

  14. Question 14 of 14 A table has 3 million rows and must reach a colleague who uses Excel. How many rows can one Excel worksheet hold, according to Microsoft's specifications?

    Type a number.

    Show the answer: Question 14 of 14

    Answer: 1048576 rows

    1,048,576 rows by 16,384 columns. Send a summary or an extract in the workbook, and the full table in a format made for large data, such as Parquet or a database.

Interview questions

Warm-up: what does haven’s as_factor() do to an SPSS column with value labels, and when should you not call it yet?

as_factor() turns a labelled vector into a factor whose levels are the value labels, in the order of their codes, and uses the code itself as the level for a value without a label, so 1 = Yes, 2 = No and an unlabelled 3 become the levels Yes, No and 3. levels = "values" uses the codes instead, and levels = "label" makes unlabelled values NA. Do not call it yet if the column was read with user_na = TRUE: the missing codes still have labels, so Refused would become an ordinary level and count as an answer. Run zap_missing() first. And do not call it on columns that are quantities, such as hours or age, whose only labels name the missing codes: they should stay numbers.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    Two analysts read the same SPSS survey of 400 people into R. One reports a mean age of 38.9, the other 37.5, and a third gets NA with a warning. The file codes Refused as -9, marked as missing. Explain how all three results can come from one file, and which is right.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A field team sends a workbook every week: a title in row 1, the header in row 3, notes under the table, a codes sheet, and dates that are sometimes date cells and sometimes plain numbers. Your import breaks every few weeks. How do you make it robust, and how do you know when it has gone wrong?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A workbook is sheets of cells: read by sheet name and name the table’s rectangle with range, or use skip and then check the rows, because a title and a note around the table become data otherwise.
  • Excel has one numeric cell type, and a date is a count of days: a cell without a date format arrives as a number that as.Date(x, origin = "1899-12-30") converts in the 1900 system.
  • Decode codes with factor(x, levels = codes, labels = names) from the sheet that defines them, count NA values around it, and keep the codes sheet beside any workbook you write.
  • haven reads SPSS, Stata and SAS files into labelled vectors, turns user-defined missing values into NA unless user_na = TRUE, and keeps variable and value labels as attributes.
  • Run zap_missing() before arithmetic or as_factor(), or missing codes count as numbers and as categories.
  • Send a .sav to SPSS users, a .dta to Stata users, and everyone else a CSV or a workbook with a data dictionary; a table of more than 1,048,576 rows does not fit in one worksheet.

Terms in this lesson

cell range
A rectangle of a spreadsheet named by its corners, such as A3:F9 for columns A to F of rows 3 to 9. read_excel(range = ...) reads only that rectangle, so a title or a note around the table stays out.
data dictionary
A table that describes each column of a data file: its name, type, unit, value labels, missing-value codes and level order. It travels with a CSV to carry what the CSV cannot.
Excel date number
The count of days that Excel stores for a date, in its 1900 or its 1904 date system. A cell without a date format reads into R as that plain number, which as.Date() with the system's origin turns back into a date.
labelled vector
haven's type for a column of an SPSS, Stata or SAS file: the values, often numeric codes, with value labels such as 1 = Yes beside them and an optional variable label. as_factor() turns it into a factor.
user-defined missing value
A code that an SPSS file marks as missing, such as -9 for Refused. haven's read_sav() turns it into NA unless user_na = TRUE, which keeps the code and marks it as missing.
value label
The text a statistics package shows for a stored code, such as Yes for 1 or Refused for -9. haven keeps value labels in the labels attribute of a labelled vector.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.