Good code practice
Now you write your own code - how to write it so you can trust it yourself in six months
You have built your cohort (Phase 10), assembled your extracts (Phase 12), and are now about to write the code that actually produces your results: descriptive tables, models, sensitivity analyses.
The most important thing in register-based research is that your results can be reproduced - by a reviewer, a colleague or yourself in six months. That places demands on how you organise and write your code. The habits you adopt now will save you hours later.
In short: One of the most important things is keeping an overview - e.g. via one script per step, run each script top to bottom (never across), give objects meaningful names, and comment the why rather than the what. The rest is polish.
1. Structure your scripts logically
One script per step. Put each step of the analysis in its own .R file, named with a number that tells you the order to run them in:
01_build_cohort.R # build the cohort (pnr + index date)
02_extract_outcomes.R # extract outcomes
03_extract_covariates.R # extract covariates
04_data_management.R # assemble, clean, derive variables
05_descriptive.R # descriptive analyses (Table 1)
06_analysis.R # main models
07_sensitivity.R # sensitivity analyses
The script numbers tell anyone who sees the folder the order to run them in. Use subfolders as the project grows - e.g. R/, output/, datasets/.
A predictable order within each script. Whatever the script does, the same frame recurs: a header, load packages, import data - then the actual work - and finally save the result. Only the middle part changes from script to script:
# Project: Dementia and surgery (DARTER 708421)
# Author: Your Name
# Date: 2026-06-05
# Purpose: Main analysis - Cox model for dementia
# 1. Load packages -------------------------------------------------------------
library(tidyverse)
library(survival)
library(tableone) # CreateTableOne()
# 2. Point fastreg at the registers --------------------------------------------
# Once per script, right after the library() calls - not before each read.
# Only needed in scripts that read registers. See Parquet and fastreg.
library(fastreg)
options(
fastreg.project_workdata_dir = "E:/workdata/[projectnumber]/cleaned-data/",
fastreg.project_rawdata_dir = "E:/rawdata/[projectnumber]/"
)
# 3. Import data ---------------------------------------------------------------
analysis_data <- readRDS("path/to/analysis_data.rds")
# 4. The actual work -----------------------------------------------------------
# ... varies from script to script - here e.g.: Table 1, Cox model, sensitivity analyses ...
# 5. Save output ---------------------------------------------------------------
saveRDS(cox_model, "output/cox_model.rds")The same steps - header, packages, paths, data, save - recur in every script; only the actual work changes. The options() call belongs here, once, next to the library() calls: it is what lets read_register("bef") find a register by name, and it is the step people most often discover by getting an error. See Point fastreg at your registers. The header at the top tells you in five seconds what the script does, who wrote it and what it needs. More on good section headings and comments in section 5.
Avoid jumping back and forth between cleaning, modelling and plotting. A script should read and run from top to bottom. If your code jumps around, it becomes impossible to follow - even for yourself.
Larger projects? For a pipeline of several scripts, the targets package orchestrates the whole workflow and reruns only the steps whose inputs changed - reproducible, and fast to re-run.
2. Run scripts top to bottom - never across
A script should run from line 1 to the end without interruption and give the same result every time. Avoid running two lines from one file, jumping to another and back.
Never run code manually across scripts. If your result depends on you having run line 14 in script 02 before line 7 in script 03, it is not reproducible. Instead, do a saveRDS() at the end of script 02 and a readRDS() at the start of script 03.
# End of 03_extract_covariates.R - save the result
saveRDS(covariates, "path/to/covariates.rds")# Start of 04_data_management.R - load it again
covariates <- readRDS("path/to/covariates.rds")When you think you are done: restart R (Session → Restart R) and run the whole script from line 1 again. Does it run clean? Then it is reproducible.
3. Use meaningful object names
The name should describe what the object contains.
# Bad - what are a and b?
a <- read_csv("data.csv")
b <- lm(bmi ~ age, data = a)# Good - the name speaks for itself
participant_data <- read_csv("data.csv")
bmi_model <- lm(bmi ~ age, data = participant_data)In six months you will not remember what a and b were. participant_data and bmi_model explain themselves.
4. Use snake_case consistently
Consistent naming makes code far easier to read. Pick snake_case (lowercase with underscores) and stick to it:
# Good - snake_case
body_mass_index
participant_age
sweetener_intake# Avoid mixing styles
BodyMassIndex # PascalCase
bodyMassIndex # camelCase
BMI_Data # mixedWhat matters is not which style, but that you are consistent.
5. Headings and comments make the code readable
Headings and comments are what make a script navigable - for a reviewer, a colleague or yourself in six months. Three things to get into the habit of:
- A short description at the top of each script: what it does, what it needs as input, and what it produces (the header from section 1).
- Section headings that run out to the right margin: write the heading, then fill the rest of the line with dashes, as in the example in section 1. That one line does two jobs: it breaks the file up visually, and it makes the heading a section your editor can navigate to (see below).
- A comment on each substantial line of code: but explain why, not what.
Comment the “why”, not the “what”. The code already shows what happens. A good comment explains why - the decision behind it.
# Bad - the comment just repeats the code
# Calculate BMI
data$bmi <- data$weight / data$height^2# Better - the comment explains the decision
# BMI used as an adjustment variable in the primary models
data$bmi <- data$weight / data$height^2Explain choices, assumptions and sources - not the obvious. It takes five minutes to write a good comment now and an hour to understand the code again in three months.
Find your way around a long script
Your editor keeps an outline of the file you are editing: an automatically generated table of contents. You do not write it. The editor builds it from your code, listing every function you define plus every comment line you have marked as a section heading. A 600-line extraction script stops needing to be scrolled.
Where to find it. In RStudio the outline is a panel on the right-hand side of the editor. The same list also sits in the dropdown at the bottom left of the editor pane, the one that normally reads (Top Level): it is clickable, and a lot of people have looked past it for years.
Ctrl+Shift+O (Shift+Cmd+O on macOS).| What | Windows / Linux | macOS |
|---|---|---|
| Toggle the outline panel | Ctrl+Shift+O |
Shift+Cmd+O |
| Open the jump-to dropdown | Shift+Alt+J |
Cmd+Shift+Option+J |
| Insert a new section | Ctrl+Shift+R |
Shift+Cmd+R |
What turns a comment into a heading. A comment line that ends with four or more -, = or # characters. All three work and they are interchangeable; dashes are simply the most common. Whatever comes before that trailing run becomes the label in the panel:
# Load data ---- # appears as "Load data"
# 3. Derive comorbidity ==== # appears as "3. Derive comorbidity"
#### Load data #### # also works: any number of # to start
# Load data --- # nothing: only three dashesThat trailing run is the entire mechanism. A comment without it never appears in the outline.
A space before the run is not required, it is just easier to read. That has a consequence worth knowing, and it is the subject of the warning below.
What you get out of it, in order of usefulness:
- Jumping: click an entry and the cursor goes there. This is the main point.
- Folding: a triangle appears in the left margin of each section, so you can collapse everything except the part you are working on and see the shape of the whole script on one screen.
- Orientation: the panel is a map. A colleague opening your script for the first time sees what is in it before reading a single line, which is exactly how register code is usually read.
Getting the most out of it:
- Name a section after what the step does, not what it touches:
# Restrict to study period ----beats# Dates ----. - Number them (1., 2., 2.1) so the outline reads as a sequence, and so a colleague can write “what happens in section 4?” in an email.
- Run the dashes out to the right margin rather than stopping at four. It costs nothing, the heading then doubles as the visual break in the file, and
Ctrl+Shift+Rwrites it for you. - Keep a contents list in the script header mirroring the section numbers. The outline only works inside an editor; a contents list also works in a diff, in a code review and on paper.
Check that it works. Press Ctrl+Shift+O in a file you have just marked up. If a heading is missing from the panel, its marker is missing or too short (three dashes instead of four).
If the panel is instead full of entries called (Untitled), the file has rule lines in it - #----- on a line of its own, usually boxing a heading in. Since no space is required before the marker, each rule line counts as a heading with no text in front of the dashes. Delete them and put the dashes on the heading line instead. You will meet this in scripts you inherit from colleagues.
The outline is also a check on the script itself. For a simple script it should read as the four steps from section 1: header, packages, data, save. If it does not, the script does not follow the structure.
6. Avoid hard-coded “magic numbers”
A “magic number” is a value in the middle of your code whose meaning is unclear. Give it a name instead:
# Bad - why 18? what if the cutoff changes?
data <- data %>%
filter(age >= 18)# Better - the cutoff has a name and is defined in one place
adult_age_cutoff <- 18
data <- data %>%
filter(age >= adult_age_cutoff)This is especially important when a cutoff is used in several places or may change: then you only fix it once.
7. Keep lines reasonably short
Long lines are hard to read and to see changes in. Break long calls up so each argument stands out clearly. Both versions below run identically - the difference is entirely in what happens when a human, or a diff, has to read them:
# Bad - one long line. You have to scroll sideways to see what is in the model,
# and a diff shows the whole line as changed when you add one covariate.
model <- glm(outcome ~ age + sex + bmi + smoking + education + income + physical_activity + energy_intake + alcohol, data = data, family = binomial())# Better - one argument per line, one covariate per line
model <- glm(
outcome ~ age +
sex +
bmi +
smoking +
education +
income +
physical_activity +
energy_intake +
alcohol,
data = data,
family = binomial()
)8. Write functions for repeated tasks
If you copy the same code more than a few times - e.g. a Table 1 for each exposure group - write a function. Functions reduce errors: fix something once, and it is fixed everywhere.
# Bad - the same call repeated, easy to make a mistake in one of them
table1_a <- CreateTableOne(
vars = baseline_vars,
strata = "operated",
data = data_a
)
table1_b <- CreateTableOne(
vars = baseline_vars,
strata = "operated",
data = data_b
)
# ... repeated 10 times ...# Better - write the function once
create_table1 <- function(data, exposure) {
CreateTableOne(
vars = baseline_vars,
strata = exposure,
data = data
)
}
table1_a <- create_table1(data_a, "operated")
table1_b <- create_table1(data_b, "operated")How to write your own function
A function has three parts: a name, some arguments (the input in the parentheses), and a body (the code between { }). Whatever the last line produces is what the function returns.
name <- function(argument1, argument2) {
# body: do something with the arguments
result <- argument1 + argument2
result # last line = what is returned
}A concrete example - a function that computes age at a given date:
# Function: age in whole years at a given date
compute_age <- function(birth_date, index_date) {
as.numeric(difftime(index_date, birth_date, units = "days")) %/% 365.25
}
# Use it
compute_age(as.Date("1950-03-01"), as.Date("2020-01-01")) # 69You can read more about functions - arguments, default values and when they pay off - in Functions: overview.
9. Fail early - check your data before the analysis
It is cheaper to catch an error straight away than to discover it in a finished result. Insert explicit checks of your assumptions:
# Stop immediately if an assumption is broken
stopifnot(
all(data$age >= 0),
all(data$age <= 120)
)# Alternative with clearer error messages (the assertthat package)
assertthat::assert_that(
nrow(data) > 0,
msg = "data is empty - check your extract"
)If the check fails, the script stops immediately - instead of carrying a hidden error forward into your models.
10. One object = one purpose
Avoid overwriting the same object again and again. It makes debugging hard, because data means something different depending on how far you have got:
# Bad - the same name overwritten all the way down
data <- read_csv("data.csv")
data <- filter(data, age >= 18)
data <- mutate(data, bmi = weight / height^2)
data <- left_join(data, covariates, by = "pnr")# Better - each step has its own name
raw_data <- read_csv("data.csv")
clean_data <- raw_data %>%
filter(age >= 18)
analysis_data <- clean_data %>%
mutate(bmi = weight / height^2) %>%
left_join(covariates, by = "pnr")Now you can inspect each intermediate step (raw_data, clean_data, analysis_data) separately - invaluable when something looks wrong.
11. Mind your RAM in the shared environment
On Forskermaskinen (the shared environment) all users share the same RAM. R loads data directly into RAM, so a single large extraction can slow the server for everyone. That is why DST automatically kills processes (RStudio sessions, jobs, etc.) when memory is close to full - and if your session is killed, you lose all unsaved work.
There is a 250 GB limit per user session (on the STATA and R/Python servers). If you exceed it, you are logged off automatically and sent an email about the event. And if a server has less than 10 % free memory overall, the session with the largest usage is logged off - even if it is below 250 GB. Questions: servicedesk@dst.dk / +45 39 17 38 00.
Save often, and write to disk along the way. Save your code continuously, and write intermediate results to disk with saveRDS() (cf. sections 2 and 10), so you do not lose hours of work if your process is killed.
Concrete habits that keep RAM use down:
Load only what you need. Select columns and rows before data lands in RAM - see Read only what you need (
open_dataset()+filter/select,read_sas(col_select=, n_max=)).Clean up as you go. Delete large objects once you no longer need them:
rm(raw_data) # remove a large object from RAM gc() # ask R to release the memoryClose unused sessions, and start a fresh session when you begin a new task (
Session → Restart R) - this also clears out old objects.Keep an eye on usage. Two views, and you want both. The Task Manager shortcut on the server’s desktop → the Users tab → your project ident → Memory tells you what the machine is doing. To see what your R session is holding, see below.
Seeing your memory use from inside R
The Task Manager tells you the machine is running out of memory. It does not tell you which object is the problem. R can, and you do not need any extra packages for it.
How much is this session using right now?
gc() # "garbage collection" - it also reports memory useRead the (Mb) columns. used is what your session holds at this moment, max used the high-water mark since the session started. The Vcells row is the one that matters for data: it counts the memory holding your actual values. Calling gc() also asks R to hand back memory it no longer needs, so it is useful after an rm().
Which object is eating it?
# Every object in your session, biggest first
sizes <- sapply(ls(), function(x) object.size(get(x)))
sort(sizes, decreasing = TRUE)
# The same, in units you can read
for (n in names(sort(sizes, decreasing = TRUE))) {
cat(sprintf("%-25s %s\n", n, format(object.size(get(n)), units = "auto")))
}That prints something like lpr_raw 4.2 Gb next to cohort 1.3 Mb, and the culprit is usually obvious immediately. Then rm(lpr_raw); gc().
A single object, before you decide to keep it:
format(object.size(my_data), units = "auto") # e.g. "812.4 Mb"RStudio shows it too. The Environment pane lists every object with its size, and the dropdown at the top of that pane has a Memory Usage… report with the session total. It is the same numbers as above, without typing anything - handy while you work, though the code is what you want when you need to compare two approaches.
object.size() measures the object, not the query. A lazy parquet connection from open_dataset() or read_register() is a few KB no matter how large the register behind it is - that is the whole point of lazy loading (see Phase 4). The size only becomes real after collect(). So a small object.size() on a lazy object is good news, not a measurement error.
If the shared environment cannot handle your analyses, you can look into a hosted server or high performance computing. What is actually installed on the shared machine is listed in DST’s hardware and software overview (PDF, Danish).
DST’s official advice, with code examples in R, Python and STATA, is collected in DST guide: Reducing RAM use in the shared environment (PDF, Danish).
See also
- Phase 4 - File formats: which file types are lazy
- Phase 12 - Assemble and prepare the dataset: joins and pivots
- Functions: overview: functions in depth
- Phase 5 - Extracting data step by step: the fundamental pattern
- Inspiration for formatting code: Stack Overflow’s formatting guide
Further depth in The Epidemiologist R Handbook:
