DST pitfalls

10 errors that cost time and produce uninformative or no error messages

Published

September 9, 2026

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.

2. RAM is shared - clean up after large extractions

You are on a shared server with shared RAM. When the memory bar in RStudio turns red, everyone on the server experiences slowdowns - and DST automatically kills processes when RAM is close to full, so an oversized extraction can cost you your unsaved work.

# Filter early - never collect() first
lmdb <- read_register("lmdb") %>%
  rename_with(tolower) # lazy connection - no RAM used yet (fastreg gets the path)
# Without fastreg: open_dataset("path/to/lmdb/") %>% rename_with(tolower)

result <- lmdb %>%
  semi_join(tibble(pnr = cohort_pnrs), by = "pnr") %>% # only the cohort's pnr's
  filter(substr(atc, 1, 4) == "N06D") %>% # filter before collect
  select(pnr, atc, eksd) %>%
  collect() # only now is data moved to R

# Free large objects when you are done with them
rm(lmdb) # delete the lazy connection - it does not use much, but it is good practice
gc() # return memory to the operating system

To find out which object is filling the memory, see Seeing your memory use from inside R - gc() for the session total and object.size() for the culprit. More practical habits (save often, partial loading, Task Manager) in Mind your RAM in the shared environment, and DST’s official advice in DST guide: Reducing RAM use in the shared environment (PDF, Danish).

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 directly

5. 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 date

6. 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_countnmi_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.

Back to top