DST pitfalls
10 errors that cost time and produce uninformative or no error messages
This page collects the errors that most frequently catch new users of DST registers. What they have in common: the error messages are either confusing, or there is no error message at all - the result is just silently wrong.
1. Four death registers - dodsaars stops in 2001
Four registers have confusingly similar names, and the one most people reach for first is the one that ends a quarter of a century ago:
| Register | Full name | Covers | Use it for |
|---|---|---|---|
dod |
Døde i Danmark | 1970-2025, ongoing | Censoring at death |
dodsaars |
Dødsårssagsregistret | 1970-2001 only | Cause of death, to 2001 |
dodsaasg |
Dødsårsagsregister | 2002-2022 | Cause of death, 2002-2022 |
dodsaarsager |
Dødsårsagsregister | 2022-2024 | Cause of death, 2022 on |
Coverage is from DST’s own register overview.
Two separate things are going on. The date of death lives in dod, which covers the whole period on its own. The cause of death is split across the three others, each picking up where the last stopped, so a study spanning 1995 to 2024 needs all three chained together and none of them alone will tell you it is incomplete.
If you censor on dodsaars, everyone who died after 2001 looks alive. There is no error and no warning. They stay in your risk set until the end of follow-up, so your survival curves are too good and your rates are too low. For a study running past 2001, which is nearly all of them, use dod.
library(fastreg) # read_register()
library(dplyr) # semi_join, select, collect
# Date of death for censoring: dod, not dodsaars
death <- read_register("dod") %>%
rename_with(tolower)
death_person <- death %>%
semi_join(tibble(pnr = cohort_pnrs), by = "pnr") %>% # only the cohort's pnr's
select(pnr, death_date = doddato) %>% # doddato, NOT d_dodsdto
collect()Two things to check on your own project before you rely on this.
Is dod in your delivery, and does it reach far enough back? Your project received the years it ordered, not necessarily all of 1970-2025. Check with read_register("dod") %>% summarise(min(doddato), max(doddato)) %>% collect().
Is it converted to parquet? On some projects dod sits only as a raw SAS file, so read_register() will not find it and you read it with haven::read_sas() instead. Ask your data manager for the path.
Only if dod turns out not to cover your early years do you need dodsaars as well, and then you stack the two and rename so the date columns match. Do not do it by default: on a full delivery both registers hold every death from 1970 to 2001, so stacking them counts those deaths twice.
3. rename_with(tolower) must be called on each register
Raw column names vary by register and year: PNR, pnr, Pnr, V_CPR. If you forget it, semi_join(..., by = "pnr") silently fails with “Column pnr not found” - even though the column is there.
The rule: every open_dataset() or read_register() call ends with %>% rename_with(tolower) as the first step in your pipe. See Extracting data step by step for explanation and example.
4. Date columns are not always in Date format
DST registers store dates in multiple formats - they look the same but behave differently.
| Format | Example | What class() returns |
What to do |
|---|---|---|---|
| Date | 2020-05-15 |
"Date" |
Nothing - can be used directly |
| Character | "2020-05-15" |
"character" |
as.Date(column) |
| Datetime | "2020-05-15 14:32:00" |
"POSIXct" |
as.Date(column) to get only the date part |
| SAS integer | 21990 |
"numeric" |
as.Date(column, origin = "1960-01-01") |
The rule: always check class() on a date column before using it in calculations.
class(lpr_a_kontakt$kont_starttidspunkt) # "POSIXct" - datetime, not Date
# Fix:
mutate(date = as.Date(kont_starttidspunkt))
class(bef$foed_dag) # "Date" - can be used directly5. BEF is a status snapshot - not a live register
BEF is a status register: it records the composition of the population at a given reference time - not continuously. DST classifies every register as one of four reference types: Status (a snapshot on one date, which is BEF), Statusperiode (population fixed on a date, values accumulated over a period), Forløb (rows with a start and an end date) and Hændelse (one row per event, with one date). Knowing which type you are holding tells you what a row actually means. DST’s reference time is ultimo (typically 31 December for an annual snapshot). Since 2008, BEF is also delivered quarterly (March, June, September, December).
year == 2020 meaning 1 January 2020 is a project convention. In many projects BEF snapshots are renamed so that year == 2020 refers to the population composition as of 1 January 2020, but that does not follow from DST’s delivery naming. Confirm the convention in your project guide.
See DST’s official BEF documentation: statistikdokumentation/befolkningen →
This means that a person who dies in June 2020 still appears in the 2020 BEF snapshot.
# ERROR: do not use BEF to check "alive on a specific date"
bef_2020 <- bef %>%
filter(year == 2020) # includes everyone in the 2020 snapshot
# - including those who die during 2020
# CORRECT: combine with dodsaars to exclude deaths
deaths <- read_register("dodsaars") %>% # without fastreg: open_dataset("path/to/dodsaars/")
rename_with(tolower) %>%
semi_join(tibble(pnr = cohort_pnrs), by = "pnr") %>%
select(pnr, d_dodsdto) %>%
collect()
bef_alive <- bef_data %>%
left_join(deaths, by = "pnr") %>%
filter(is.na(d_dodsdto) | d_dodsdto > index_date) # alive at index date6. The “a” in lpr_a_diagnose does not mean A-type diagnoses
The table is called lpr_a_diagnose - the “a” refers to “analysis model” (the LPR_A series introduced in 2025). It does not mean the table only contains A-type (action) diagnoses.
The table contains all diagnosis types: A (action), B (secondary diagnosis) and G (underlying condition). You still need to filter on diag_kode_type:
lpr_a_diagnose %>%
filter(diag_kode_type %in% c("A", "B")) %>% # still necessary
...7. Categorical codes are not consistent across registers
The same variable can have different coding in different registers - different type (numeric vs. character), different values, or both.
The worst case is ICD-10. LPR stores it with a Danish D in front (DE119), the cause-of-death registers and the cancer register store the plain WHO code (E119). A code list built for one matches nothing in the other, and the habit of stripping the D turns E119 into 119. See ICD codes and the D-prefix.
In practice you extract demographic variables (sex, age) from BEF and rarely need to compare the same variable in another register. But if you do, always check with table() and class() before using the variable:
table(register_a$koen) # what are the actual values and types?
class(register_a$koen)
table(register_b$koen)
class(register_b$koen)8. !! (bang-bang) forgotten in lazy evaluation
When filtering with a local R vector inside a DuckDB query, you must use !!. Without it, DuckDB looks for a column with that name - and fails silently or with a confusing message.
# Example: a year list against bef (the principle applies to any local R vector)
my_years <- c(2018, 2019, 2020) # local R vector (years, here as an example)
# WRONG - DuckDB looks for a column called "my_years"
bef %>% filter(year %in% my_years) # error or wrong result
# CORRECT - !! tells DuckDB: "use the local R vector"
bef %>% filter(year %in% !!my_years)!! is necessary for all local R objects used inside filter(), mutate() etc. on lazy DuckDB connections - typically code or year lists (%in% !!codes, >= !!min_date). If instead you filter on pnr against the whole cohort, use semi_join(tibble(pnr = cohort_pnrs), by = "pnr"): it takes a local table directly and needs no !!. See Functions guide for full explanation.
9. nmi_count ≠ nmi_score
These two variables are not the same and are not interchangeable:
| Variable | What it is | Source |
|---|---|---|
nmi_score |
Weighted comorbidity score - Nordic Multimorbidity Index (Kristensen et al., Clin Epidemiol 2022). 50 predictors with individual weights; lung cancer counts e.g. 19 points, type 2 diabetes counts 2. | See NMI page |
nmi_count |
Simple count of the number of chronic conditions (out of 33 possible) a person has been diagnosed with | Calculated separately |
If you use nmi_count in your regression model instead of nmi_score, you are adjusting for something different than you think - and you get no error message.
10. Immortal time bias - exposure defined using the future
No error message, no warning - just an effect estimate that looks too good. Immortal time bias arises when a person is given follow-up time during which they could not, by construction, have had the outcome. It is the classic register mistake, because register data let you define groups retrospectively, looking back at what eventually happened.
A concrete example. Question: does bariatric surgery lower mortality in people with type 2 diabetes? You take everyone diagnosed with T2D in 2010, split them into a surgery group (had surgery at some point during follow-up) and a no-surgery group, and start counting follow-up for everyone at the diagnosis date.
The trap: to land in the surgery group, a person had to survive long enough to be operated. Say the average wait from diagnosis to surgery is 2 years. Those 2 years are immortal: anyone who died in that window never reached surgery and so fell into the no-surgery group instead. You have handed the surgery group ~2 years of guaranteed-alive person-time and labelled it “surgery” time.
| Group | Deaths | Person-years | Rate (per 1000 py) |
|---|---|---|---|
| Surgery (immortal time counted as surgery time) | 30 | 12,000 | 2.5 |
| Surgery (time correctly aligned) | 30 | 8,000 | 3.8 |
| No surgery | 50 | 13,000 | 3.8 |
The true rates are identical (3.8) - surgery does nothing. But counting the 4,000 immortal person-years as surgery time drops its rate to 2.5 and makes surgery look 34% protective. The “effect” is an artefact of the misaligned time zero, not of surgery.
The fix: align time zero. Eligibility, exposure assignment and start of follow-up must coincide.
- Risk-set (incidence-density) matching: start each person’s follow-up at the moment they become exposed, and assign each comparator the same index date (the core rule in Comparison cohort).
- Treat exposure as time-varying: the person contributes unexposed person-time until surgery, then exposed time after - never exposed time before they were exposed (see Time-varying variables).
Related: defining a baseline covariate from post-index information is the same error in disguise (e.g. the OSDC diabetes type, see OSDC). Whenever a variable is built from the future, ask whether you are conditioning on the person having survived to see it. Background: Hernán & Robins, What If, §3.6 (target trial, time zero).
11. year is a folder name, not a date
In a converted register, year says which yearly file a row came from. Filtering on it is what makes a query against a billion rows finish in seconds - but it does not tell you when anything happened. year == 2020 in BEF does not say which quarterly snapshot you have. When the timing matters, use the register’s own date column (hf_vfra in UDDA, d_inddto in lpr_adm, referencetid in BEF). The two uses look identical in the code.
And it is only a year at all if the register arrived as one file per year. Some registers arrive as a single file covering everything, and then every row carries the same year whatever its date. dodsaars spans 1970 to 2001 and says 2024 on every row. lpr_a_kontakt is worse, because it is the consolidated LPR_A table: one file holding the whole migrated history, so counting by year puts LPR1, LPR2, MiniPAS and LPR3 contacts all in the same single year, and it looks as though four reporting systems ran at once.
Check with count(year) before you filter on it. One value means the column labels the delivery, not the data, and a year filter will return everything or nothing. Use the register’s own date column instead, kont_starttidspunkt in lpr_a_kontakt. It is per register: lpr_diag in the same delivery really is split by year and filters correctly.
Full explanation: Phase 4 - Reading a register that is split by year.
Something actually broke?
Every pitfall above is a case where the code runs and the answer is quietly wrong. If instead you have a red error message, or a result that came back empty, that is Troubleshooting.