R Bootcamp: Day 1


Types, vectors, and data frames; reading data



Eric Manning

August 28, 2026



princeton-ddss.github.io/r-bootcamp

This session



1) Types, vectors, and objects

  • (Mostly) every object has a type
  • Vectors, matrices, lists, and more
  • Subsetting syntax; common functions


2) Data frames, reading data, and R as a calculator

  • Data frames: what and how
  • Getting data in and out of R
  • Summary statistics and some real data

Some sources



Types

Every object has a type



x <- 1
class(x)
[1] "numeric"


school <- "Princeton"
class(school)
[1] "character"


y <- c(1, 2)
class(y)
[1] "numeric"


The type is R’s label for what kind of thing a value is — every object carries one

Types decide what’s possible: numbers can be averaged, text can be capitalized, etc.

Most confusing errors are type errors in disguise.

Some basic types



Type Examples
numeric 1, 3.14, -2.5 numbers (a.k.a. “double”)
integer 1L, 42L whole numbers; the L makes it explicit
character "Princeton" text, in quotes — 'single' work too
logical TRUE, FALSE yes/no answers (T/F acceptable to some)
Date Sys.Date() calendar dates


Coming soon: list, data.frame

Mostly you’ll just say “numeric” — R treats 1 as a double by default

Why keep both? Storage: double is 8 bytes (for the decimals); integer is 4 bytes.

Mostly you’ll just notice it as int vs num in output

Reassignment



An object name holds a value. Assigning again replaces it.

x <- x + 2
x
[1] 3


Types decide what’s allowed:

school + "University"
Error in `school + "University"`:
! non-numeric argument to binary operator


R will not guess what you meant.

Casting: convert types deliberately



The as.*() family converts between types

as.numeric("3")
as.character(3.9)
[1] 3
[1] "3.9"

Careful casting to integer — decimals get chopped, not rounded:

as.integer(3.9)
[1] 3


as.numeric("Princeton")
Warning: NAs introduced by coercion
[1] NA

When R can’t convert, you get NA and a warning (not an error!)

Booleans: questions with yes/no answers



3 == 3
3 == 4
school == "Princeton"
[1] TRUE
[1] FALSE
[1] TRUE

And the answers are a type of their own:

class(TRUE)
[1] "logical"


== asks “are these equal?” = is an assignment operator, like <-

Combining booleans: &, |, !



And, or, not:

TRUE & FALSE
TRUE | FALSE
!TRUE
[1] FALSE
[1] TRUE
[1] FALSE


Comparisons produce booleans, so you can combine questions:

(3 > 1) & (school == "Princeton")
[1] TRUE

Boolean operators && and ||



x <- T
y <- "hello"
x | y
Error in `x | y`:
! operations are possible only for numeric, logical or complex types


x || y
[1] TRUE


If left side is TRUE, right side is skipped. This is useful in if-else statements, other control flows.
These are NOT vectorized.

Missing values: NA



NA marks a missing value inside your data

survey <- c(4, NA, 5)
is.na(survey)
[1] FALSE  TRUE FALSE


Why not just ask survey == NA?

survey == NA
[1] NA NA NA


“Is this value equal to unknown?” — the answer is unknown. Use is.na().

Even NA has a type



class(NA)
[1] "logical"


NA takes the type of the vector it sits in:

class(c(1.5, NA))
class(c("a", NA))
[1] "numeric"
[1] "character"


There’s an NA for every type (NA_real_, NA_character_, …)

Usually you don’t need to be explicit.

NA is contagious



One NA poisons every calculation it touches

mean(survey)
[1] NA


Tell summary functions to drop missings first:

mean(survey, na.rm = TRUE)
[1] 4.5


You will type na.rm = TRUE a thousand times and one day you will be grateful.

Nothing at all: NULL



NULL is the absence of an object — not a missing value

is.null(NULL)
[1] TRUE


NA is something; NULL is nothing at all:

length(NA)
length(NULL)
[1] 1
[1] 0


NA holds a place; NULL disappears:

c(1, NA, 2)
c(1, NULL, 2)
[1]  1 NA  2
[1] 1 2

Dates are a type too



day1 <- as.Date("2026-08-29")
class(day1)

## use YYYY-MM-DD where possible
## but see `format = ` arg of as.Date
## Documentation: ?as.Date
[1] "Date"


Date math:

day1 + 7 ## also a date!
[1] "2026-09-05"


as.Date("2026-12-25") - day1
Time difference of 118 days

Subtracting two dates is something else: difftime class

Vectors

There are (almost) no scalars for data values



c() combines values into a vector

x <- c(5, 10, 15)
x
length(x)
[1]  5 10 15
[1] 3


Surprise: you’ve been using vectors all along

length(school)
[1] 1

school is a character vector of length 1.

One type per vector



A vector holds exactly one type. Mix them and R will silently “downcast” where possible.

c(1, "two", TRUE)
[1] "1"    "two"  "TRUE"


To convert on purpose, cast the whole vector at once:

as.character(c(1, 2))
[1] "1" "2"


Most functions are vectorized: they work element-by-element without being told explicitly

Combining text: paste()



The right way to do school + "University":

paste("R", "Bootcamp")
paste0(school, "University")
[1] "R Bootcamp"
[1] "PrincetonUniversity"

paste() glues with a space; paste0() glues with nothing


It casts numbers to text and vectorizes, all at once:

paste("Day", c(1, 2, 3))
[1] "Day 1" "Day 2" "Day 3"

And collapse glues the results into one single string:

paste("Day", c(1, 2, 3), collapse = ", ")
[1] "Day 1, Day 2, Day 3"

Math is vectorized too



x * 2
x + x
x^2
[1] 10 20 30
[1] 10 20 30
[1]  25 100 225

(** also works for powers, but write ^)


Comparisons are vectorized — one question, one answer per element:

x > 8
[1] FALSE  TRUE  TRUE

Making sequences



1:5
5:1
[1] 1 2 3 4 5
[1] 5 4 3 2 1


For more control, seq():

seq(1, 9, by = 2)
[1] 1 3 5 7 9

Summarizing booleans: any() and all()



Collapse a vector of yes/no answers into one answer

x > 8
[1] FALSE  TRUE  TRUE
any(x > 8)
all(x > 8)
[1] TRUE
[1] FALSE


“Did anyone answer yes?” / “Did everyone answer yes?”

TRUE counts as 1 and FALSE as 0 — so you can count and share:

sum(x > 8)
mean(x > 8)
[1] 2
[1] 0.6666667

Useful vector questions



regions <- c("South", "West", "South", "Northeast")
length(regions)
unique(regions)
[1] 4
[1] "South"     "West"      "Northeast"


Is a value in the vector?

"West" %in% regions
"Midwest" %in% regions
[1] TRUE
[1] FALSE


!("Midwest" %in% regions) == ("Midwest" %notin% regions)
[1] TRUE

Elements in vectors can have names



grades <- c(math = 90, r_bootcamp = 95)
grades
      math r_bootcamp 
        90         95 
names(grades)
[1] "math"       "r_bootcamp"


Names are also a way to pull values out:

grades["r_bootcamp"]
r_bootcamp 
        95 

Subsetting by position



