flowchart TD
A[("Source registers - whole population<br>LPR2: lpr_sksopr + lpr_adm<br>LPR3: procedurer_kirurgi + lpr_a_kontakt")]:::store
B["Step 2: filter on exposure codes<br>exposed with pnr + index_date<br>n = 12,300"]:::step
E1["Excluded in Step 3:<br>prevalent outcome before index<br>(from LPR diagnoses)<br>n = 1,150"]:::excl
C["Exposed cohort<br>n = 11,150"]:::step
F["Optional (Phase 10a):<br>+ matched comparison cohort"]:::optional
D["Final study population<br>pnr + index_date<br>(→ Phase 11)"]:::result
A --> B
B --> C
B -.->|attrition| E1
C -.-> F
C --> D
F -.-> D
classDef store fill:#eef0f2,stroke:#8a94a6,color:#1f2733;
classDef step fill:#eaf2fb,stroke:#4a78b5,color:#173a5e;
classDef excl fill:#fdecea,stroke:#d9534f,color:#7a1f1a;
classDef optional fill:#fff3e0,stroke:#e69500,color:#7a4f00;
classDef result fill:#e9f7ef,stroke:#3fae6b,color:#14532d;
Build your study population
Identify your cases/exposed - then choose your study design
The later phases - extract variables (Phase 11), assemble the dataset (Phase 12) and analysis (Phase 13) - assume you already have a cohort: a table with pnr and index_date per person. This page shows how to identify your study population and give each person an index date; then you choose a study design.
In short: First you identify your cases/exposed and give each an index date. After that, the path depends on your design:
- Prevalence study / cohort without a comparison group: after this page you are done building the study population; continue to Phase 11 - Extract variables.
- Cohort study with a comparison cohort: after this page continue to → Comparison cohort.
- (Nested) case-control: after this page continue to → Case-control.
- Self-controlled or family-based design: lets the person (or family) be their own control - see [Self-controlled and family-based designs](10c_special-designs.qmd).
What is index date?
Index date is the point in time that marks the start of follow-up for a given person.
- Exposed: the date the person received the exposure (e.g. surgery date, diagnosis date, first prescription dispensed)
- Comparator cohort: the index date assigned from the matched exposed person
Everything that follows - outcome date, covariates at baseline, follow-up time - is calculated relative to index date. The definition of index date is crucial for study validity.
This page is still under development. The code needs further review and testing before being used directly. Use it as structural guidance and adapt to your own project.
Functions used here. inner_join() and bind_rows() are already shown in Extract from LPR and explained in detail in Link your extracts. group_by() + slice() is explained in Long ↔︎ wide format. New in Step 3: anti_join(), distinct(), pull() plus nrow(), cat() and stopifnot() - see Function guide.
Step 1 - Define the source population
Before you look for the exposed, you need to answer: who could have entered the study at all? That is the source population, and it is decided by two things - your data delivery and a residence criterion.
Your data delivery sets the outer limit. DST only delivers data on the population your project is approved for. If your delivery is “persons in BEF 2005-2024”, then a person who only lived in Denmark in 2003 does not exist in your extract at all - and no code can pull them in. Check your project guide first. The registers below can make the timing of residence precise within your delivery, but they cannot add people to it.
BEF alone cannot decide residence
BEF is a snapshot: one picture per reference time point (quarterly since 2008, yearly before that - see Overview of registers). That means BEF gets it wrong in both directions:
- Too many included: the person is in that year’s snapshot, but had emigrated or died by your index date.
- Too few included: the person is not in that year’s snapshot, but was genuinely resident on the index date. That happens if they had emigrated at the time of the snapshot and moved home later the same year.
You can filter your way out of the first error. You cannot filter your way out of the second - a filter can only remove people from the pool BEF already found, never add the ones missing from it. So residence has to be decided on events, not on snapshots.
Use BEF and VNDS together
You need both registers, and it is worth understanding why:
- BEF gives you the population and demographics: who exists, date of birth and sex. Use all snapshots in your study window combined (the union), not one year at a time.
- VNDS gives you the events: immigrations and emigrations with dates. VNDS holds only migration events, so a person born in Denmark who never left has no rows at all in VNDS. Absence of events is the normal case, not an error.
- DODSAARS gives the date of death, applied on top.
The rule is therefore a default with exceptions: resident since birth, unless VNDS says otherwise. For each person, find the latest VNDS event before the index date:
| Latest event before index | Resident at index? | Residence period starts |
|---|---|---|
"I" (immigration) |
Yes | the event date |
"U" (emigration) |
No | - |
| no events | Yes | the date of birth |
That start date is what you measure a continuous residence requirement from (e.g. “at least 5 years in Denmark before index”). It is not the same as the earliest year the person appears in BEF: someone seen in BEF in 2005, but living abroad 2006-2012 and moving home in 2013, has only two years of continuous residence at an index date in 2015.
Check these five things before you trust the rule.
- The event codes: run
count(indud_kode)on your own VNDS. The guide uses"I"and"U", but confirm the values in your delivery. - Immigration before 1971: CPR only has complete immigration and emigration data from 1971 (see Further information). A person born abroad who immigrated before that has no events, and the rule above wrongly counts them as resident since birth.
- Greenland is a status of its own in CPR, not the same as emigration. Find out how moves there appear in your delivery.
- Short stays abroad: decide whether a short gap breaks “continuous” residence, and write the choice down in your methods.
- Register coverage is not residence: five years of residence only gives you five years of lookback if the registers cover those five years too.
Other data sources
The National Centre for Register-based Research also works with a register called “stamdata”. If you have further information about this external dataset, please get in touch: Sara Schwartz - saras@clin.au.dk
Municipality codes can also be used to assess residence in Denmark. CPR keeps a running residence history: one period per address, with municipality code and dates. With it you can look residence up directly (was there a period spanning the index date?) instead of deriving it from BEF snapshots and VNDS events. CPR has complete information on municipality of residence in Denmark from 1971 and full address from 1977; Greenland is a separate series from May 1972. The residence history says nothing about whether the person is alive, so the date of death still has to be fetched separately. See Further information for the source.
Note - do not read the gaps uncritically. Up to 40% appear to have a longer period outside Denmark or with no known residence, probably immigrants, exchange students and others. This has not been investigated further, so a gap in the history can be either a genuine stay abroad or a missing registration. If you have more information about it, we would like to hear from you.
Show the code: the source population at study start
#=====================================================
# Step 1: the source population at study start (BEF + VNDS)
#=====================================================
library(fastreg) # read_register()
library(dplyr) # the verbs are translated to SQL and run in DuckDB
# Here residence is decided at ONE fixed date: study start. That is the simplest
# version, and it gives a list at person level. If each person has their own index
# date the rule is the same - but the date only exists later, see the note below
# the code.
STUDY_START <- as.Date("2010-01-01") # we choose: first day of the study
#-----------------------------------------------------
# 1.1 The population and date of birth (BEF, all snapshots combined)
#-----------------------------------------------------
# distinct() gives one row per person across ALL years in the window. Using a
# single year's snapshot loses those who moved home later that year.
bef <- read_register("bef") %>%
rename_with(tolower) %>%
distinct(pnr, foed_dag, koen)
#-----------------------------------------------------
# 1.2 Latest migration event BEFORE study start
#-----------------------------------------------------
# Everything is lazy: DuckDB does the lookup, and we only fetch the result at the
# end. Pulling VNDS into R first fills memory for no reason.
vnds <- read_register("vnds") %>%
rename_with(tolower) %>%
select(pnr, indud_kode, haend_dato)
latest_event <- vnds %>%
filter(haend_dato <= STUDY_START) %>% # only events before study start
group_by(pnr) %>%
slice_max(haend_dato, n = 1, with_ties = FALSE) %>% # the latest of them
ungroup() %>%
select(pnr, last_code = indud_kode, last_date = haend_dato)
#-----------------------------------------------------
# 1.3 Decide residence and apply the criteria
#-----------------------------------------------------
# left_join: people WITHOUT events get NA - and NA here means "never left",
# i.e. resident since birth. That is the default, not an exception.
source_population <- bef %>%
left_join(latest_event, by = "pnr") %>%
mutate(
resident = is.na(last_code) | last_code == "I", # no event OR last one was an entry
resident_since = if_else(is.na(last_code), foed_dag, last_date)
) %>%
filter(resident) %>% # resident at study start
filter(as.numeric(STUDY_START - resident_since) / 365.25 >= 5) %>% # 5 years continuous residence
collect() # only now is data pulled into RAMDoes each person have their own index date? Then exactly the same rule applies, but it has to be used where the date exists - not here. The index date is not a property of the person:
- The exposed get their index date in Step 2 (the exposure date). Residence is therefore decided for them in Step 3 on Comparison cohort.
- Comparison persons are only assigned an index date at the match itself, and the same person can be a candidate at several different index dates - and be resident at one but not the other. So residence is decided per (person, index date) at match time in Step 5, never once and for all.
In short: the source population here is at person level, while eligibility at index is at pair level (person + date).
Step 2 - Identify the exposed (or cases)
You scan the register that defines your exposure. You do not yet have a cohort_pnrs list - you query the entire register and filter on the exposure criterion.
The exposure can be defined in many ways:
| Type | Example | Register |
|---|---|---|
| Surgery / procedure (SKS code) | Bariatric surgery KJDF10/KJDF11 | lpr_sksopra (LPR2), procedurer_kirurgia (LPR3) |
| Hospital diagnosis (ICD code) | Type 2 diabetes E11 | lpr_diag + lpr_adm, lpr_a_diagnose + lpr_a_kontakt |
| Medication exposure (ATC code) | Metformin A10BA02 | LMDB |
| Clinical measurement / biomarker | BMI > 35, HbA1c > 75 mmol/mol | Project-specific data / OSDC / DBSO |
a lpr_sksopr and procedurer_kirurgi are the names on the DARTER project (708421) - see Overview of registers. Names may vary on other projects.
The examples use read_register(); it requires fastreg set up with the path to your registers - see Phase 4 if you did not convert them from SAS yourself.
Example A: SKS codes (surgery/procedure)
lpr_sksopr holds the procedure code (c_opr) + recnum, but not pnr or date; those live in lpr_adm. So you join the two on recnum to get person + date + procedure together - exactly the same pattern as diagnoses (contact + diagnosis register), see Understand LPR.
#=====================================================
# Step 2A: identify the exposed via SKS codes
#=====================================================
library(fastreg) # read_register - read a register by name
library(dplyr) # filter, select, group_by, slice, ungroup, bind_rows, mutate
# without fastreg: open_dataset("E:/workdata/[projectnumber]/cleaned-data/parquet-registers/<register>/") %>% rename_with(tolower) for each register
# Adapt these codes to your study
RYGB <- c("KJDF10", "KJDF11") # Roux-en-Y gastric bypass
SG <- c("KJDF40", "KJDF41", "KJDF96", "KJDF97") # sleeve gastrectomy
BS_CODES <- c(RYGB, SG) # combined vector
#-----------------------------------------------------
# 1A.1 LPR2: procedures up to 2018/2019
#-----------------------------------------------------
lpr_sksopr <- read_register("lpr_sksopr") %>%
rename_with(tolower)
lpr_adm <- read_register("lpr_adm") %>%
rename_with(tolower)
exp_lpr2 <- lpr_sksopr %>%
filter(c_opr %in% !!BS_CODES) %>% # only bariatric procedures
select(recnum, sks_code = c_opr) %>% # recnum is the join key to lpr_adm
inner_join(
lpr_adm %>% select(pnr, recnum, index_date = d_inddto), # attach pnr and date
by = "recnum"
) %>%
collect()
#-----------------------------------------------------
# 1A.2 LPR3: procedures from 2019 onwards
#-----------------------------------------------------
proc_kir <- read_register("procedurer_kirurgi") %>%
rename_with(tolower)
lpr_a_k <- read_register("lpr_a_kontakt") %>%
rename_with(tolower)
exp_lpr3 <- proc_kir %>%
filter(procedurekode %in% !!BS_CODES) %>% # only bariatric procedures
select(dw_ek_forloeb, sks_code = procedurekode) %>%
inner_join(
lpr_a_k %>% select(pnr, dw_ek_forloeb, index_date = kont_starttidspunkt),
by = "dw_ek_forloeb"
) %>%
collect() %>%
mutate(index_date = as.Date(index_date)) # datetime → date
#-----------------------------------------------------
# 1A.3 Combine and take one procedure per person (the first)
#-----------------------------------------------------
# The result is called "exposed" - only the exposed group, NOT the full cohort yet
exposed <- bind_rows(exp_lpr2, exp_lpr3) %>%
group_by(pnr) %>% # group per person
arrange(index_date) %>% # oldest date first
slice(1) %>% # one procedure per person (the first)
ungroup() %>% # release grouping (see Phase 12)
mutate(exposed = 1L) # mark as exposed (1 = yes)
nrow(exposed) # number of unique operated individualsexposed contains only the operated individuals. The full cohort (exposed + comparator cohort) is built on Comparison cohort and saved as cohort. It is cohort - not exposed - that you use as cohort_pnrs in the other phases.
Example B: ICD diagnosis as exposure criterion
#=====================================================
# Step 2B: identify the exposed via ICD diagnosis
#=====================================================
# Same LPR pattern as Phase 9 - but without semi_join(tibble(pnr = cohort_pnrs), by = "pnr"),
# as the cohort does not yet exist. You query the full population to identify the exposed.
lpr_diag <- read_register("lpr_diag") %>%
rename_with(tolower)
lpr_adm <- read_register("lpr_adm") %>%
rename_with(tolower)
exposed <- lpr_adm %>%
inner_join(
lpr_diag %>%
filter(c_diagtype %in% c("A", "B"),
substr(c_diag, 2, 4) == "E11") %>% # T2D: "DE11" → strip D-prefix
select(recnum, c_diag),
by = "recnum"
) %>%
select(pnr, index_date = d_inddto) %>%
collect() %>%
group_by(pnr) %>%
arrange(index_date) %>%
slice(1) %>% # first diagnosis per person
ungroup() %>%
mutate(exposed = 1L)This example pulls from LPR2 only. Always consider which registers your outcome/exposure needs - LPR2 (possibly + psychiatry) and LPR3 - and combine them as in 9b: Extract from LPR. If you leave out a register, you miss the cases that only appear there.
Step 3 - Exclude prevalent cases
People who already had your outcome before the index date must be excluded - otherwise they count as new cases even though they are not. This applies to all designs, including a prevalence study without a comparison group, so it belongs here where you build the exposed cohort.
Count how many drop out at each exclusion step, so you can describe the attrition and later draw a STROBE flow diagram (also called a participant or attrition diagram; see the STROBE checklist). The generic N-counting template is in Phase 6.
Helper function for excluding prevalent cases
If the same check is needed more than once (e.g. also on a comparison group later, see Comparison cohort), wrap the logic in a function so the two checks do not drift apart. See Good coding practice.
Show the code: exclude prevalent cases
#=====================================================
# Step 3: exclude prevalent cases
#=====================================================
# diagnoses = your LPR extract from Phase 9 (columns: pnr, date_contact, icd3)
OUTCOME_CODES <- c("G30", "F00", "F01", "F02", "F03") # ICD-10 for your outcome - adapt
# We make our OWN function (a reusable block of code). It takes three arguments:
# diagnoses = your LPR diagnosis extract (columns: pnr, date_contact, icd3)
# codes = the ICD codes that define your outcome (here OUTCOME_CODES)
# cohort = the group to check, with pnr + index_date
# It returns the pnr that had the outcome BEFORE their own index date.
prior_outcome_pnrs <- function(diagnoses, codes, cohort) {
diagnoses %>%
filter(icd3 %in% codes) %>%
inner_join(cohort %>% select(pnr, index_date), by = "pnr") %>% # attach each person's index
filter(date_contact < index_date) %>% # only contacts BEFORE index
distinct(pnr) %>% # one row per person
pull(pnr) # vector of pnr's to exclude
}
prevalent <- prior_outcome_pnrs(diagnoses, OUTCOME_CODES, exposed)
n_before <- nrow(exposed) # nrow() = number of rows (here: number of persons)
exposed <- exposed %>%
filter(!pnr %in% prevalent) # keep only those WITHOUT a prevalent outcome
# cat() simply prints a readable line in the console so you can follow the attrition.
cat("After prevalence exclusion:", nrow(exposed),
"| excluded:", n_before - nrow(exposed), "\n")
# If the message is only for yourself, you can settle for: nrow(exposed) # number remaining
stopifnot(n_distinct(exposed$pnr) == nrow(exposed)) # check: one row per personnrow(), cat() and stopifnot() are explained in the Function guide.
Planning a comparison cohort? Then extract the outcome (and exposure) dates for the whole population already here - not just for the exposed - and reuse them: restricted to the exposed in Step 3 (as above), and later for the comparison pool (see Comparison cohort). The outcome extract on the diagnosis codes returns everyone with the diagnosis anyway, so you save an extra query against the registers.
Participant flow diagram (STROBE)
The N counts you made in Step 2 and Step 3 come together in a STROBE flow diagram: a figure showing how many people were in from the start, how many were excluded at each step and why, and how many ended up in the study population. It is expected in almost every observational study. The diagram below also shows where the data comes from at each step (the numbers are simulated).
How each step maps to the data:
- Source registers: the whole population’s LPR data (you have no cohort list yet). LPR2 and LPR3 are opened lazily with
open_dataset()- see Step 2 above. - Step 2: you filter on your exposure codes and attach
pnr+index_date. The result isexposed. - Step 3 (exclusion): prevalent cases - people with the outcome before their index date - are found in the LPR diagnoses and removed. That is the
n_before - nrow(exposed)shown in the exclusion box. - Final study population:
pnr+index_dateper person, ready for Phase 11 - Extract variables. If you build a comparison cohort, Phase 10a is inserted before the last step. ::: {.callout-warning} Output control: the exclusion boxes show raw counts. Small numbers (e.g. an exclusion group with very few people) can be disclosive - round or combine them before the diagram leaves DST. See [Phase 14 - Export and repatriation](14_export.qmd). :::
What now - choose your design:
- Prevalence study / cohort without a comparison group: after this page you are done: you have your study population with an index date. Continue to Phase 11 - Extract variables and Phase 12 - Assemble and prepare the dataset.
- Cohort study with a comparison cohort: after this page continue to → Comparison cohort, where you build a match pool and risk-set match a comparison group.
- (Nested) case-control: after this page continue to → Case-control, where your “exposed” group above is instead your cases, and you sample controls who were at risk when the case had the outcome.
Further information
- The Danish Civil Registration System (CPR): Pedersen CB, Gøtzsche H, Møller JØ, Mortensen PB. “The Danish Civil Registration System. A cohort of eight million persons”, Dan Med Bull 2006;53(4):441-9 - documents what CPR contains and how far back it reaches: immigrations and emigrations plus municipality of residence from 1971, Greenland from May 1972, and full address from 1977.
See also
- Phase 1 - Study preparation: design choices behind cohort, matching and case-control
- Extract from LPR: diagnosis pattern for exposure/case identification
- Overview of registers: column names for lpr_sksopr, procedurer_kirurgi etc.