install.packages(c(
"targets", "tarchetypes", "tidyverse", "broom",
"palmerpenguins", "qs2", "quarto", "knitr", "crew"
))Welcome to the hands-on lab on targets. In this lab you will practice using targets for pipeline orchestration, and a bit of tarchetypes for higher-level target patterns.
Each section builds on the previous one. Complete the exercises in order. When you are stuck, expand the Hints or Solution blocks — but try on your own first!
1 Setup
Before starting, make sure the required packages are available:
Work in a new directory throughout this lab. All exercises should be done inside that project directory.
2 Your first pipeline
Create _targets.R at the root of your project and add supporting functions in R/functions.R with the following content:
# R/functions.R
library(palmerpenguins)
library(tidyverse)
library(broom)
fn_load <- function() {
penguins |> drop_na()
}
fn_model <- function(data) {
lm(body_mass_g ~ flipper_length_mm + bill_length_mm + species,
data = data)
}
fn_summarize <- function(model) {
tidy(model)
}Now create _targets.R:
# _targets.R
library(targets)
source("R/functions.R")
list(
tar_target(penguins, fn_load()),
tar_target(model, fn_model(penguins)),
tar_target(summary, fn_summarize(model))
)3 Explore the pipeline
tar_outdated()
tar_manifest()
tar_visnetwork()Questions:
- What does
tar_outdated()return before running the pipeline? - What does
tar_manifest()show about the targets and their dependencies? - What does
tar_visnetwork()show before running the pipeline? Note the colour and shape of the nodes.
4 Run the pipeline
tar_make()Questions:
- Which targets ran on the first
tar_make()? - What happens when you call
tar_make()a second time without changing anything? - Inspect the
_targets/directory. What is stored there?
The first call to tar_make() will run all three targets. Look at the console output — it shows which targets ran. Then run tar_make() again and it shows ‘skipped pipeline’.
Check the status of the pipeline after running:
tar_outdated()
tar_visnetwork()Questions:
- What does
tar_outdated()return after everything is up to date? - What colour are the nodes in
tar_visnetwork()after a successful run?
5 Load and inspect targets
tar_load(summary)
print(summary)
# Equivalent read-only access
coef(tar_read(model))Tasks:
- Load the
penguinstarget and count the rows. - What’s the difference between
tar_load()andtar_read()? Check what happens tols()after each.
tar_load(penguins)
nrow(penguins) # should be 333 after dropping NAs
# equivalent read-only access
nrow(tar_read(penguins))
# tar_load() adds 'penguins' to the global environment (visible in ls())
# tar_read() returns the value but does not assign it to the environmentClean up your environment by removing summary and penguins objects.
rm(summary, penguins)5.1 Trigger invalidation
Modify a function and observe which targets become outdated.
Edit R/functions.R — change fn_summarize() to also return glance() output:
fn_summarize <- function(model) {
list(
tidy = tidy(model),
glance = glance(model)
)
}Then:
tar_outdated()
tar_visnetwork()Questions:
- Before
tar_make(), which targets doestar_outdated()list? - Inspect the graph network.
Run the pipeline again:
tar_make()- Check the status again
- Does
targetsre-runpenguinsormodel? Why or why not? - What does this tell you about
targets’ dependency tracking?
targets hashes function bodies. Changing fn_summarize only invalidates summary (and anything downstream of it). penguins and model are unaffected because their commands and upstream inputs have not changed.
6 File targets
Track an external file so the pipeline re-runs when the file changes.
- Save the penguins data to a CSV file:
dir.create("data", showWarnings = FALSE)
write.csv(palmerpenguins::penguins, "data/penguins.csv", row.names = FALSE)- Update
_targets.Rto track the file:
library(targets)
source("R/functions.R")
list(
tar_target(
file_input,
"data/penguins.csv",
format = "file"
),
tar_target(penguins, read.csv(file_input) |> drop_na()),
tar_target(model, fn_model(penguins)),
tar_target(summary, fn_summarize(model))
)- Run
tar_make(), then open the CSV, change one value, save it, and runtar_make()again.
Questions:
- Which targets re-ran after you modified the CSV?
- What is stored in the
file_inputtarget object?
tar_read(file_input)
# "data/penguins.csv" — the path string
# But targets stores the file hash internally, so any file change triggers re-run
# After modifying the CSV:
tar_outdated()
# file_input, penguins, model, summary — all four are outdated
# because all depend on the file7 Configuration
Add global pipeline options at the top of _targets.R:
The format = "qs" option requires the qs2 package. Install that first.
library(targets)
source("R/functions.R")
tar_option_set(
packages = c("tidyverse", "broom", "palmerpenguins"),
format = "qs",
seed = 42
)
list(
tar_target(file_input, "data/penguins.csv", format = "file"),
tar_target(penguins, read.csv(file_input) |> drop_na()),
tar_target(model, fn_model(penguins)),
tar_target(summary, fn_summarize(model))
)Then remove the library() calls from R/functions.R.
Questions:
- What does
packagesintar_option_set()do? How does it differ from puttinglibrary()at the top of_targets.R? - What is the purpose of
seed = 42?
After adding tar_option_set(), run tar_outdated(). If format changed from the default "rds" to "qs", existing targets may be invalidated because the storage format changed. This is expected — run tar_make() to rebuild with the new format.
The seed ensures that any random number generation in the pipeline is reproducible.
8 Dynamic branching
Dynamic branching runs the same target once for each value produced by an upstream target. Here, create one summary branch for each penguin species.
Add this function to R/functions.R:
fn_species_summary <- function(data) {
summarise(
data,
species = first(species),
penguins = n(),
mean_body_mass_g = mean(body_mass_g),
mean_flipper_length_mm = mean(flipper_length_mm)
)
}Then update _targets.R:
library(targets)
source("R/functions.R")
tar_option_set(
packages = c("tidyverse", "broom", "palmerpenguins")
)
list(
tar_target(file_input, "data/penguins.csv", format = "file"),
tar_target(penguins, read.csv(file_input) |> drop_na()),
tar_target(
species_names,
as.list(sort(unique(penguins$species))),
iteration = "list"
),
tar_target(
species_summary,
fn_species_summary(filter(penguins, species == species_names)),
pattern = map(species_names),
iteration = "list"
),
tar_target(all_species_summaries, bind_rows(species_summary))
)Run tar_make(), then inspect the result:
tar_visnetwork()
tar_read(all_species_summaries)Questions:
- How many
species_summarybranches were created? - What target determines the number of branches?
- How would adding a fourth species to the input data affect the pipeline?
There are three species_summary branches: Adelie, Chinstrap, and Gentoo. species_names determines the number of branches because pattern = map(species_names) runs the downstream target once for each element. If the input data contained a fourth species, the next tar_make() would create and run a fourth branch automatically.
tar_meta(
starts_with("species_summary"),
fields = c(name, parent)
)9 Branching with groups
tar_group_by() from tarchetypes is a convenient alternative when the branches come from groups in a data frame. Reuse the fn_species_summary() function from the dynamic branching exercise.
Then update _targets.R:
library(targets)
library(tarchetypes)
source("R/functions.R")
tar_option_set(
packages = c("tidyverse", "broom", "palmerpenguins"),
format = "qs",
seed = 42
)
list(
tar_target(file_input, "data/penguins.csv", format = "file"),
tar_target(penguins, read.csv(file_input) |> drop_na()),
tar_group_by(penguins_by_species, penguins, species),
tar_target(
species_summary,
fn_species_summary(penguins_by_species),
pattern = map(penguins_by_species),
iteration = "list"
),
tar_target(all_species_summaries, bind_rows(species_summary))
)Add library(tarchetypes) to the top of _targets.R if you haven’t already.
Run tar_make(), then inspect the result:
tar_visnetwork()
# Read the results
tar_read(all_species_summaries)Questions:
- How many
species_summarybranches were created? - What does
tar_read(species_summary)show? How does it differ fromtar_read(all_species_summaries)?
There are three branches: Adelie, Chinstrap, and Gentoo. tar_group_by() marks the data frame’s row groups, and pattern = map(penguins_by_species) creates one branch for each group. Referencing species_summary as a whole aggregates all branches into a list, which bind_rows() combines.
# List the individual dynamic branches and their common parent target.
tar_meta(
starts_with("species_summary"),
fields = c(name, parent)
)9.1 Parallelized branching
The three species branches are independent, so they can run in parallel. Add a local controller to tar_option_set():
tar_option_set(
packages = c("tidyverse", "broom", "palmerpenguins"),
format = "qs",
seed = 42,
controller = crew_controller_local(workers = 2)
)Add library(crew) to the top of _targets.R if you haven’t already.
Run tar_make() again. This small example is too quick to show a meaningful speedup, but the same configuration allows expensive independent branches to run on separate workers.
10 Debugging
Introduce a temporary typo in fn_species_summary():
mean_body_mass_g = mean(body_mass_gg)Run the pipeline and inspect the failure:
tar_make()
tar_meta(fields = c(name, error))
tar_visnetwork()
# Run target commands in the main R session while debugging.
tar_make(callr_function = NULL)Questions:
- Which
species_summarybranch failed, and what error doestar_meta()report? - How does the failed branch appear in
tar_visnetwork()? - What happens when you run
tar_make(callr_function = NULL)? How does this help with debugging?
tar_meta(fields = c(name, error)) lists recorded target errors. A failed branch is red in tar_visnetwork().
11 Static branching
Compare two modelling approaches using tar_map().
Update R/functions.R:
fn_model <- function(data, formula_str) {
lm(as.formula(formula_str), data = data)
}
fn_metrics <- function(model) {
glance(model)
}Update _targets.R:
library(targets)
library(tarchetypes)
source("R/functions.R")
tar_option_set(packages = c("tidyverse", "broom", "palmerpenguins"))
formulas <- list(
simple = "body_mass_g ~ flipper_length_mm",
full = "body_mass_g ~ flipper_length_mm + bill_length_mm + species"
)
model_targets <- tar_map(
values = list(
formula_str = formulas,
name_suffix = names(formulas)
),
names = name_suffix,
tar_target(model, fn_model(penguins, formula_str)),
tar_target(
metrics,
fn_metrics(model) |>
mutate(model = name_suffix, .before = 1)
)
)
list(
tar_target(file_input, "data/penguins.csv", format = "file"),
tar_target(penguins, read.csv(file_input) |> drop_na()),
model_targets
)Run:
tar_visnetwork()
tar_make()Questions:
- How many targets does
tar_map()generate? - What are the names of the generated targets?
- Load
metrics_simpleandmetrics_full. Which model has a higher R²?
# tar_map() generates: model_simple, model_full, metrics_simple, metrics_full
tar_manifest()
tar_load(c(metrics_simple, metrics_full))
metrics_simple$r.squared
metrics_full$r.squared
# The full model will have a higher R² because it has more predictors12 Aggregating results
Combine the metrics from both models into a single data frame.
Add to the end of the list() in _targets.R:
tar_combine(
all_metrics,
model_targets[["metrics"]],
command = bind_rows(!!!.x)
)Check the network using tar_visnetwork(). Run tar_make(), then:
tar_load(all_metrics)
print(all_metrics)Questions:
- What does the
!!!.xsyntax do intar_combine()? - What columns does
all_metricshave? - Which model performs better according to AIC?
The !!!.x is the rlang “splicing” operator. Inside tar_combine(), .x is replaced with the list of target names, and !!! splices them as individual arguments to bind_rows().
13 Quarto reporting
Create a Quarto report that reads from the targets pipeline.
- Create
report.qmd:
---
title: "Penguin Body Mass Analysis"
format: html
---
```{r}
#| echo: false
library(targets)
library(tidyverse)
library(broom)
library(knitr)
```
## Model Comparison
```{r}
metrics <- tar_read(all_metrics)
kable(
metrics[, c("model", "r.squared", "AIC", "BIC")],
digits = 3,
caption = "Model fit statistics"
)
```
## Best Model Coefficients
```{r}
best_model_name <- metrics$model[which.min(metrics$AIC)]
best_model <- switch(
best_model_name,
simple = tar_read(model_simple),
full = tar_read(model_full)
)
tidy(best_model) |>
kable(digits = 3)
```- Add the report target to
_targets.R:
tar_quarto(report, "report.qmd")- Run
tar_make()and open the rendered HTML.
Questions:
- What does
tar_quarto()do differently from just callingquarto::quarto_render()? - What targets does
reportdepend on? Checktar_visnetwork(). - What happens if you change the
fn_model()function and re-runtar_make()? Does the report re-render automatically? - Inspect the quarto report. Does it show the correct model comparison and coefficients?
tar_quarto()tracks the source document, output file, and target dependencies referenced withtar_read()/tar_load().quarto::quarto_render()has no such pipeline integration.- In tar_visnetwork(), ‘report’ will have edges from all_metrics, model_simple, and model_full.
- Yes — changing the
fn_model()function invalidates the report, sotar_make()re-renders it.
14 Session
Click here
sessionInfo()R version 4.5.3 (2026-03-11)
Platform: x86_64-conda-linux-gnu
Running under: Ubuntu 26.04 LTS
Matrix products: default
BLAS/LAPACK: /home/roy/miniforge3/envs/r-4.5/lib/libopenblasp-r0.3.33.so; LAPACK version 3.12.0
locale:
[1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
[3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
[5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
[7] LC_PAPER=en_US.UTF-8 LC_NAME=C
[9] LC_ADDRESS=C LC_TELEPHONE=C
[11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
time zone: Europe/Stockholm
tzcode source: system (glibc)
attached base packages:
[1] stats graphics grDevices utils datasets methods base
loaded via a namespace (and not attached):
[1] htmlwidgets_1.6.4 compiler_4.5.3 fastmap_1.2.0 cli_3.6.6
[5] tools_4.5.3 htmltools_0.5.9 otel_0.2.0 yaml_2.3.12
[9] rmarkdown_2.31 knitr_1.51 jsonlite_2.0.0 xfun_0.59
[13] digest_0.6.39 rlang_1.3.0 evaluate_1.0.5