12  Create variables

A survey’s recorded answers are often the starting point for an analysis. Before calculating a new measure, we need to know how to name a column, add one, and retain the returned table. These operations should preserve the connection between a respondent and their answers.

By the end of this chapter, you will be able to:

We build on selecting columns and observations, sorting, and the pipe. Our data are the prepared 2024 GSS respondents. This chapter establishes the mechanics of changing columns and text. Chapter 13 next applies an operation to several columns, before Chapter 14 develops calculations and conditions. The optional programming chapters are not prerequisites.

Prepare the extract following Appendix A, then restore the saved file in your project folder:

library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")

Keep the ID, hours worked last week, and years of schooling. These are observed answers, including their original missing values:

work <- gss24 |>
  select(id, hrs1, educ)
head(work)
# A tibble: 6 × 3
     id  hrs1  educ
  <int> <dbl> <dbl>
1     1    43    16
2     2    NA    16
3     3    NA    14
4     4    20    12
5     5    NA    13
6     6    NA    14

There are 3986 respondents in work; only six are displayed. hrs1 refers to the previous week, not a respondent’s usual schedule or commuting time. Its missing values do not imply zero working hours. The examples below change table structure without changing the recorded answers.

12.1 Renaming variables

The abbreviation hrs1 is concise but gives little information outside the codebook. rename() returns a table with changed column names. Write the new name on the left of = and the existing name on the right:

work |>
  rename(hours_last_week = hrs1)
# A tibble: 3,986 × 3
      id hours_last_week  educ
   <int>           <dbl> <dbl>
 1     1              43    16
 2     2              NA    16
 3     3              NA    14
 4     4              20    12
 5     5              NA    13
 6     6              NA    14
 7     7              80    13
 8     8              NA    14
 9     9              50    14
10    10              NA    12
# ℹ 3,976 more rows

The result replaces the heading hrs1 with hours_last_week. Values, row order, and dimensions are unchanged. work itself still uses hrs1, because we have not assigned the result back to it.

We can rename several columns in one call and save the returned table:

work_named <- work |>
  rename(hours_last_week = hrs1, schooling_years = educ)
work_named
# A tibble: 3,986 × 3
      id hours_last_week schooling_years
   <int>           <dbl>           <dbl>
 1     1              43              16
 2     2              NA              16
 3     3              NA              14
 4     4              20              12
 5     5              NA              13
 6     6              NA              14
 7     7              80              13
 8     8              NA              14
 9     9              50              14
10    10              NA              12
# ℹ 3,976 more rows

Both new names describe the existing quantities; renaming has not converted their units or changed any answer. The original ID remains alongside each respondent’s measurements.

12.2 Creating and changing columns

mutate() takes a table and returns a table containing new or replaced columns. Bare names refer to existing columns. The expression to the right of = supplies values; the name on the left names the result. First copy the recorded hours into a separate column so that we can inspect the operation without changing the measurement:

work_copy <- work_named |>
  mutate(hours_copy = hours_last_week)
head(work_copy)
# A tibble: 6 × 4
     id hours_last_week schooling_years hours_copy
  <int>           <dbl>           <dbl>      <dbl>
1     1              43              16         43
2     2              NA              16         NA
3     3              NA              14         NA
4     4              20              12         20
5     5              NA              13         NA
6     6              NA              14         NA
ncol(work_copy)
[1] 4

The returned table has the same respondents and one additional column, hours_copy. Each copied value belongs to the respondent on that row. Missing hours remain missing, and the ID, original hours, and schooling values stay alongside the copy. mutate() does not sort or remove respondents. The copy demonstrates column creation; it is not a new measure for analysis.

Using an existing name replaces that column in the returned table rather than adding another column. Repeat the copy using the name that now exists:

work_copy |>
  mutate(hours_copy = hours_last_week)
# A tibble: 3,986 × 4
      id hours_last_week schooling_years hours_copy
   <int>           <dbl>           <dbl>      <dbl>
 1     1              43              16         43
 2     2              NA              16         NA
 3     3              NA              14         NA
 4     4              20              12         20
 5     5              NA              13         NA
 6     6              NA              14         NA
 7     7              80              13         80
 8     8              NA              14         NA
 9     9              50              14         50
10    10              NA              12         NA
# ℹ 3,976 more rows

