gantt
title LPR registers
dateFormat YYYY
axisFormat %Y
section LPR2 somatic
lpr_adm + lpr_diag :done, 1977, 2019
section LPR2 psychiatric
t_psyk_adm + t_psyk_diag :done, 1995, 2019
section LPR3 combined
lpr_a_kontakt + lpr_a_diagnose :active, 2019, 2027
Understand LPR
Structure, history and nuances - before you write code
The National Patient Register (LPR) is the source of diagnoses and hospital contacts. It covers all public hospital admissions and outpatient contacts in Denmark.
LPR is more complex than most registers, because it changed format in 2019 and is split into somatic and psychiatric tables. This page explains the structure - the periods, the ICD codes, the diagnosis types and the pitfalls you need to know before you extract data. The concrete extraction recipes are in Extract from LPR.
In short: LPR changed format in 2019 - use LPR2 (up to March 2019) and LPR3 (after) and combine them. All ICD codes carry a D prefix you usually strip, and you choose diagnosis types (A/B for outcomes, +G for baseline comorbidity).
LPR is split into two periods
In March 2019 LPR changed format. Studies covering the period across 2019 must query both systems and combine them.
| LPR2 somatic | LPR2 psychiatric | LPR3 | |
|---|---|---|---|
| Period | up to March 2019 | up to March 2019 | March 2019 and onwards |
| Contact register | lpr_adm |
t_psyk_adm |
lpr_a_kontakt |
| Diagnosis register | lpr_diag |
t_psyk_diag |
lpr_a_diagnose |
| Covers psychiatry | No | Yes | Yes (both combined) |
| Join key | recnum |
recnum¹ |
dw_ek_kontakt |
| Date column | d_inddto (Date) |
d_inddto (Date) |
kont_starttidspunkt (datetime)² |
| pnr column | pnr |
pnr³ |
pnr |
| Diagnosis code | c_diag |
c_diag |
diag_kode |
| Diagnosis type | c_diagtype |
c_diagtype |
diag_kode_type |
| Contact type | c_pattype ("0" = inpatient) |
c_pattype |
kont_type ("ALCA00" = inpatient) |
¹ ³ These are DST’s names. Check whether your variables have been changed during delivery or data preprocessing: in DARTER the join key arrives as k_recnum in t_psyk_adm and v_recnum in t_psyk_diag, and the person column as v_cpr. Check colnames() and rename to recnum and pnr before joining. ² datetime format - convert with as.Date().
This table lists what the registers contain, not what your project was given. Run colnames() on your own extract before you build anything on a column here - c_pattype in particular is often left out (see Patient type).
Why two registers - contact and diagnosis? LPR splits each hospital contact into two tables: the contact register (e.g. lpr_adm) has one row per contact with pnr, dates and hospital, but not the diagnoses; the diagnosis register (e.g. lpr_diag) has one row per diagnosis with the ICD code, but not pnr or date. One contact can have several diagnoses. You join the two on the contact key (recnum in LPR2, dw_ek_kontakt in LPR3) to get pnr + date + diagnosis in one table. That is the join the extraction recipes in Extract from LPR are built on. The same principle applies to operations and procedures: lpr_sksopr (LPR2) has the SKS code + recnum, but not pnr or date, so it is joined to lpr_adm in exactly the same way when you want to find who had an operation and when.
Psychiatry: separate in LPR2, combined in LPR3 Before 2019, psychiatric diagnoses (F-codes: dementia, depression etc.) were stored in separate registers (t_psyk_adm, t_psyk_diag). The structure resembles somatic LPR2, but column names differ - see the table footnotes above. From March 2019, LPR3 combines both: somatic and psychiatric contacts and diagnoses are in the same tables, and no separate psychiatric query is needed.
Psychiatric data older than 1995 exists but is not part of a standard LPR extract - see Overview of registers.
You access LPR3 through the LPR_A files (lpr_a_kontakt, lpr_a_diagnose).
Use the LPR_A files, and filter them. Two things, both easy to get wrong.
- LPR_A, not LPR_F. LPR3 has been delivered in two formats: the older LPR_F (
kontakter,diagnoser,forloeb, a course-oriented model) and the current LPR_A (lpr_a_kontakt,lpr_a_diagnose, contact-based). Projects receive LPR_A today, and every example on this site useslpr_a_*. Both may sit in your folder covering the same years, and the two represent the same contacts differently - so loading LPR_F as well, or mixing them, gives you duplicated rows. - The reporting system starts in March 2019, but the files reach further back.
lpr_a_kontaktholds contacts from around 2017 that LPR2 already covers, so “LPR3” as a period and “LPR_A” as a file are not the same thing. Filter withlprindberetningssystem == "LPR3", or the same contact is counted twice across the two generations. On DARTER this is pitfall 5.
(The LPR_F data model is documented in Vejledning til LPR3_F if you have inherited a project that used it. LPR_A documentation is still incomplete.)
ICD codes and the D-prefix
ICD-10 (International Classification of Diseases, 10th revision) is the WHO’s international system for classifying diseases and conditions. All hospital diagnoses in Denmark are coded with ICD-10, e.g. G30 for Alzheimer’s disease and F00 for dementia in Alzheimer’s.
ICD-10 codes in LPR have a prepended "D": "DG30" (Alzheimer’s), "DF00" (dementia), "DI21" (acute myocardial infarction). The D is a Danish addition from SKS, not part of the WHO code.
Strip the D-prefix before comparison - it makes code more readable and easier to reuse:
mutate(icd3 = substr(c_diag, 2, 4)) # "DG30" → "G30" (3-digit code)
mutate(icd4 = substr(c_diag, 2, 5)) # "DI219" → "I219" (4-digit code)substr(x, start, stop) keeps characters from position start up to and including stop (counted from 1). substr(c_diag, 2, 4) skips position 1 (the D-prefix) and keeps characters 2, 3 and 4: "DG30" → "G30". Use 2 to 5 for 4-digit codes: "DI219" → "I219".
The D is an LPR thing, not a DST thing
This is the part that costs people a result. The prefix belongs to the patient registers. Other registers store the plain WHO code, and stripping a character there removes a real one:
| Register | Column | What it holds | Example |
|---|---|---|---|
| LPR2, LPR3, psychiatric LPR | c_diag, diag_kode, adiag |
D-prefixed | DE119 |
Cause of death (dodsaars, dodsaasg) |
c_dod1, c_dod_1a |
plain | E119 |
| Cancer register | c_icd10 |
plain | C509 |
Run substr(x, 2, 4) on "E119" and you get "119". It matches nothing, it raises no error, and the result looks like an empty subgroup rather than a bug.
A leading D is not always a prefix. ICD-10 chapter D covers in-situ and benign neoplasms, so D46 (myelodysplastic syndromes) and D32 (benign meningeal tumour) are whole codes in their own right. In the cancer register they are exactly that. Strip the “prefix” and D46 becomes 46.
LPR2 before 1994 is not ICD-10 at all
LPR2 starts in 1977, and Denmark did not move to ICD-10 until 1994. The early years hold ICD-8: pure digits, no letter and no decimal point, so 446.30 is stored as 44630.
Nothing about this announces itself. An ICD-10 code list matches no rows before 1994, and substr(c_diag, 2, 4) on "44630" returns "463", which is not a code in any classification. A study whose period starts before 1994 is reading two classifications out of one column and needs a code list for each.
The same applies to dodsaars, which runs from 1970 and changes over in the same year.
Diagnosis types: A, B and G
| Code | Meaning | When to include |
|---|---|---|
| A | Action diagnosis - primary reason for the contact | Always for outcomes |
| B | Secondary diagnosis - additional condition present | Always for outcomes |
| G | Underlying condition - background comorbidity | Only for baseline comorbidity |
# For outcomes and exclusion diagnoses:
filter(c_diagtype %in% c("A", "B"))
# For baseline comorbidity (NMI):
filter(c_diagtype %in% c("A", "B", "G")) # note: "G" only exists 1995-2003Keep the type column in your extract. Carry c_diagtype (in LPR3: diag_kode_type) into your output, not just the diagnosis code. It costs one column and lets you vary the case definition later - e.g. main analysis on A + B, sensitivity analysis on A only (primary diagnosis) or including G - without re-querying LPR.
Patient type: inpatient, outpatient or emergency
Some studies need to tell inpatient contacts apart from outpatient and emergency-room visits - only admissions as an outcome, say, or acute contacts as a marker. There is no single shared field across the two generations, so you derive it, and the two generations derive it differently.
c_pattype and c_indm have to be in your extract. DST documents both for every year of LPR_ADM, but a delivery only contains the variables the project ordered. In the DARTER delivery, for instance, lpr_adm has neither, while t_psyk_adm has both - the same register family, two different extract scopes.
Run colnames() on your own lpr_adm first. If the columns are missing, no filtering trick recovers them: you have to ask DST to add them to the delivery.
How to derive it, in LPR2 and in LPR3
The column names and codes must be verified against your own extract. The logic is durable (ALCA00 and ATA1 are confirmed LPR3 values as of 2025), but the exact column names in LPR_A may differ from the example - check with arrow::schema() or colnames() and look them up in Overview of registers. The pattern is adapted from the Plana-Ripoll group’s code on OSF.
LPR2 (up to March 2019). Patient type lives in c_pattype, but the emergency-room coding changed in 2014: code "3" was discontinued and ER visits arrive as "2" with an acute admission mode instead. Source: Sundhedsdata- styrelsen’s register documentation, section “Kendte databrud/aendringer”.
library(dplyr) # mutate, case_when
lpr2_type <- lpr_adm %>% # your lpr_adm extract
mutate(
patienttype = case_when(
c_pattype %in% c("0", "1") ~ "inpatient", # "0" full/admitted, "1" part-day
c_pattype == "3" ~ "emergency", # explicit ER, discontinued in 2014
c_pattype == "2" & c_indm == "1" ~ "emergency", # from 2014: "3" became "2" + acute
c_pattype == "2" ~ "outpatient",
TRUE ~ NA_character_
)
)LPR3 (March 2019 and onwards). There is no c_pattype at all. The kont_type code ALCA00 means physical attendance (not admission), and the prioritet code ATA1 means acute. A common research approach derives patient type from the contact’s duration plus an ER/acute marker:
library(dplyr) # filter, mutate, case_when
lpr3_type <- lpr3_k %>% # your lpr_a_kontakt extract
filter(kont_type == "ALCA00") %>% # keep physical attendances; drop phone/video
mutate(
duration_hours = as.numeric(
difftime(kont_sluttidspunkt, kont_starttidspunkt, units = "hours") # verify the end-time column name
),
patienttype = case_when(
duration_hours >= 8 ~ "inpatient", # >= 8 hours ~ admission
enhedstype_ans == "skadestue" & prioritet == "ATA1" ~ "emergency", # acute ER
TRUE ~ "outpatient"
)
)The 8-hour cut-off is a heuristic, not an official definition - LPR3 has no “inpatient” field. Pick and document your own threshold, and clarify it with your data manager.
Retracted diagnoses in LPR3 (senere_afkraeftet)
LPR3 flags diagnoses that have been retracted. The standard filter:
filter(is.na(senere_afkraeftet) | senere_afkraeftet != "Ja")The is.na() part is deliberate. R’s default behaviour is: NA != "Ja" returns NA - not TRUE. A filter treats NA as FALSE and drops the row. filter(senere_afkraeftet != "Ja") alone would therefore remove all diagnoses that have no retraction marker at all (i.e. NA fields) - even though they are definitely not retracted. is.na(...) fixes this: “keep the row if the field is NA OR if it is not "Ja"”. The filter thus retains uncategorised diagnoses, which is the safest assumption.
Challenge with LPR_A: diagnosis spike around 2019-2020
The move to LPR3’s contact-based model also changed how outpatient diagnoses are registered, and this can distort diagnosis counts across the transition.
In LPR2, a course of outpatient visits was typically summarised with a single (action) diagnosis for the whole course. In LPR3, each contact can carry its own diagnoses. So a patient with 10 outpatient visits for depression that produced one diagnosis row in LPR2 can produce ten rows in LPR3 - the same illness, many more registrations.
The visible effect is a spike in the number of diagnoses around 2019-2020, present for most diagnoses, and visible even if you restrict to the primary (action) diagnosis. It is further complicated by an overlap with COVID-19 in 2020, which makes the period even harder to interpret.
There is no single agreed fix. This is a real problem in register research, especially for analyses that rely only on hospital diagnoses.
We therefore encourage you to visualise, across calendar years, the counts of the diagnoses used in your study.
library(lubridate)
all_dx %>%
# not `year`: that name belongs to the parquet partition column, and
# assigning to it would overwrite it
mutate(contact_year = lubridate::year(date_contact)) %>% # date_contact from your extract
count(contact_year) %>%
arrange(contact_year)
# plot n against contact_year and look for a jump around 2019-2020Most diagnoses show the spike. A few are stable across the transition (type 1 diabetes, for example) and make a useful sanity check: if even a stable diagnosis jumps in your data, something else is wrong.
For inspiration and for evaluating diagnostic stability across the transition, see Aarhus-Psychiatry-Research/diagnostic-stability-lpr2-lpr3 (methodological inspiration, not code for reuse).
Next steps
You now know LPR’s structure and the most important pitfalls. The next step is to extract the diagnoses with code:
See also
- Extract from LPR: the runnable extraction recipes
- Overview of registers: confirmed column names for all LPR registers
- DST pitfalls: known issues with LPR on DST
Looking up a code: the SKS browser for ICD diagnosis and SKS procedure codes, and esundhed.dk’s LPR code sheet (PDF). Both in Danish.
Going deeper on the 2019 transition: Sundhedsdatastyrelsen’s extraction guide (PDF) covers the contact-based LPR_A format. Danish only.