Skip to main content
R beginner Lesson 1 of 10

Getting Started with R

Run R from a script rather than a console, load a dataset into a tibble, and learn the two vector behaviours that surprise everyone coming from Python.

R is a language built around the data frame. A table is the primary type, not something a library adds — which is why the same code cleans a dataset, plots it, fits a model to it, and, through dbplyr, runs as SQL against a warehouse.

This track covers both halves: the analysis work R is known for (lessons 6-8 — visualisation, exploratory statistics, modelling) and the pipeline work it is quietly good at (lessons 3, 9 and 10 — Parquet, databases, reproducible runs).

Setting up

R --version
Rscript -e 'install.packages(c("tidyverse", "arrow", "DBI", "duckdb"), repos="https://cloud.r-project.org")'
R version 4.5.1 (2026-06-14) -- "Pile of Leaves"
Copyright (C) 2026 The R Foundation for Statistical Computing
Platform: x86_64-pc-linux-gnu

* installing *source* package 'tidyverse' ...
* DONE (tidyverse)
* DONE (arrow)
* DONE (DBI)
* DONE (duckdb)

This track runs everything with Rscript, not from an interactive console — pipeline code has to run unattended, and a script is the unit that gets scheduled.

The first script

# intro.R
library(tidyverse)

orders <- tibble(
  order_id    = c(1001L, 1002L, 1003L, 1004L, 1005L),
  customer_id = c(1L, 2L, 1L, 3L, 2L),
  ordered_at  = as.Date(c("2026-01-04", "2026-01-05", "2026-01-07",
                          "2026-01-09", "2026-01-11")),
  status      = c("completed", "completed", "returned", "completed", "pending"),
  amount      = c(25.50, 12.00, 40.00, 8.75, 63.20)
)

print(orders)
Rscript intro.R
── Attaching core tidyverse packages ─────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.4     ✔ readr     2.1.5
✔ forcats   1.0.0     ✔ stringr   1.5.1
✔ ggplot2   3.5.1     ✔ tibble    3.2.1
✔ lubridate 1.9.3     ✔ tidyr     1.3.1
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()

# A tibble: 5 × 5
  order_id customer_id ordered_at status    amount
     <int>       <int> <date>     <chr>      <dbl>
1     1001           1 2026-01-04 completed  25.5 
2     1002           2 2026-01-05 completed  12   
3     1003           1 2026-01-07 returned   40   
4     1004           3 2026-01-09 completed   8.75
5     1005           2 2026-01-11 pending    63.2 

The tibble print is doing a lot of work: dimensions on the first line, and a type under every column name — <int>, <date>, <chr>, <dbl>. Reading types at a glance is how you catch the classic bug of a numeric column that arrived as text.

Silence the startup banner in scripts:

suppressPackageStartupMessages(library(tidyverse))

Everything is a vector

x <- c(1, 2, 3, 4, 5)

print(x * 2)
print(x + c(10, 20, 30, 40, 50))
print(sqrt(x))
print(sum(x))
print(x[x > 3])
[1]  2  4  6  8 10
[1] 11 22 33 44 55
[1] 1.000000 1.414214 1.732051 2.000000 2.236068
[1] 15
[1] 4 5

No loop anywhere. Operations apply element-wise, which is why R code that looks like it handles one value usually handles a whole column. [1] at the start of each line is the index of the first element on that line, not part of the data.

Indexing starts at 1, and negative indices mean exclusion rather than counting backwards:

print(x[1])
print(x[-1])
print(x[c(1, 3)])
print(x[length(x)])
[1] 1
[1] 2 3 4 5
[1] 1 3
[1] 5

x[-1] dropping the first element rather than returning the last is the single most common error for people arriving from Python.

Recycling, which will bite you

print(c(1, 2, 3, 4) + c(10, 20))
print(c(1, 2, 3) + c(10, 20))
[1] 11 22 13 24
Warning message:
In c(1, 2, 3) + c(10, 20) :
  longer object length is not a multiple of shorter object length
[1] 11 22 13

The shorter vector is repeated to match the longer one. When the lengths divide evenly it happens silently — the first line produced a plausible wrong answer with no warning at all. This is worth knowing before it produces a quietly incorrect column.

Missing values propagate

amounts <- c(25.50, NA, 12.00, 8.75)

print(sum(amounts))
print(sum(amounts, na.rm = TRUE))
print(mean(amounts, na.rm = TRUE))
print(is.na(amounts))
print(amounts == NA)
[1] NA
[1] 46.25
[1] 15.41667
[1] FALSE  TRUE FALSE FALSE
[1] NA NA NA NA

Two rules. Any arithmetic touching NA returns NA, so a single missing value poisons a total unless you pass na.rm = TRUE — that explicitness is deliberate, and better than silently ignoring the gap. And == NA is always NA, never TRUE: use is.na().

Reading a real file

# orders.csv
order_id,customer_id,ordered_at,status,amount
1001,1,2026-01-04,completed,25.50
1002,2,2026-01-05,completed,12.00
1003,1,2026-01-07,returned,40.00
1004,3,2026-01-09,completed,8.75
1005,2,2026-01-11,pending,63.20
1006,9,2026-01-12,completed,19.99
orders <- read_csv("orders.csv", show_col_types = FALSE)
print(orders)
glimpse(orders)
# A tibble: 6 × 5
  order_id customer_id ordered_at status    amount
     <dbl>       <dbl> <date>     <chr>      <dbl>