The result still has four columns, because hours_copy already exists. Its values are unchanged in this particular demonstration because we copied the same source again. In an applied transformation, a different expression would supply replacement values. Check the meaning and unit of a replacement before saving it; Chapter 14 works through that decision with hours and minutes.

Assignment and column naming do different jobs. In work_copy <-, <- saves the entire returned table under an object name. Inside mutate(), hours_copy = names one column in that table. Neither call changes work_named: it still contains only the renamed original columns. Likewise, a pipeline that is printed without assignment returns a result but does not save it over its input.

Keep calculations inside the table whose rows supply their inputs. A column copied or calculated there stays aligned with its respondent. A separately sorted or filtered column may no longer have the same rows in the same order, even if its length looks plausible.

12.3 Text

Quotation marks tell R to treat their contents as text, also called a character value or string. Even digits become text inside quotation marks:

12
[1] 12
"12"
[1] "12"

The first is numeric and the second is character. Printed quotation marks help distinguish them. A string may contain digits, spaces, or punctuation; it need not be a single letter. Use straight quotation marks in code, either single or double.

var_type() takes one object and returns its type without changing it:

var_type(12)
[1] "double"
var_type("12")
[1] "character"

The results are "double" and "character". A double is R’s usual numeric type, including fractional values such as 2.75. Quoted digits remain character values even when they look numeric.

paste() joins its inputs, inserting a space by default:

text <- "hello"
greeting <- paste(text, "R")
greeting
[1] "hello R"
text
[1] "hello"

The result is "hello R". The original text remains "hello"; the joined string is stored separately as greeting.

Joining text can change how missingness appears. Consider an abstract label whose second component is unknown:

missing_component <- NA_character_
missing_label <- paste("Label:", missing_component)
missing_label
[1] "Label: NA"
is.na(missing_component)
[1] TRUE
is.na(missing_label)
[1] FALSE

NA_character_ is a missing character value. The input check returns TRUE, but the joined label is the non-missing string "Label: NA", so its check returns FALSE. paste() has inserted visible text representing the unknown component. Checking only the finished label would miss that unavailable input. If a label requires every component, check their missingness before joining them; a readable string does not establish that all its information was recorded.

12.4 Convert text and numbers

To convert numeric text explicitly, use as.numeric():

number_text <- "12"
number <- as.numeric(number_text)
number
[1] 12
var_type(number_text)
[1] "character"

The returned number is 12. Assignment stores it in number; the original number_text remains character. as.character() makes the reverse conversion:

as.character(number)
[1] "12"

It returns "12" without changing the numeric input. A string with no numeric representation cannot be converted successfully:

converted_text <- as.numeric("hello")
Warning: NAs introduced by coercion
converted_text
[1] NA
is.na(converted_text)
[1] TRUE

R warns that NAs were introduced by coercion, its term for conversion. The output is NA, and is.na() returns TRUE. The failed conversion does not produce zero.

A conversion can succeed while losing part of the text:

code <- "01"
as.numeric(code)
[1] 1
code
[1] "01"

The converted result is 1, losing the leading zero. The original remains "01" because we did not overwrite it. Keep identifiers as text when their formatting matters. Keep a field’s meaning in view: numeric-looking identifiers and category codes are not automatically quantities to average. Chapter 15 develops factor levels and labelled survey scores later.

12.5 Prepare descriptive text

Extra spaces can prevent two otherwise identical labels from matching. str_squish() receives character values and returns character values with surrounding whitespace removed and repeated internal whitespace reduced to one space. It does not change an object unless we assign its result. Use a plain phrase to make the spacing rule visible:

spacing_example <- "  A   short phrase  "
spacing_example
[1] "  A   short phrase  "
str_squish(spacing_example)
[1] "A short phrase"

The quotation marks show where the original spaces occur. The returned phrase keeps the words and their order. This is a syntax demonstration with plain text. Spaces can be meaningful in identifiers, so inspecting the source and understanding its coding rules should precede any automatic cleaning.

Case is another feature of text. str_to_lower() returns a lowercase version without changing the supplied object:

case_example <- "A Short Phrase"
str_to_lower(case_example)
[1] "a short phrase"
case_example
[1] "A Short Phrase"

The result is "a short phrase"; the original retains its capitals. Lowercasing can provide a consistent search form. Neither spacing changes nor a consistent case establishes that two records identify the same person.

12.5.1 Labels from recorded answers

Can we describe respondents by their recorded sex and highest degree without changing those source answers? Select the three columns together so their rows remain aligned:

