17  List columns

An ordinary atomic vector holds values of one basic type. A list can keep objects of different types or lengths together without converting them to a common type. A list column stores one such component for each row of a table. We will split existing entity names into words, retain the words together, then expand them while preserving their connection to the original names.

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

The first example needs only base R. The application uses the public country data already used in Section 15.7, not participant information. Selection and column creation are prerequisites; the later reshaping chapter is not.

17.1 Keep different objects together

A list can hold different types together, such as a number and some text:

list(1, "a")
[[1]]
[1] 1

[[2]]
[1] "a"

The printed result has two components: the number 1 and the character value "a". Their types remain distinct. A list is useful when the pieces we want to keep together are not all the same kind of object.

A list is not necessarily a dataset with observations in rows. Tables and model results can themselves be stored as list components. The fuller comparison in Chapter 28 explains how lists relate to the other structures used in R.

17.2 Keep the words of a name together

Our World in Data, abbreviated OWID, supplies the captured life-expectancy CSV used earlier. It contains aggregate entity-year statistics. It has no workshop participants. We reuse its entity names because names can contain different numbers of words. Copy the book’s captured CSV into your project’s examples folder:

library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.1     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.3     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
owid <- read_csv("examples/life-expectancy.csv")
Rows: 21565 Columns: 4
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (2): Entity, Code
dbl (2): Year, Life expectancy

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
entity_names <- owid |>
  filter(Year == 2023) |>
  select(Entity, Code, Year)
head(entity_names)
# A tibble: 6 × 3
  Entity         Code      Year
  <chr>          <chr>    <dbl>
1 Afghanistan    AFG       2023
2 Africa         OWID_AFR  2023
3 Albania        ALB       2023
4 Algeria        DZA       2023
5 American Samoa ASM       2023
6 Americas       NA        2023

The selected year has 261 entities. Some are territories or aggregates, so not every name denotes a sovereign country. Retaining one year avoids repeating each name across years. Code is missing for some aggregates; retain Entity and Year as well rather than assuming codes identify every row.

str_split() takes character values and a separator pattern, returning a list with one character vector for each input value. Here the familiar fixed(" ") specifies a literal space, as in Section 15.7. The captured names use single spaces between words; we are splitting their recorded form, not repairing source errors:

name_lists <- entity_names |>
  mutate(words = str_split(Entity, fixed(" ")))
head(name_lists)
# A tibble: 6 × 4
  Entity         Code      Year words    
  <chr>          <chr>    <dbl> <list>   
1 Afghanistan    AFG       2023 <chr [1]>
2 Africa         OWID_AFR  2023 <chr [1]>
3 Albania        ALB       2023 <chr [1]>
4 Algeria        DZA       2023 <chr [1]>
5 American Samoa ASM       2023 <chr [2]>
6 Americas       NA        2023 <chr [1]>
name_lists |>
  filter(Entity == "United States")
# A tibble: 1 × 4
  Entity        Code   Year words    
  <chr>         <chr> <dbl> <list>   
1 United States USA    2023 <chr [2]>

The new words column is a list column. Its component for “United States” contains two strings, “United” and “States”. The printed <chr [2]> abbreviation describes a two-element character vector stored in that cell; it is not the literal cell text. Names with one word have one-element components. The table still has one row per entity in 2023, and all original name and code values remain.

17.3 Expand words into rows

When we need to inspect words separately, unnest_longer() from tidyr expands a list column. First supply the table through the pipe and select the list column words. Each component’s values become separate rows, with the other columns repeated alongside them. Assignment stores a new table; name_lists retains the complete components:

name_words_default <- name_lists |>
  unnest_longer(words)
head(name_words_default)
# A tibble: 6 × 4
  Entity         Code      Year words      
  <chr>          <chr>    <dbl> <chr>      
1 Afghanistan    AFG       2023 Afghanistan
2 Africa         OWID_AFR  2023 Africa     
3 Albania        ALB       2023 Albania    
4 Algeria        DZA       2023 Algeria    
5 American Samoa ASM       2023 American   
6 American Samoa ASM       2023 Samoa      

The column is still called words, but it now contains one character value per row. The original entity, code, and year stay attached to each word. Give the expanded value column a singular name with values_to:

name_words_named <- name_lists |>
  unnest_longer(words, values_to = "word")