1     1001           1 2026-01-04 completed  25.5 
2     1002           2 2026-01-05 completed  12   
3     1003           1 2026-01-07 returned   40   
4     1004           3 2026-01-09 completed   8.75
5     1005           2 2026-01-11 pending    63.2 
6     1006           9 2026-01-12 completed  19.99

Rows: 6
Columns: 5
$ order_id    <dbl> 1001, 1002, 1003, 1004, 1005, 1006
$ customer_id <dbl> 1, 2, 1, 3, 2, 9
$ ordered_at  <date> 2026-01-04, 2026-01-05, 2026-01-07, 2026-01-09, 2026-01-11, 2026-…
$ status      <chr> "completed", "completed", "returned", "completed", "pending", "com…
$ amount      <dbl> 19.99, 25.50, 12.00, 40.00, 8.75, 63.20

readr inferred the date column without being told. glimpse() is the function to reach for on a wide table — it prints one row per column, so forty columns stay readable.

Guessing types is convenient and not what you want in a pipeline. Declare them:

orders <- read_csv(
  "orders.csv",
  col_types = cols(
    order_id    = col_integer(),
    customer_id = col_integer(),
    ordered_at  = col_date(format = "%Y-%m-%d"),
    status      = col_factor(levels = c("completed", "returned", "refunded", "pending")),
    amount      = col_double()
  )
)
print(orders)
# A tibble: 6 × 5
  order_id customer_id ordered_at status    amount
     <int>       <int> <date>     <fct>      <dbl>
1     1001           1 2026-01-04 completed  25.5 
2     1002           2 2026-01-05 completed  12   
...

Now a rogue value fails loudly instead of turning the column into text:

Warning: One or more parsing issues, call `problems()` on your data frame for details
# A tibble: 1 × 5
    row   col expected  actual file       
  <int> <int> <chr>     <chr>  <chr>      
1     5     5 a double  n/a    orders.csv

problems() gives you the row, the column, what was expected and what arrived — the same information a good ingestion tool provides, and the reason to declare types.

The pipe

orders |>
  filter(status == "completed") |>
  mutate(amount_with_vat = round(amount * 1.20, 2)) |>
  arrange(desc(amount)) |>
  print()
# A tibble: 4 × 6
  order_id customer_id ordered_at status    amount amount_with_vat
     <int>       <int> <date>     <fct>      <dbl>           <dbl>
1     1001           1 2026-01-04 completed  25.5            30.6 
2     1006           9 2026-01-12 completed  19.99           24.0 
3     1002           2 2026-01-05 completed  12              14.4 
4     1004           3 2026-01-09 completed   8.75           10.5 

|> is base R’s native pipe: x |> f(y) is f(x, y). You will also meet %>% from magrittr in older code — near-identical for everyday use. Prefer |> in new code; it needs no package.

Practice

1. Build a tibble and read its column types.
customers <- tibble(
  customer_id = 1:4,
  full_name = c("Ada Lovelace", "Grace Hopper", "Alan Turing", "Katherine Johnson"),
  country = c("GB", "US", "GB", "US")
)
print(customers)
# A tibble: 4 × 3
  customer_id full_name         country
        <int> <chr>             <chr>  
1           1 Ada Lovelace      GB     
2           2 Grace Hopper      US     
3           3 Alan Turing       GB     
4           4 Katherine Johnson US     

1:4 produced <int> while c(1, 2, 3, 4) gives <dbl> — R’s numeric literals are doubles unless you write 1L or use a range.

2. Add two vectors of different lengths.
print(c(1, 2, 3, 4) + c(10, 20))
[1] 11 22 13 24

No warning, because 4 is a multiple of 2. Recycling is a feature for scalars — x * 2 relies on it — and a silent bug generator for anything longer.

3. Sum a column containing NA.
print(sum(c(25.50, NA, 12.00)))
print(sum(c(25.50, NA, 12.00), na.rm = TRUE))
[1] NA
[1] 37.5

The NA result is the language refusing to guess. Reach for na.rm = TRUE deliberately, and count the missing values rather than dropping them silently.

4. Declare column types and feed the reader a bad value.
Warning: One or more parsing issues, call `problems()` on your data frame for details
    row   col expected  actual
1     5     5 a double  n/a   

With type guessing the whole column becomes <chr> and every later arithmetic operation fails somewhere less obvious. Declaring types moves the failure to the boundary.

Next: dplyr — the five verbs that cover most transformation work.

Frequently Asked Questions

Is R for data science or data engineering?
Both, and the same code serves each. The tidyverse covers cleaning, reshaping, visualisation and modelling, while `dbplyr` translates that same dplyr code into SQL against a warehouse and `arrow` reads datasets larger than memory. This track covers both halves.
What is the difference between a data frame and a tibble?
A tibble is a data frame with better defaults: it prints only the first ten rows with column types, never converts strings to factors, never partially matches column names, and always returns a tibble from `[`. Every tidyverse function returns one.
Why does R index from 1 instead of 0?
It follows the mathematical and statistical convention its predecessor S adopted, where the first element is element one. It is consistent throughout the language — `x[1]` is the first element and `x[-1]` means everything except the first, not the last.
Should I use the tidyverse or base R?
Use the tidyverse for pipeline and analysis code: it is far more readable and consistent, which matters most when someone else maintains it. Base R and `data.table` are worth knowing for performance-critical paths and for reading older code.