Medication (ATC)
Extract drug exposure from LMDB - filtered to your cohort
Prescription medication lives in LMDB (the Prescription Register). The register has one row per dispensed prescription, so the same person can have hundreds of rows. The two columns you almost always use are:
atc: the drug’s ATC code (Anatomical Therapeutic Chemical), e.g."A10BA02"for metformin.eksd: the dispensing date (when the prescription was collected) - use it as the prescription date, just likedate_contactin LPR.
LMDB starts in 1995, and is generally used from 1997. There is no medication history before that, so a lookback window that reaches further back is empty rather than negative - and “no prescriptions” will look like “not a user”.
The full confirmed column list is in Register reference.
LMDB only covers prescriptions dispensed at community pharmacies. Three things are therefore systematically missing: drugs given during hospital admission, drugs dispensed directly by hospitals (e.g. chemotherapy and immunosuppressants) and drugs for certain institutionalized people (Pottegård et al. 2017). If you need in-hospital medication, a newer Hospital Medication Register (Sygehusmedicinregisteret, data from 2018) exists, but it is still unvalidated, incomplete and accessed via the Danish Health Data Authority, not DST - use it with caution (Rosenkrantz et al. 2024).
Exposure, outcome or covariate? Same extract
Medication can be all three. It is solved exactly as for diagnoses (Extract from LPR): you make one extract of the dispensings, and the role is decided by when eksd falls relative to index_date - not by a separate page per role:
| Role | When | What you do | Page |
|---|---|---|---|
| Exposure | The date exposure starts | First eksd = the person’s index date |
Phase 10 |
| Covariate (medication at baseline) | eksd < index_date (e.g. 6-12 months before) |
Ever/never or a count in a window before index | Comorbidity |
| Outcome (new treatment) | eksd > index_date |
First eksd after index |
Outcomes |
| Time-varying (on/off treatment) | Changes during follow-up | Start/stop format | Time-varying |
So you do not write three different medication extracts. You write one, and the date filter relative to index determines the role.
The pattern
ATC codes have no D-prefix (unlike ICD in LPR, see Understand LPR), so you match directly on the start of the code. read_register() requires fastreg set up with the path to your registers - see Phase 4 if you did not convert them from SAS yourself:
library(fastreg) # read_register()
library(arrow) # open_dataset() - fallback without fastreg
library(dplyr)
cohort_pnrs <- unique(readRDS("path/to/full_cohort.rds")$pnr) # your cohort from Phase 10
medication <- read_register("lmdb") %>% # without fastreg: open_dataset("path/to/lmdb/")
rename_with(tolower) %>%
semi_join(tibble(pnr = cohort_pnrs), by = "pnr") %>% # ONLY your cohort
filter(substr(atc, 1, 5) == "A10BJ") %>% # GLP-1 analogues - filter BEFORE collect
select(pnr, atc, eksd, vnr) %>%
collect() %>% # only HERE is data pulled into RAM
group_by(pnr) %>%
arrange(eksd) %>%
slice(1) %>%
ungroup() # first dispensing per person
saveRDS(medication, "path/to/extract_medication.rds")substr(atc, 1, 5) == "A10BJ" matches the whole ATC level (all GLP-1 analogues). For several groups at once, use regex as in LPR: grepl("^A10BJ|^A10BA", atc). The code-matching pattern (regex, %in% with a code list, !!) is explained in Extract from LPR and Function guide. Reducing to one row per person is explained in Long ↔︎ wide format - here slice(1) for the first dispensing, but it could also be ever/never or a count in a window (see the role table above).
From dispensings to “is this person a user?”
One dispensing is weak evidence of treatment: people fill a prescription once and stop. A common definition is therefore at least two dispensings within a window, with the onset date being the date the definition is first met.
library(dplyr) # group_by, mutate, lag, filter, summarise
# Does the person have `min_dispensings` fills inside `window_days`?
# lag() looks back to the (min_dispensings - 1)th previous fill for that person,
# so the gap between them is the span of the whole qualifying run.
flag_users <- function(data, min_dispensings = 2, window_days = 365) {
data %>%
arrange(pnr, eksd) %>% # lag() needs the rows in date order
group_by(pnr) %>%
mutate(
lookback = lag(eksd, n = min_dispensings - 1),
gap_days = as.numeric(eksd - lookback)
) %>%
ungroup() %>%
filter(!is.na(gap_days), gap_days <= window_days) %>% # the run is short enough
group_by(pnr) %>%
summarise(onset_date = min(eksd), .groups = "drop") # first date the rule is met
}
users <- flag_users(medication, min_dispensings = 2, window_days = 365)The two numbers are your choices, not standards - write them into your methods. One dispensing is enough for a drug taken once; a chronic treatment usually wants two or more.
This is also how medication can enter a comorbidity definition: for conditions treated largely outside hospital, LMDB catches people LPR never sees. Whether to define a condition from diagnoses, prescriptions or both is a design decision - see Comorbidity - diagnoses, prescriptions, or both?.
arrange() before lag(), always. lag() works on row order, not on dates. If the rows are not sorted by person and date, gap_days is computed against an arbitrary earlier row and the result is quietly wrong rather than an error.
Which ATC column?
LMDB does not hold the ATC code once. It holds it five times, at five levels, in five separate columns. DST’s variable list for LMDB labels them ATC-kode detaljeret and ATC-niveau 1 to ATC-niveau 4. They are cumulative: each level repeats the levels above it and adds its own characters.
| Column | ATC level | Characters | Example | What it is |
|---|---|---|---|---|
atc1 |
1 | 1 | C |
anatomical main group |
atc2 |
2 | 3 | C09 |
therapeutic subgroup |
atc3 |
3 | 4 | C09A |
pharmacological subgroup |
atc4 |
4 | 5 | C09AA |
chemical subgroup |
atc |
5 | 7 | C09AA05 |
chemical substance |
The column names and their levels come from DST’s variable list. The character counts are not stated there; they follow from the WHO standard, where level 2 is by definition the three-character code (WHO Collaborating Centre for Drug Statistics Methodology).
A pattern longer than the column can never match, and R will not tell you. atc2 holds three characters. grepl("N02A", atc2) compares a four-character pattern against three-character values, so it matches nothing: zero rows, no error, no warning. The same goes for filter(atc2 %in% c("A10A", "A10B")). An empty result looks like a finding rather than a bug, and it is easy to write up as “no exposed patients” when the truth is that the filter could not fire.
So filter on atc, not on the level columns. It is seven characters, it contains every level as a prefix, and substr(atc, 1, 3) gives you exactly what atc2 holds. There is no case where a level column lets you filter something atc cannot:
library(dplyr) # filter, select, collect
# Ischaemic heart disease treatment, defined as organic nitrates (C01DA):
ihd <- lmdb %>%
filter(grepl("^C01DA", atc)) %>% # the full definition, in one filter
select(pnr, atc, eksd) %>%
collect()Use ^ to anchor the pattern at the start of the code. It costs nothing and it is the right habit from LPR, where a code genuinely can turn up mid-string.
Which way should you write the filter?
There are two ways to keep the codes you want, and the length of your codes decides which one to use. ATC codes are not all the same length: N02 is three characters, A10BA is five.
| Your code list | Use | Why |
|---|---|---|
| All codes have the same length | substr() + %in% |
Faster, and easy to read |
| Codes have different lengths | grepl() with ^ |
Handles any length |
All codes the same length. Cut atc down to that length, then check whether the result is on your list:
library(dplyr) # filter, select, collect
# Four drug groups, all three characters long
my_codes <- c("A10", "C09", "N02", "M01")
lmdb %>%
filter(substr(atc, 1, 3) %in% !!my_codes) %>% # keep the first 3 characters, look them up
select(pnr, atc, eksd) %>%
collect()substr(atc, 1, 3) means “the first three characters of atc”. Change the 3 to match your codes: use 1, 5 for five-character codes such as A10BA.
Codes of different lengths. One substr() cannot serve N02 and A10BA at the same time, because they need different cut lengths. Use grepl() instead, which matches whatever you give it:
lmdb %>%
filter(grepl("^N02|^A10BA|^C09AA", atc)) %>% # ^ means "starts with", | means "or"
select(pnr, atc, eksd) %>%
collect()In doubt? Use grepl(). It works for every code length, so it is never wrong. substr() + %in% was about twice as fast on a twenty-million-row test, which is worth having when you fetch many drug groups at once, but a correct slow filter beats a fast broken one.
Does it save memory to filter on atc2 first? No. It is a reasonable thing to wonder, since atc2 is the short column, but the filter runs before anything is loaded: nothing reaches your memory until collect(), and what lands there is the rows that survived, which are the same rows either way. substr(atc, 1, 3) gives you exactly what atc2 contains, so the two are the same query.
There is a practical catch as well. Comorbidity definitions often need more than three characters (A10BA rather than A10), and atc2 cannot narrow that far. You would filter on atc2, then bring in atc afterwards to get down to the codes you actually wanted: two steps for a result one step gives you. And if a code longer than three characters is compared against atc2 on its own, it matches nothing and returns no rows without an error, so it is worth doing in one step on atc.
What the level columns are actually for: grouping, not filtering. When you want a descriptive breakdown by therapeutic subgroup, atc2 is a ready-made grouping column and saves you a substr():
medication %>%
count(atc2, sort = TRUE) # dispensings per therapeutic subgroupThat is the use case. Filtering is not.
Two minutes, and it settles both whether the columns exist and how they are coded:
library(dplyr) # select, collect
colnames(lmdb) # do you even have atc1-atc4? Not every delivery does
lmdb %>%
select(atc, atc1, atc2, atc3, atc4) %>%
head(20) %>%
collect()Expect C / C09 / C09A / C09AA / C09AA05 across a row: each column repeating the one before it. If instead you see isolated fragments (09 in atc2), every ^-anchored pattern in this section is wrong for your delivery and you should filter on atc only.
Note that names(lmdb) will not answer this. On a lazy connection it returns the connection object’s own parts (src, lazy_query), not the table’s columns, so it looks as though the columns are missing. colnames() works on the DuckDB object read_register() returns; if you opened LMDB with open_dataset() instead, use names(collect(head(lmdb, 0))) - see Phase 7 - The shape.
atc3 and atc4 are not filled in for every product. Not all preparations are classified to that depth, so do not assume the columns are complete. Count the blanks in your own extract before you build anything on them.
ATC is not enough: same substance, different product
ATC classifies by active substance, not by product or indication. Two brands with the same substance therefore get the same ATC - and cannot be told apart on atc alone:
- Ozempic (semaglutide, type 2 diabetes) and Wegovy (semaglutide, weight loss) both have ATC
A10BJ06. Filter on ATC only, and you mix diabetes treatment together with weight-loss treatment.
Two columns separate them:
vnr(varenummer): the unique key to the actual product (package). It is the only reliable way to isolate one specific product. Thevnr-to-product lookup comes from the medicine taxonomy (KAT / Danish Health Data Authority);name/packtexthold the product text if you want to recognise it by eye.indo(indication code): a coded indication (from the Medicinpriser catalogue, LMS 25), not free text. The code is recorded only when the prescriber picks an indication from the drop-down menu in the electronic prescription. If the doctor types the indication as free text instead, it is not transferred to the register andindois left blank. So it can in principle separate the same substance across indications, but it is often empty. Usevnras the primary product key andindoas a supporting signal, not a clean filter.
# 1. Build your own list of the varenumbers that belong to the product
# (look them up in the medicine taxonomy - one product has several varenumbers):
ozempic_vnr <- c("xxxxxx", "yyyyyy") # placeholders - replace with your looked-up numbers
# 2. Keep only the dispensings whose vnr is on your list:
medication %>%
filter(atc == "A10BJ06") %>% # semaglutide (Ozempic AND Wegovy)
filter(vnr %in% !!ozempic_vnr) # keep only rows with a vnr from your listKeep the two names in the last line apart: vnr (on its own) is the register’s column holding the varenummer - the product’s ID, just as pnr is the person’s ID. ozempic_vnr is your own R vector of the varenumbers you looked up for Ozempic; you choose the name yourself (same pattern as the code list in Extracting data step by step). %in% keeps the rows where vnr is found in your list, and !! sends your local list into the lazy query (explained in Extract from LPR and Function guide).
Which vnr belongs to which product depends on package and strength (one product has several varenumbers). Look them up in the medicine taxonomy for your exact study period rather than assuming, and document the list in your code. For indo, Harbi & Pottegård 2024 found a recorded indication code on 82% of prescriptions (about 88% corrected) and almost 100% correct when present - but missingness is markedly higher before 1 October 2017 (when electronic prescribing became mandatory) and varies by drug class (about 8% missing for systemic anti-infectives versus 28% for blood-related agents). 5.6-36% of codes are nonspecific (e.g. “for the heart” for beta-blockers); whether a nonspecific code is usable depends on your question. The value set is the Danish Health Data Authority’s drug classification (Medicinpriser). As a side note: a validly recorded code does not mean the prescriber chose the correct indication - drop-down menus make wrong choices easy, so the code is not necessarily the clinical truth.
A dispensing is not the same as intake. eksd tells you the prescription was collected at the pharmacy, not that the patient took the drug, and certainly not for how long. For an exposure that lasts over time - treatment episodes, grace periods (the allowed gap between two prescriptions before a person counts as having stopped) and adherence measures such as PDC (proportion of days covered: the share of follow-up time covered by medicine) or MPR (medication possession ratio: amount dispensed divided by the length of the period) - you must build exposure windows from quantity and strength, not just count dispensings. The relevant LMDB fields are apk (number of packages), packsize (pack size, i.e. units per package) and strnum (the numeric strength; the unit is in strunit). Note that the dosage field itself (doso) is essentially empty (recorded for ~0.06% of prescriptions in the same validation), so dose must be derived from these package fields. Ready-made tools for this are in heaven (medicinMacro() for drug exposure windows from LMDB, and hypertensionMedication() if the exposure you need is antihypertensive treatment, which it defines from ATC codes for you). If your index date or exposure status depends on future medication, you get immortal time bias: if you require a person to fill the prescription (or two prescriptions) after index to count as exposed, then by construction they survived until that date - and the “immortal” time between index and the first fill makes the exposure look artificially protective. Instead, start follow-up for the exposed at the first dispensing, or treat medication as a time-varying exposure.
See also
- Extract from LPR: code matching in detail (regex, code lists,
!!) - same technique without the D-prefix - Outcomes: where a codelist comes from (applies to ATC too)
- Register reference: all confirmed LMDB columns
- DST’s variable list for LMDB: DST’s own column labels and period coverage. The darter project has the same lookup for Danish registers in a searchable form
- Algorithms & special packages:
heavenfor exposure windows; OSDC and NMI already use ATC codes - Time-varying variables: on/off treatment over time
- Phase 12 - Assemble and prepare the dataset: join the medication extract onto the cohort
- RECORD-PE: reporting standard for pharmacoepidemiology on routinely collected data (extends STROBE/RECORD)