[ pulls out pieces of a vector

x <- c(5, 10, 15, 20)
x[2]
x[c(1, 3)]
[1] 10
[1]  5 15


Negative positions drop instead:

x[-1]
[1] 10 15 20

Subsetting by condition



A boolean vector inside [ keeps the TRUE positions

x[x > 8]
[1] 10 15 20


which() turns a condition into positions:

which(x > 8)
x[which(x > 8)]
[1] 2 3 4
[1] 10 15 20


Read x[x > 8] aloud: “x, where x is greater than 8”

Sorting and peeking



x <- c(15, 5, 20, 10)
sort(x)
[1]  5 10 15 20


head() and tail() peek at the ends. Combine for smallest and biggest:

head(sort(x), 2)
tail(sort(x), 2)
[1]  5 10
[1] 15 20


order() yields the order of a (hypothetical) sort:

order(x)
x[order(x)]
[1] 2 4 1 3
[1]  5 10 15 20

Matrices

A matrix is a vector with more dimensions



m <- matrix(1:8, nrow = 4)
m
     [,1] [,2]
[1,]    1    5
[2,]    2    6
[3,]    3    7
[4,]    4    8


dim(m)
[1] 4 2


t() flips rows and columns:

t(m)
     [,1] [,2] [,3] [,4]
[1,]    1    2    3    4
[2,]    5    6    7    8

Filling by row: byrow



matrix() fills down the columns unless you say otherwise:

matrix(1:8, nrow = 4, byrow = FALSE) ## (the default)
     [,1] [,2]
[1,]    1    5
[2,]    2    6
[3,]    3    7
[4,]    4    8
matrix(1:8, nrow = 4, byrow = TRUE)
     [,1] [,2]
[1,]    1    2
[2,]    3    4
[3,]    5    6
[4,]    7    8

Growing a matrix: cbind()



Bind on a new column:

cbind(m, 9:12)
     [,1] [,2] [,3]
[1,]    1    5    9
[2,]    2    6   10
[3,]    3    7   11
[4,]    4    8   12


A single value gets recycled:

cbind(m, 0)
     [,1] [,2] [,3]
[1,]    1    5    0
[2,]    2    6    0
[3,]    3    7    0
[4,]    4    8    0

When the lengths don’t match



cbind(m, 1:3)
Warning in cbind(m, 1:3): number of rows of result is not a multiple of
vector length (arg 2)
     [,1] [,2] [,3]
[1,]    1    5    1
[2,]    2    6    2
[3,]    3    7    3
[4,]    4    8    1

Why the warning?

Recycling is convenient for single values and dangerous for everything else.

Matrix math



Arithmetic is element-by-element, like vectors

m * 2
     [,1] [,2]
[1,]    2   10
[2,]    4   12
[3,]    6   14
[4,]    8   16


Sums along each dimension:

rowSums(m)
colSums(m)
[1]  6  8 10 12
[1] 10 26

Matrix multiplication: %*%



Build an N × 1 column — now shapes matter:

v <- matrix(c(10, 20), ncol = 1)
v
     [,1]
[1,]   10
[2,]   20

%*% matches inner dimensions and returns a new shape — (4 × 2) %*% (2 × 1) → (4 × 1):

m %*% v
     [,1]
[1,]  110
[2,]  140
[3,]  170
[4,]  200

* is not %*%



Element-wise * is different. It wants identical shapes, just like + or - or /:

m * v
Error in `m * v`:
! non-conformable arrays


(m * 2 worked because a lone number recycles; a mis-shaped matrix does not)

If you mean linear algebra, write %*%.

Subsetting a matrix: [row, column]



m[1, ]
m[, 2]
m[3, 2]
[1] 1 5
[1] 5 6 7 8
[1] 7


Conditions work here too — and count down the columns:

m[m > 5]
which(m > 5)
[1] 6 7 8
[1] 6 7 8

Lists

Lists hold anything



Different types, different lengths, even other lists

x <- list()
x[[1]] <- 1:5
x[[2]] <- seq(1, 9, by = 2)
x[[3]] <- "lists are flexible"
x[[4]] <- list(a = "hello", b = "world")


str(x)
List of 4
 $ : int [1:5] 1 2 3 4 5
 $ : num [1:5] 1 3 5 7 9
 $ : chr "lists are flexible"
 $ :List of 2
  ..$ a: chr "hello"
  ..$ b: chr "world"

[[ versus [



x[[3]]
[1] "lists are flexible"
x[3]
[[1]]
[1] "lists are flexible"


[[ returns the element itself; [ returns a smaller list

class(x[[3]])
class(x[3])
[1] "character"
[1] "list"

Why care about lists?

Data frames

A data frame is a fancy list



mtcars is a data frame built into R — let’s borrow it

df <- mtcars
class(df)
[1] "data.frame"


Under the hood: a list of columns, all the same length

is.list(df)
dim(df) # c(nrow(df), ncol(df))
length(df)
[1] TRUE
[1] 32 11
[1] 11

Looking at a data frame



Typing df prints all rows. Use head() instead:

head(df, 3) ## head(df) default is 6 rows
               mpg cyl disp  hp drat    wt  qsec vs am gear carb
Mazda RX4     21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
Mazda RX4 Wag 21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
Datsun 710    22.8   4  108  93 3.85 2.320 18.61  1  1    4    1


head() shows just the first rows (six, unless you say otherwise)

The whole structure: str()



One line per column: type first, values after

str(df)
'data.frame':   32 obs. of  11 variables:
 $ mpg : num  21 21 22.8 21.4 18.7 ...
 $ cyl : num  6 6 4 6 8 ...
 $ disp: num  160 160 108 258 360 ...
 $ hp  : num  110 110 93 110 175 ...
 $ drat: num  3.9 3.9 3.85 3.08 3.15 ...
 $ wt  : num  2.62 2.88 ...
 $ qsec: num  16.5 17 ...
 $ vs  : num  0 0 1 1 0 ...
 $ am  : num  1 1 1 0 0 ...
 $ gear: num  4 4 4 3 3 ...
 $ carb: num  4 4 1 1 2 ...


This is usually the first thing to run on any new data (alternatively, View(df) in RStudio)

Row and column names in a data.frame



names(df)
 [1] "mpg"  "cyl"  "disp" "hp"   "drat" "wt"   "qsec" "vs"   "am"   "gear"
[11] "carb"


rownames(df)[1:4]
[1] "Mazda RX4"      "Mazda RX4 Wag"  "Datsun 710"     "Hornet 4 Drive"


The car names aren’t a column. They live in the row names

Don’t do this.

You can change the column names



Assign into the attribute:

df2 <- df

colnames(df2)[1] <- "miles_per_gallon"

names(df2)
 [1] "miles_per_gallon" "cyl"              "disp"            
 [4] "hp"               "drat"             "wt"              
 [7] "qsec"             "vs"               "am"              
[10] "gear"             "carb"            


Same trick works for rownames() — these are just character vectors you can overwrite

Grabbing a column: $



df$cyl
 [1] 6 6 4 6 8 6 8 4 4 6 6 8 8 8 8 8 8 4 4 4 4 8 8 8 8 4 4 4 8 6 8 4


table() counts values, but it’s slow on large or high-cardinality vectors

table(df$cyl) # add a useNA = 'ifany' if you suspect NAs!

 4  6  8 
11  7 14 


Two columns give a cross-tab:

table(df$cyl, df$gear)
   
     3  4  5
  4  1  8  2
  6  2  4  1
  8 12  0  2

as.matrix(): what do we lose?



cars_matrix <- as.matrix(df)
cars_matrix[1:3, 1:4]
               mpg cyl disp  hp
Mazda RX4     21.0   6  160 110
Mazda RX4 Wag 21.0   6  160 110
Datsun 710    22.8   4  108  93


The row names survive because matricies allow row (and column) names.

class(cars_matrix)
[1] "matrix" "array" 


Matricies require the same type for all cells: one character column would have case the whole matrix into chars

Subsetting rows



Data frames take [row, column] just like matrices

df[1:2, c("mpg", "cyl")]
              mpg cyl
Mazda RX4      21   6
Mazda RX4 Wag  21   6


Conditions filter rows:

df[df$hp > 200, c("mpg", "cyl", "hp")]
                     mpg cyl  hp
Duster 360          14.3   8 245
Cadillac Fleetwood  10.4   8 205
Lincoln Continental 10.4   8 215
Chrysler Imperial   14.7   8 230
Camaro Z28          13.3   8 245
Ford Pantera L      15.8   8 264
Maserati Bora       15.0   8 335

Extracting columns, four ways



head(df["cyl"], 3)
              cyl
Mazda RX4       6
Mazda RX4 Wag   6
Datsun 710      4

df["cyl"] is still a data frame — list subsetting with [


[[ or $ or [, "cyl"] or [, 2]) gives the vector itself:

df[["cyl"]]
 [1] 6 6 4 6 8 6 8 4 4 6 6 8 8 8 8 8 8 4 4 4 4 8 8 8 8 4 4 4 8 6 8 4


And single elements by position: df[1, 1] is 21

Build one from scratch



data.frame() works like list(): name the columns, give equal-length vectors

schools <- data.frame(
    school = c("Princeton", "Rutgers"),
    students = c(5590, 36000)
)
schools
     school students
1 Princeton     5590
2   Rutgers    36000


Add rows or columns with rbind() / cbind():

yale <- data.frame(school = "Yale", students = 6645)
rbind(schools, yale)
     school students
1 Princeton     5590
2   Rutgers    36000
3      Yale     6645

Reading and writing data

Your computer is a tree of folders



Every file lives in a folder, and every folder lives inside another — one big tree (folder = “directory”).

/Users/you/
├── Desktop/
├── Downloads/
└── Documents/
    └── bootcamp/
        ├── day-1.R
        └── data/
            └── county_population.csv

A file’s path is its full address — the folders you pass through, joined by /:

/Users/you/Documents/bootcamp/data/county_population.csv

(Windows writes it C:\Users\you\... but in R, always write / – it works on every system)


Mac Users: right-/double-click + Option (⌥) + “Copy ‘…’ as Pathname”

Growing up is hard



If you have everything in ~/Downloads….

Where is R working? The working directory



R always works from one folder — its working directory:

getwd()
[1] "/Users/ericmm/Documents/GitHub/r-bootcamp/notes"


A relative path gives directions from there. No absolute file path required.

county <- read.csv("data/county_population.csv")

(“go into data/, grab the file” — and .. means “up one folder”)


Can’t find your file? Ask what R can actually see in the current (working) directory:

list.files()
list.files("data")

Making paths painless



setwd() exists, but hard-coded paths break on other computers (or yours when you move stuff around).


Always assume you’re in the project root and make all paths relative.


  • If you have a bunch of scripts, but them in scripts/
  • Then paths to data will be ../data/county_population.csv relative to that folder (but why won’t we need this?)

Our data: county populations



2020 Census population for every US county. Download it here and save it in data/

readLines("data/county_population.csv", n = 3)
[1] "fips,region,division,pop"             
[2] "01001,South,East South Central,58805" 
[3] "01003,South,East South Central,231767"


A .csv is just text: one row per line, commas between values, names in the first line

Reading it in: read.csv()



county <- read.csv("county_population.csv")
head(county, 3)
  fips region           division    pop
1 1001  South East South Central  58805
2 1003  South East South Central 231767
3 1005  South East South Central  25223


Compare with the raw file: "01001" became the number 1001 — the leading zeros are gone!

Reading it in, with arguments



Tell read.csv() what the column really is:

county <- read.csv(
    "county_population.csv",
    colClasses = c(fips = "character")
)
str(county)
'data.frame':   3143 obs. of  4 variables:
 $ fips    : chr  "01001" "01003" ...
 $ region  : chr  "South" "South" ...
 $ division: chr  "East South Central" "East South Central" ...
 $ pop     : int  58805 231767 25223 22293 59134 ...


This distinction is often important if you are joining data, if ‘0xx’ is different from ‘xx’ etc.

Watch for coded missingness



Surveys love codes like -9 or -999 for “no answer.”

ages <- c(34, -999, 27)
mean(ages)
[1] -312.6667

NA is always better. (Why?)


na.strings translates the codes to NA when reading:

survey <- read.csv("survey.csv", na.strings = c("-9", "-999"))

Other read.csv() arguments to know



Argument When you need it
header = FALSE the file has no column-name row
skip = 3 junk lines (titles, notes) sit above the data
nrows = 100 peek at the first chunk of a huge file
fill = TRUE ragged files — some rows end early
na.strings which codes mean “missing”


Every one of these is a question about your file. You can always use readLines(path, n = a_small_number) to inspect the file.

First look at new data: summary()



summary(county)
        fips            region          division         pop          
 Length   :3143   Length   :3143   Length   :3143   Min.   :      64  
 N.unique :3143   N.unique :   4   N.unique :   9   1st Qu.:   10832  
 N.blank  :   0   N.blank  :   0   N.blank  :   0   Median :   25698  
 Min.nchar:   5   Min.nchar:   4   Min.nchar:   7   Mean   :  105456  
 Max.nchar:   5   Max.nchar:   9   Max.nchar:  18   3rd Qu.:   67946  
                                                    Max.   :10014009  


Every column at once: quartiles for numbers, profiles for text. Both str() and summary() are useful.

Beyond csv



Format Functions Trade-off
.csv read.csv() / write.csv() opens anywhere; loses types (ask fips!)
.rds readRDS() / saveRDS() one R object, restored exactly; R-only
.rda load() / save() many objects at once; R-only
.dta, .xlsx haven, readxl packages Stata and Excel files 😩


Careful with .rda: load() will overwrite existing objects if they have the same name.


Rule of thumb: share .csv with humans, save .rds and .rda for yourself (and collaborators).


Sometimes.

Writing files



write.csv(county, "county_population.csv", row.names = FALSE)

row.names = FALSE or you’ll get a useless, unnamed column


saveRDS(county, "county_population.rds") ## holds one object (compressed)
county <- readRDS("county_population.rds")


save(county, ..., file = "stuff.rda") ## holds one object (compressed)
load("stuff.rda")


Reasons to prefer one or the other?

R as a calculator

Summarizing a column



mean(county$pop)
[1] 105456.3
median(county$pop)
[1] 25698


Functions compose, evaluated inside-out:

round(mean(county$pop))

## alternatively, mean(county$pop) |> round() ...
[1] 105456


And a second argument picks the digits:

round(3.14159, 2) ## see also: ceiling, floor, trunc
[1] 3.14

Making new columns



Assign into a column that doesn’t exist yet — and now it does:

county$pop_millions <- round(county$pop / 1e6, 2)
head(county, 3)
   fips region           division    pop pop_millions
1 01001  South East South Central  58805         0.06
2 01003  South East South Central 231767         0.23
3 01005  South East South Central  25223         0.03

The rest of the toolbox



sd(county$pop) ## see also: var()
[1] 335760.4

log() and exp():

log(county$pop[1])
exp(1)
[1] 10.98198
[1] 2.718282


Careful: log() is the natural log — base 10 is log10()


Every function documents itself:

?median

Asking real questions



What share of counties hold a million-plus people?

mean(county$pop > 1e6)
[1] 0.0155902

mean() on booleans again — about 1.6% (sum() would count them)


Which county is the biggest? which.max() finds the position:

county[which.max(county$pop), ]
      fips region division      pop pop_millions
1946 06037   West  Pacific 10014009        10.01

Sorting the whole data frame



Give me all the information about the three largest counties:

head(county[order(-county$pop), ], 3)

## for readability:
# ord = order(-county$pop)
# county[ord,] |> head(3)
      fips  region           division      pop pop_millions
1946 06037    West            Pacific 10014009        10.01
1546 17031 Midwest East North Central  5275541         5.28
1453 48201   South West South Central  4731145         4.73

summary() and beyond



summary(county$pop)
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
      64    10832    25698   105456    67946 10014009 


Build your own with a named vector:

c(
    n = length(county$pop),
    mean = mean(county$pop),
    sd = sd(county$pop)
)
       n     mean       sd 
  3143.0 105456.3 335760.4 

This gets repetitive if we write the same code for each column – or for a bunch of different data frames…

Wrap-up

Monday



Morning:

  • writing your own functions
  • loops
  • lapply() and friends
  • Exercise: putting it all together in base R


Afternoon:

  • Writing professional documents (typesetting with Quarto)
  • tidyverse for working more effectively with data
  • Exercise: applications of tidyverse


princeton-ddss.github.io/r-bootcamp