respondent_labels <- gss24 |>
  select(id, sex, degree)
head(respondent_labels)
# A tibble: 6 × 3
     id sex    degree                  
  <int> <fct>  <fct>                   
1     1 male   bachelor's              
2     2 male   graduate                
3     3 female associate/junior college
4     4 male   high school             
5     5 female high school             
6     6 male   associate/junior college

id identifies the respondent. sex and degree are the prepared category columns already encountered in the GSS tables. Use as.character() to obtain their displayed category text. Convert one column first and keep its source:

respondent_labels <- respondent_labels |>
  mutate(sex_text = as.character(sex))
head(respondent_labels)
# A tibble: 6 × 4
     id sex    degree                   sex_text
  <int> <fct>  <fct>                    <chr>   
1     1 male   bachelor's               male    
2     2 male   graduate                 male    
3     3 female associate/junior college female  
4     4 male   high school              male    
5     5 female high school              female  
6     6 male   associate/junior college male    

The new column contains the sex category names. Unknown answers remain missing. Repeat the same explicit conversion for degree:

respondent_labels <- respondent_labels |>
  mutate(degree_text = as.character(degree))
head(respondent_labels)
# A tibble: 6 × 5
     id sex    degree                   sex_text degree_text             
  <int> <fct>  <fct>                    <chr>    <chr>                   
1     1 male   bachelor's               male     bachelor's              
2     2 male   graduate                 male     graduate                
3     3 female associate/junior college female   associate/junior college
4     4 male   high school              male     high school             
5     5 female high school              female   high school             
6     6 male   associate/junior college male     associate/junior college

Both text columns have one value for every respondent, in the same order. We can therefore join these equal-length inputs row by row. sep = " / " replaces paste()’s default separating space with the supplied separator:

respondent_labels <- respondent_labels |>
  mutate(description = paste(sex_text, degree_text, sep = " / "))
head(respondent_labels)
# A tibble: 6 × 6
     id sex    degree                   sex_text degree_text         description
  <int> <fct>  <fct>                    <chr>    <chr>               <chr>      
1     1 male   bachelor's               male     bachelor's          male / bac…
2     2 male   graduate                 male     graduate            male / gra…
3     3 female associate/junior college female   associate/junior c… female / a…
4     4 male   high school              male     high school         male / hig…
5     5 female high school              female   high school         female / h…
6     6 male   associate/junior college male     associate/junior c… male / ass…

Each description combines the two answers belonging to that row. A label containing "NA" still has an unknown source component, as the plain-input example showed. Inspect the source answers when either component is required:

respondent_labels |>
  filter(is.na(sex) | is.na(degree)) |>
  head()
# A tibble: 6 × 6
     id sex   degree                sex_text degree_text           description  
  <int> <fct> <fct>                 <chr>    <chr>                 <chr>        
1    18 NA    high school           NA       high school           NA / high sc…
2   202 NA    graduate              NA       graduate              NA / graduate
3   307 NA    less than high school NA       less than high school NA / less th…
4   705 NA    less than high school NA       less than high school NA / less th…
5   954 male  NA                    male     NA                    male / NA    
6   998 NA    high school           NA       high school           NA / high sc…

The preview selects respondents with at least one unavailable input. It does not establish that the joined label itself is missing. We have retained every source answer so that this distinction remains checkable.

Do descriptions uniquely identify respondents? Use a familiar frequency table:

tab(respondent_labels, description)
# A tibble: 17 × 6
   description                           n percent      cum   valid valid_cum
   <chr>                             <int>   <dbl>    <dbl>   <dbl>     <dbl>
 1 NA / NA                               2  0.0502   0.0502  0.0502    0.0502
 2 NA / bachelor's                       2  0.0502   0.100   0.0502    0.100 
 3 NA / graduate                         2  0.0502   0.151   0.0502    0.151 
 4 NA / high school                      5  0.125    0.276   0.125     0.276 
 5 NA / less than high school           19  0.477    0.753   0.477     0.753 
 6 female / NA                           2  0.0502   0.803   0.0502    0.803 
 7 female / associate/junior college   219  5.49     6.30    5.49      6.30  
 8 female / bachelor's                 466 11.7     18.0    11.7      18.0   
 9 female / graduate                   322  8.08    26.1     8.08     26.1   
