From copy-paste to a reusable pipeline — WPM Summer School
👩🏼🎓 PhD in Political Science @ University of Mannheim
👩🏼💻 Transitioned into Data Science
💜 Founded RLadies+ Cologne · Former RLadies+ Global team member

bit.ly/git-and-functions
During my PhD I kept re-writing the same code every project: checking where the missing values were, building the same coverage tables, making the same plots.
One day I wrapped that into a function. Then another. Then another.
Those functions became {overviewR}, a package on CRAN.

Don’t
Repeat
Yourself
Fix it once → fixed everywhere
One place to read, one place to fix.
A function is like a machine: you put something in, it does its work, something comes out.
One function = one job.
This credo defines when to write a function, what to call it, and when to split it.
Think of a task that you repeat in every project.
Cleaning column names?
Plotting missing values for multiple data sets?
A simulation you re-run?
Something else?
name · arguments · body
make_stats <- function(x, digits = 2) {
stats <- c(n = length(x), sd = sd(x))
return(round(stats, digits))
name a verb that does something
arguments the inputs, the nouns
body the steps, inside { }
return() what comes back out
Every function you will ever write is these blocks: name, arguments, body.
make_sum()return(): explicit vs. implicit. R hands back the last value automatically, so sum(x) alone would work.
While you’re learning, write return() anyway. It makes your intent obvious.
make_stats() 🌸digits = 2 is a default argument, sensible out of the box but overridable when you need it, e.g. make_stats(x, digits = 0).
clean_colnames()
| Respondent ID | Birth Year | Q1 Response |
|---|---|---|
| 1 | 1990 | 1) Yes |
| 2 | 1985 | 2) No |
| 3 | 2001 | 1) Yes |
→ clean_colnames()
| respondent_id | birth_year | q1_response |
|---|---|---|
| 1 | 1990 | 1) Yes |
| 2 | 1985 | 2) No |
| 3 | 2001 | 1) Yes |
…and if you’re quick, those 1) prefixes go too. That’s the bonus 🧹
clean_colnames(df) that, for every column name:
Starter file: 2_exercises/01-clean-colnames/starter.R
10:00
You just rewrote a real package function:
traceback(): the trail of calls that led to the crash (what called what).print() / cat() inside the function: the quick “what’s the value right here?” check.debug(clean_colnames) # dig in
clean_colnames(survey) # pauses inside, step through it
undebug(clean_colnames) # climb back outInside the debugger, you’re standing in the function’s world:
| Key | Does |
|---|---|
n |
run the next line |
c |
continue to the end |
s |
step into |
f |
step out |
Q |
quit out |
Type names(df) while you’re paused: you can inspect anything the function can see.
Step inside the function you just wrote
Part 1: watch it work. Run debug() on your own clean_colnames(), call it on survey, and press n line by line. After each step, type names(df) and watch the names change.
10:00
Start minimal. Add one petal at a time.
Your clean_colnames() from Exercise 1 can grow exactly this way.

_ for spacesstopifnot(is.data.frame(df))
_ for spacesstopifnot(is.data.frame(df))
_ for spacesstopifnot(is.data.frame(df))
_ for spacesstopifnot(is.data.frame(df))Each petal was one small change
That’s how functions grow
Functions should fail loudly and early, not silently do the wrong thing.
| Tool | When |
|---|---|
stopifnot() |
a quick “this must be true or stop” |
stop("msg") |
a hard error with your own message |
warning("msg") |
keep going, but flag something suspicious |
clean_ucdp(dat, country)Small, focused functions are easier to read, test, and reuse.
clean → summarize → plot → compose
Follow along in your own IDE. Run each step as it appears.
You work with UCDP conflict event data. Every time a new country or period lands, you run the same three steps:
Today we turn that script into composable functions.
Data: subsample of UCDP GED v24.1, at 1_data/ucdp_ged_sample.csv (in the repo)
clean_ucdp()library(dplyr)
library(janitor)
clean_ucdp <- function(dat, country_filter = NULL) {
stopifnot(is.data.frame(dat))
out <- dat |>
clean_names() |> # the janitor trick from Exercise 1
filter(!is.na(best)) |> # drop events with no death estimate
rename(deaths = best)
return(out)
}country_filter = NULL → by default keep every country; pass one to narrow. Named country_filter, not country, so it never collides with the data’s own country column. One job: hand back tidy data.
summarize_conflict()summarize_conflict <- function(dat) {
dat |>
group_by(year) |>
summarise(
total_deaths = sum(deaths),
n_events = n(),
.groups = "drop"
)
}It takes Step 1’s tidy data in, and hands back one row per year.
plot_conflict()library(ggplot2)
plot_conflict <- function(dat, title = NULL) {
ggplot(dat, aes(x = year, y = total_deaths)) +
geom_col(fill = "#311a36") +
labs(title = title, x = NULL, y = "Deaths")
}It takes the table Step 2 produced and returns a chart.
Each function stands on its own: you can test or debug it without touching the rest of the pipeline.

run_conflict_report(ged, country = "Iraq")
Once the work is a function, running it for every country is one line.
library(purrr)
countries <- c("Iraq", "Colombia", "Nigeria", "Philippines")
plots <- map(countries, \(cty) run_conflict_report(ged, country = cty))plots is now a list of four charts: one function, run four times, zero copy-paste.

{patchwork} stitched these four into one figure.
Turn your repeated steps into composable functions
Think of a sequence you repeat: clean something → transform/summarize it → visualise or export it, something you’d actually use.
🎯 Your task
|>.While you build
stopifnot() at the top of each: fail early.🛟 Stuck? Start here
Grab a template from 2_exercises/03-your-pipeline/:
Or extend today’s pipeline: write export_report(), saving the plot as a .png and the summary as a .csv, named from country, then fold it into run_conflict_report().
25:00
5_resources/cheatsheet.mdThank you


debug(f) digs into f and steps through it · browser() stops anywhere so you can look around · undebug(f) climbs back out · {flow} draws what a function does. (That’s make_sum() from earlier!)
run_conflict_report() calls three functions. When it breaks, which one broke?
Anonymous function (R 4.1+)
A throwaway function with no name.
You’ll also see the older %>% pipe from {magrittr}; same idea. We use |> since it’s built in.
The headers are clean, but "1) Yes" is a label glued to a code. You want "Yes".
| q1_response |
|---|
| 1) Yes |
| 2) No |
| 1) Yes |
→ clean_values()
Same three parts, a different job, so it’s a different function. One function = one job.
gsub() arguments are the wrong way round (gsub("_", " ", ...)), so the spaces are never replaced at all: you get "respondent id", not "respondent_id".Full walkthrough: 3_solutions/debug_clean_colnames.R
Functions & Programming in R · WPM Summer School · © Cosima Meyer · CC BY-NC-SA 4.0