head(name_words_named)
# A tibble: 6 × 4
  Entity         Code      Year word       
  <chr>          <chr>    <dbl> <chr>      
1 Afghanistan    AFG       2023 Afghanistan
2 Africa         OWID_AFR  2023 Africa     
3 Albania        ALB       2023 Albania    
4 Algeria        DZA       2023 Algeria    
5 American Samoa ASM       2023 American   
6 American Samoa ASM       2023 Samoa      

The option changes the output name, not the number of rows or the words. The components also have an order within each name. Add indices_to to name the column that records those positions:

name_words <- name_lists |>
  unnest_longer(
    words,
    values_to = "word",
    indices_to = "word_position"
  )
head(name_words)
# A tibble: 6 × 5
  Entity         Code      Year word_position word       
  <chr>          <chr>    <dbl>         <int> <chr>      
1 Afghanistan    AFG       2023             1 Afghanistan
2 Africa         OWID_AFR  2023             1 Africa     
3 Albania        ALB       2023             1 Albania    
4 Algeria        DZA       2023             1 Algeria    
5 American Samoa ASM       2023             1 American   
6 American Samoa ASM       2023             2 Samoa      
name_words |>
  filter(Entity == "United States")
# A tibble: 2 × 5
  Entity        Code   Year word_position word  
  <chr>         <chr> <dbl>         <int> <chr> 
1 United States USA    2023             1 United
2 United States USA    2023             2 States

The United States now contributes two rows. Each retains its original entity, code, and year, while positions 1 and 2 identify “United” and “States”. A row now represents an entity-word position. It is not an additional entity or an independent observation of life expectancy.

count() counts rows within combinations of columns, as in the age-group checks in Chapter 14. Its default result column is n. Start with the number of word positions for each entity-year:

word_counts_default <- name_words |>
  count(Entity, Year)
head(word_counts_default)
# A tibble: 6 × 3
  Entity          Year     n
  <chr>          <dbl> <int>
1 Afghanistan     2023     1
2 Africa          2023     1
3 Albania         2023     1
4 Algeria         2023     1
5 American Samoa  2023     2
6 Americas        2023     1

Each output row describes one entity in 2023; n counts that name’s words, not additional entities. The name option gives this count a more informative heading. It names the returned count column, rather than selecting a source variable:

word_counts <- name_words |>
  count(Entity, Year, name = "words")
head(word_counts)
# A tibble: 6 × 3
  Entity          Year words
  <chr>          <dbl> <int>
1 Afghanistan     2023     1
2 Africa          2023     1
3 Albania         2023     1
4 Algeria         2023     1
5 American Samoa  2023     2
6 Americas        2023     1

The values match the previous n column, but the heading now states what was counted. We can reconcile the expanded table’s row total with these component counts. sum() adds numeric input values and returns one total without changing them. Here $ extracts the numeric count column:

word_total <- sum(word_counts$words)
word_total
[1] 389
nrow(name_words)
[1] 389
word_total == nrow(name_words)
[1] TRUE

The equality returns TRUE: adding the words across names gives exactly the number of expanded rows. These counts contain no missing entries, so there is no missing-value option to set in this calculation. The total counts word positions, with repeated words counted each time they occur.

A separate check asks whether any entity-year-position combination occurs more than once. An identifying combination should occur exactly once in this example:

name_words |>
  count(Entity, Year, word_position) |>
  filter(n != 1)
# A tibble: 0 × 4
# ℹ 4 variables: Entity <chr>, Year <dbl>, word_position <int>, n <int>
nrow(name_lists)
[1] 261
nrow(word_counts)
[1] 261
nrow(name_words)
[1] 389

The duplicate-position check returns zero rows. There are still 261 entities, matching the 261 source rows, but 389 word positions. Counting rows of the expanded table would give longer names more weight. A mean of a measurement repeated across these rows would therefore answer a different question from a mean across entities.

Words are parts of names. Splitting “Trinidad and Tobago” does not create separate records for three places, and “and” is not an entity. This example teaches storage and expansion, not geographical classification. Similarly, if another source stores paired aliases and descriptions, splitting both columns requires checking that each alias still matches its own description. Expanding them independently can create incorrect combinations; this file supplies no such paired lists.