10 female / high school                952 23.9     49.9    23.9      49.9   
11 female / less than high school      208  5.22    55.2     5.22     55.2   
12 male / NA                             3  0.0753  55.2     0.0753   55.2   
13 male / associate/junior college     141  3.54    58.8     3.54     58.8   
14 male / bachelor's                   395  9.91    68.7     9.91     68.7   
15 male / graduate                     253  6.35    75.0     6.35     75.0   
16 male / high school                  860 21.6     96.6    21.6      96.6   
17 male / less than high school        135  3.39   100       3.39    100     

Several respondents share each common sex-degree description. A category combination describes an observation; it does not replace id. Keep the recorded identifier and source categories alongside any description.

We can also prepare a separate search form using the two text operations just learned. Show each step before combining it with another operation:

respondent_labels <- respondent_labels |>
  mutate(description_search = str_squish(description))

This supplies a spacing rule; it does not claim that the recorded GSS categories contain a spacing error. Now lowercase only that derived search column:

respondent_labels <- respondent_labels |>
  mutate(description_search = str_to_lower(description_search))
head(respondent_labels)
# A tibble: 6 × 7
     id sex    degree        sex_text degree_text description description_search
  <int> <fct>  <fct>         <chr>    <chr>       <chr>       <chr>             
1     1 male   bachelor's    male     bachelor's  male / bac… male / bachelor's 
2     2 male   graduate      male     graduate    male / gra… male / graduate   
3     3 female associate/ju… female   associate/… female / a… female / associat…
4     4 male   high school   male     high school male / hig… male / high school
5     5 female high school   female   high school female / h… female / high sch…
6     6 male   associate/ju… male     associate/… male / ass… male / associate/…

description remains available for display, and description_search follows a consistent search convention. Neither field establishes identity or resolves conflicting records. Chapter 15 later uses these tools with original country names and codes.

12.6 Review

  • rename() changes column names without changing their values or row order.
  • mutate() adds a column when its output name is new and replaces a column when that name already exists.
  • A column expression uses values from the same row; preserve the connection to the observation’s ID.
  • Assignment saves the returned table. Printing a result leaves the input object unchanged.
  • as.numeric() and as.character() return explicit conversions; check failed conversions and formatting losses.
  • paste() joins aligned text inputs; a visible label can conceal a missing component and need not identify a respondent.
  • str_squish() and str_to_lower() prepare derived text while source values remain available.

Chapter 13 next applies an operation to several selected variables. Chapter 14 then uses these mechanics for calculations, indicators, and numeric recoding.

12.7 Exercises

  1. Keep ID and hours worked, then rename the hours column to hours_last_week. Save the result under a new object name. Explain whether this changes the recorded hours or the original table.

  2. Copy hours_last_week into hours_copy in a saved result. Repeat that operation on the result. Explain why the first call adds a column, the second replaces one, and both preserve respondent order and unknown hours.

  3. Diagnose conversions and joins. Convert "hello" to numeric and inspect the result. Convert "01" to numeric and compare it with the source string. Join "Label:" to a missing character input, then compare missingness checks on the input and output. Explain the warning, the lost zero, and the concealed missing component.

  4. Keep id, sex, and degree; convert the two category columns to text separately and join their equal-length values into a descriptive label. Tabulate the labels and create a separate lowercase search form. Which columns must remain available to diagnose missing inputs? Explain why repeated descriptions cannot replace id.

Worked answers

Prepare the extract following Appendix A, then restore the saved file in your project folder:

library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")
answer_work <- gss24 |>
  select(id, hrs1)
answer_named <- answer_work |>
  rename(hours_last_week = hrs1)
head(answer_named)
# A tibble: 6 × 2
     id hours_last_week
  <int>           <dbl>
1     1              43
2     2              NA
3     3              NA
4     4              20
5     5              NA
6     6              NA
ds(answer_work)
[1] "id"   "hrs1"

answer_named contains the same IDs and hours under the clearer heading. answer_work still has the original hrs1 name. Renaming changes neither the reference period nor the values, including missing answers.

Prepare the extract following Appendix A, then restore the saved file in your project folder:

library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")
answer_named <- gss24 |>
  select(id, hrs1) |>
  rename(hours_last_week = hrs1)
answer_copy <- answer_named |>
  mutate(hours_copy = hours_last_week)
answer_replaced <- answer_copy |>
  mutate(hours_copy = hours_last_week)