17.4 Review

  • list() keeps components together while allowing different types.
  • A component can itself be a more complex object; the list need not have a rectangular shape.
  • str_split() returns one list component per input string; a list column retains one component per table row.
  • unnest_longer() expands components into rows; retain identifiers and within-component positions.
  • count() names and checks component counts; sum() reconciles their total with the expanded row count.
  • Expanded rows need not be independent observations. Preserve the source name and interpret the new unit before calculating summaries.

The later Chapter 19 changes the layout of questionnaire and monthly data. It also checks which columns identify each resulting row.

17.5 Exercises

  1. Explain why list(1, "a") preserves two types. What does <chr [2]> mean for a list cell containing the words of "United States"? How does it differ from literal character text?
  2. Reload the captured CSV, keep the entity names in 2023, split them on spaces, and expand the resulting list with positions. Before running the expansion, predict how many rows “Trinidad and Tobago” contributes before and after expansion. Inspect its words and positions to check your prediction.
  3. Name the per-entity word count words, add those counts, and reconcile the result with the expanded row count. Check that every entity-year-position combination is unique. A colleague keeps only word after expansion and calculates a mean of a measurement repeated beside those words. Diagnose the loss of identifiers and the changed weighting.

Worked answers

library(tidyverse)
list(1, "a")
[[1]]
[1] 1

[[2]]
[1] "a"
str_split("United States", fixed(" "))
[[1]]
[1] "United" "States"

The first list retains a number and a character value as separate components. The second contains one component holding two character values. <chr [2]> describes a character vector of length two inside one list cell. It is a compact description of the cell, not literal text stored there. A literal character cell holds the complete name as one string; a list cell can hold the vector of its words without adding table rows.

library(tidyverse)
answer_owid <- read_csv("examples/life-expectancy.csv")
Rows: 21565 Columns: 4
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (2): Entity, Code
dbl (2): Year, Life expectancy

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
answer_names <- answer_owid |>
  filter(Year == 2023) |>
  select(Entity, Code, Year) |>
  mutate(words = str_split(Entity, fixed(" ")))
answer_words <- answer_names |>
  unnest_longer(words, values_to = "word", indices_to = "word_position")
answer_names |>
  filter(Entity == "Trinidad and Tobago")
# A tibble: 1 × 4
  Entity              Code   Year words    
  <chr>               <chr> <dbl> <list>   
1 Trinidad and Tobago TTO    2023 <chr [3]>
answer_words |>
  filter(Entity == "Trinidad and Tobago")
# A tibble: 3 × 5
  Entity              Code   Year word_position word    
  <chr>               <chr> <dbl>         <int> <chr>   
1 Trinidad and Tobago TTO    2023             1 Trinidad
2 Trinidad and Tobago TTO    2023             2 and     
3 Trinidad and Tobago TTO    2023             3 Tobago  

One entity row becomes three word-position rows. Each retains the same complete name, code, and year. The three words are not three countries.

library(tidyverse)
answer_owid <- read_csv("examples/life-expectancy.csv")
Rows: 21565 Columns: 4
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (2): Entity, Code
dbl (2): Year, Life expectancy

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
answer_names <- answer_owid |>
  filter(Year == 2023) |>
  select(Entity, Code, Year)
answer_words <- answer_names |>
  mutate(words = str_split(Entity, fixed(" "))) |>
  unnest_longer(words, values_to = "word", indices_to = "word_position")
answer_counts <- answer_words |>
  count(Entity, Year, name = "words")
answer_total <- sum(answer_counts$words)
answer_total
[1] 389
nrow(answer_words)
[1] 389
answer_total == nrow(answer_words)
[1] TRUE
nrow(answer_counts) == nrow(answer_names)
[1] TRUE
answer_words |>
  count(Entity, Year, word_position) |>
  filter(n != 1)
# A tibble: 0 × 4
# ℹ 4 variables: Entity <chr>, Year <dbl>, word_position <int>, n <int>

Both equalities return TRUE, and the duplicate-position check returns zero rows. The same entities remain, but those with longer names contribute more rows. A row count describes word positions; a mean of repeated entity measurements would weight entities by their number of words unless we first return to one record per entity.

Keeping only word would remove the complete name, code, year, and within-name position. A repeated word could then no longer be traced to its source name. Retain these columns in the working table; select a short display separately if needed. Likewise, repeating a measurement once per word gives longer names more weight. Count or summarize at the entity level when the question concerns entities.