head(answer_replaced)
# A tibble: 6 × 3
     id hours_last_week hours_copy
  <int>           <dbl>      <dbl>
1     1              43         43
2     2              NA         NA
3     3              NA         NA
4     4              20         20
5     5              NA         NA
6     6              NA         NA
ncol(answer_named)
[1] 2
ncol(answer_copy)
[1] 3
ncol(answer_replaced)
[1] 3

The column counts are two, three, and three. The first mutate() adds hours_copy; the second uses that existing name and therefore replaces it. Here both copies contain exactly the same source hours, so replacement changes no values. IDs and row order remain aligned, and unknown hours remain unknown in both columns. Saving each returned table makes it available without changing its input.

converted_text <- as.numeric("hello")
Warning: NAs introduced by coercion
converted_text
[1] NA
is.na(converted_text)
[1] TRUE
code <- "01"
as.numeric(code)
[1] 1
code
[1] "01"

The first conversion warns and returns NA, confirmed by TRUE from is.na(). The string has no numeric representation, so the conversion cannot return a known number such as zero. Converting "01" succeeds but returns 1, discarding its leading zero. The original text remains "01".

missing_component <- NA_character_
missing_label <- paste("Label:", missing_component)
missing_label
[1] "Label: NA"
is.na(missing_component)
[1] TRUE
is.na(missing_label)
[1] FALSE

The missing input gives TRUE, but "Label: NA" is a non-missing string, giving FALSE. Inspect the inputs before using a joined label as evidence of complete information.

Load the saved extract in a fresh session:

library(tidyverse)
library(tidystata)
answer_gss24 <- readRDS("gss24.rds")
answer_labels <- answer_gss24 |>
  select(id, sex, degree) |>
  mutate(sex_text = as.character(sex)) |>
  mutate(degree_text = as.character(degree)) |>
  mutate(description = paste(sex_text, degree_text, sep = " / ")) |>
  mutate(description_search = str_squish(description)) |>
  mutate(description_search = str_to_lower(description_search))
tab(answer_labels, description)
# A tibble: 17 × 6
   description                           n percent      cum   valid valid_cum
   <chr>                             <int>   <dbl>    <dbl>   <dbl>     <dbl>
 1 NA / NA                               2  0.0502   0.0502  0.0502    0.0502
 2 NA / bachelor's                       2  0.0502   0.100   0.0502    0.100 
 3 NA / graduate                         2  0.0502   0.151   0.0502    0.151 
 4 NA / high school                      5  0.125    0.276   0.125     0.276 
 5 NA / less than high school           19  0.477    0.753   0.477     0.753 
 6 female / NA                           2  0.0502   0.803   0.0502    0.803 
 7 female / associate/junior college   219  5.49     6.30    5.49      6.30  
 8 female / bachelor's                 466 11.7     18.0    11.7      18.0   
 9 female / graduate                   322  8.08    26.1     8.08     26.1   
10 female / high school                952 23.9     49.9    23.9      49.9   
11 female / less than high school      208  5.22    55.2     5.22     55.2   
12 male / NA                             3  0.0753  55.2     0.0753   55.2   
13 male / associate/junior college     141  3.54    58.8     3.54     58.8   
14 male / bachelor's                   395  9.91    68.7     9.91     68.7   
15 male / graduate                     253  6.35    75.0     6.35     75.0   
16 male / high school                  860 21.6     96.6    21.6      96.6   
17 male / less than high school        135  3.39   100       3.39    100     
answer_labels |>
  filter(is.na(sex) | is.na(degree)) |>
  head()
# A tibble: 6 × 7
     id sex   degree         sex_text degree_text description description_search
  <int> <fct> <fct>          <chr>    <chr>       <chr>       <chr>             
1    18 NA    high school    NA       high school NA / high … na / high school  
2   202 NA    graduate       NA       graduate    NA / gradu… na / graduate     
3   307 NA    less than hig… NA       less than … NA / less … na / less than hi…
4   705 NA    less than hig… NA       less than … NA / less … na / less than hi…
5   954 male  NA             male     NA          male / NA   male / na         
6   998 NA    high school    NA       high school NA / high … na / high school  

The frequency table counts respondents sharing the same description; frequencies above one demonstrate that the labels are not unique identifiers. Keep id, sex, and degree. The source categories reveal unavailable components that joined text can conceal. Converting their displayed names to character supports text operations without changing the recorded answers in those source columns.