Socioeconomic variables

Education, income and employment following the SEPLINE approach

Published

September 9, 2026

You now know the pattern: read_register/open_dataset → filter → collect → left_join. That is exactly what you use here. Two things are new in this phase:

  1. “Fetch for the year before index date”: SES registers are annual snapshots. You cannot just filter on pnr; you must also match on which year is relevant per person (year(index_date) - 1). This is done by calculating the baseline year per person and using it as a join key.
  2. FAIK via familie_id: Income is linked to the household, not the person directly. You need BEF as a bridge: fetch familie_id from BEF for the baseline year, then join to FAIK on familie_id.

The rest is categorisation per SEPLINE guidelines.

Socioeconomic position (SEP) is measured in register-based studies via three dimensions: education (UDDA), income (FAIK) and employment (AKM). This page shows how to extract and categorise them following the SEPLINE guideline.

SEPLINE article: Hjorth et al. Clinical Epidemiology 2025 - doi:10.2147/CLEP.S520772. See the article for full justification, recommended reference groups and categorisations.

ImportantStructural guidance, not verified code

The categorisations below are not validated. They show how to code the variables, not an approved implementation, so review them against your own analysis plan before you use them. Have working code, or input on the categorisations? Get in touch.

The examples read registers by name with read_register(), which assumes fastreg is pointed at your registers. Without fastreg, use open_dataset("path/to/akm/") instead. Column names differ between deliveries, so check yours with names(your_data).

Fetch the variable for the year BEFORE index - not the index year itself. All three registers are annual: each person has one value per calendar year. When in the year the value applies depends on the register - e.g. income (FAIK) is a sum over the whole calendar year, while education (UDDA) and employment (AKM) use a reference point during the year. Look it up for the specific register if the exact timing matters to you.

Regardless of the exact timing, the point holds: if you take the value from the index year, you risk measuring status that falls after your exposure. Example: index = surgery date 15 June 2015. The 2015 value may reflect status after the surgery and be affected by the exposure itself (e.g. job loss after illness) - that gives reverse causation, whereas you want a baseline value from before the exposure.

So you fetch the value for year(index_date) - 1 (here 2014). This is a safe, uniform rule for the whole cohort, regardless of when in the year index falls.

The three dimensions

Dimension Register Variable
Education UDDA hfaudd - highest completed education (DISCED-15 code)
Income FAIK famaekvivadisp_13 - household-equivalised disposable income
Employment AKM socio13 - labour market classification

SEPLINE specifies both how these variables are categorised and when in the follow-up they are measured.

Employment - AKM (socio13)

socio13 is DST’s socioeconomic classification. The full code list, with what each value means, is in DST’s classification documentation: SOCIO →.

DST’s words are narrower than the everyday ones. “Beskæftiget” is a statistical definition with thresholds attached, not simply “has a job”, and the same goes for “husstand” and “disponibel indkomst” further down this page. If you are about to write in a methods section what a category means, look the word up first: Hvad betyder →. Getting this wrong does not produce an error, it produces a sentence in your paper that does not match your data.

Two codes are easy to get wrong. 410 is Andre, a category of its own, not unemployed: the unemployed are 210. And 420 is children under 15, a real category rather than a missing value, so seeing it in an adult cohort means the index date is wrong, not that the data is incomplete.

Two things about year before you join on it. It is not a DST variable - it comes from the parquet partitioning, so confirm the name in your own files (Phase 4). And do not assign to it: mutate(year = ...) overwrites the partition column rather than adding one, which silently breaks every join in this chapter, since they all join on person plus year. Give a derived year its own name, e.g. index_year.

library(fastreg) # read_register() - reads registers by name
library(arrow) # open_dataset() - fallback without fastreg
library(dplyr) # filter, select, mutate, left_join, collect
library(lubridate) # year() to extract year from dates

# 1. Open AKM via fastreg (read_register gets the path from your project config)
akm <- read_register("akm") %>%
  rename_with(tolower) # standardise column names
# Without fastreg: open_dataset("E:/workdata/[projectnumber]/cleaned-data/parquet-registers/akm/") %>% rename_with(tolower)

# 2. Fetch employment status for the year before index date
# (assumes cohort has columns pnr and index_date)
index_year <- unique(lubridate::year(cohort$index_date) - 1) # baseline year = index year minus 1

akm_data <- akm %>%
  semi_join(tibble(pnr = cohort$pnr), by = "pnr") %>% # only the cohort's pnr's
  filter(year %in% !!index_year) %>% # baseline year
  select(pnr, year, socio13) %>% # only the columns we use
  collect() # fetch into R

# 3. Attach to cohort with index year as join key
cohort_akm <- cohort %>%
  mutate(year_baseline = lubridate::year(index_date) - 1) %>% # calculate baseline year
  left_join(akm_data, by = c("pnr", "year_baseline" = "year")) # join on pnr and year

# 4. Categorise per SEPLINE
cohort_akm <- cohort_akm %>%
  mutate(
    occupation_cat = case_when(
      socio13 %in%
        c(
          110,
          111,
          112,
          113,
          114,
          120,
          131,
          132,
          133,
          134,
          135,
          139
        ) ~ "Employed",
      socio13 == 310 ~ "Student", # under uddannelse
      socio13 == 210 ~ "Unemployed", # arbejdsløse mindst halvdelen af året
      # 220 sygedagpenge/orlov, 321 førtidspension, 330 kontanthjælp
      socio13 %in% c(220, 321, 330) ~ "Outside labour market",
      socio13 %in% c(322, 323) ~ "Retired", # folkepension, efterløn
      socio13 == 410 ~ "Other", # "Andre" - a category of its own, not unemployed
      socio13 == 420 ~ "Child under 15", # should not occur in an adult cohort
      TRUE ~ "Unknown" # missing
    )
  )

Education - UDDA (hfaudd)

hfaudd is short for højest fuldførte AUDD - the highest completed education, coded with an AUDD code. It is a four-digit DISCED-15 code for the specific education a person completed: 4112 is “electrician”. It is an identifier, not a scale, so the level (short / medium / long) has to be looked up, not read off the digits. UDDA itself does not carry the level.

The AUDD in the name matters when you go looking for a lookup table: DISCED-15 codes come in two flavours, AUDD for a completed education and UDD for one that is ongoing or was interrupted. DST documents them as two separate classifications, DISCED-15 AUDD and DISCED-15 UDD. hfaudd is completed, so it is an audd lookup you want.

Do not derive the level from substr(hfaudd, 1, 2). The level codes (10, 15, 20, 30, 40, 70, 90) look just like the first two digits of an education code, so it is the obvious thing to try - and it is wrong roughly 4 times in 10, silently. You need the lookup. Why it fails is set out in Overview of registers.

# 1. Open UDDA via fastreg (read_register gets the path from your project config)
udda <- read_register("udda") %>%
  rename_with(tolower) # standardise column names
# Without fastreg: open_dataset("E:/workdata/[projectnumber]/cleaned-data/parquet-registers/udda/") %>% rename_with(tolower)

# 2. Fetch education for the baseline year
udda_data <- udda %>%
  semi_join(tibble(pnr = cohort$pnr), by = "pnr") %>% # only the cohort's pnr's
  filter(year %in% !!index_year) %>% # baseline year
  select(pnr, year, hfaudd) %>% # only the columns we use
  collect() # fetch into R

# 3. Take the latest record if a person appears multiple times
udda_data <- udda_data %>%
  group_by(pnr) %>% # group to find newest record per person
  arrange(desc(year)) %>% # newest year first
  slice(1) %>% # keep only the newest record
  ungroup() # release grouping

# 4. Look the level up - one row per education code, joined on hfaudd
library(heaven) # edu_code: 4,621 DISCED-15 codes with their level
data(edu_code)

udda_data <- udda_data %>%
  mutate(hfaudd = as.character(hfaudd)) %>% # match the type in edu_code
  left_join(
    edu_code %>%
      as_tibble() %>% # edu_code is a data.table
      mutate(hfaudd = as.character(hfaudd)) %>%
      select(hfaudd, level_code = number), # number = the two-digit level
    by = "hfaudd"
  )

# 5. Group the level into the SEPLINE categories
udda_data <- udda_data %>%
  mutate(
    level_code = as.numeric(level_code),
    education_cat = case_when(
      level_code %in% c(5, 10, 15) ~ "Short", # pre-school, primary, preparatory
      level_code %in% c(20, 25, 29, 30, 35, 39) ~ "Medium", # upper secondary and vocational
      level_code >= 40 & level_code < 90 ~ "Long", # short cycle higher and above
      TRUE ~ "Unknown" # 90 "not stated", or no match in edu_code
    )
  )

# ALWAYS check how many did not match - a high number means a type mismatch
# on hfaudd, or codes that are not in the lookup.
udda_data %>%
  summarise(unmatched = sum(is.na(level_code)), total = n())

Getting heaven outside DST. On DST it is already installed, which matters because the server has no internet - you could not install it there yourself. Elsewhere it is not on CRAN, so install.packages("heaven") will not find it, but it installs from GitHub in one line:

# install.packages("pak")
pak::pak("tagteam/heaven")

If you would rather not add the dependency, DST has the same lookup on the server. The level files live in SAS_datasaet/Uddannelser/ and are named by the levels they map between: an L1 → L5 file turns the four-digit hfaudd into the Hovedgruppe (10 Grundskole, 20 Gymnasiale, 30 Erhvervsfaglige, … 80 Ph.d.). Format tables has the naming systematics, the code, and the three things to check first.

Whichever source you use, the shape of the solution is the same: a table of education codes joined to your data on hfaudd. What you must not do is skip the lookup.

Use one lookup for the whole study, and write down which one. DST’s folder holds several versions of the classification side by side (2008, 2011, 2015, possibly newer). You do not need one per study year: DST updates the newest version with older codes as well, so the latest file covers your whole period. Take the newest.

The two routes will not necessarily give you the same set of categories though. heaven::edu_code follows DISCED-15 and splits education into 15 groups; DST’s level-5 Hovedgruppe is a nine-way split. Neither is wrong - they are different levels of detail. Compare them on your own data before you commit, and report which one you used.

The grouping in step 5 is our reading of SEPLINE, not a validated implementation. edu_code gives you the level reliably; how those levels are collapsed into “short / medium / long” is a study-design choice. Check the SEPLINE article for the categorisation your analysis should use, and note that edu_code also carries a ready-made 5-level grouping (code5txt) and ISCED codes (isced_code) if either fits better.

Income - FAIK via BEF (famaekvivadisp_13)

Income is linked to the household, not the person. You need familie_id from BEF as a bridge. SEPLINE recommends a 3-year average divided into quintiles stratified by sex × 5-year age group × reference year.

famaekvivadisp_13 has a sister column, famaekvivadisp, with no suffix. The _13 one is not simply the newer of the two: DST gives the old column the years 1990-2012 and the _13 column 1987-2024, so the newer definition was applied backwards as well as forwards. Use _13 for the whole period. Splicing the two at 2013 mixes two definitions inside one series.

# 1. Open BEF + FAIK via fastreg (read_register gets the path from your project config)
bef <- read_register("bef") %>%
  rename_with(tolower)
faik <- read_register("faik") %>%
  rename_with(tolower) # check whether FAIK is one row per family in YOUR data - see the callout below
# Without fastreg: open_dataset("E:/workdata/[projectnumber]/cleaned-data/parquet-registers/bef/") %>% rename_with(tolower)  (same for faik)

# 2. Fetch familie_id from BEF for baseline year
bef_family <- bef %>%
  semi_join(tibble(pnr = cohort$pnr), by = "pnr") %>% # only the cohort's pnr's
  filter(year %in% !!index_year) %>% # baseline year
  select(pnr, year, familie_id) %>% # familie_id is the bridge to FAIK
  collect() # fetch into R

# 3. Fetch income from FAIK for baseline year
faik_data <- faik %>%
  filter(year %in% !!index_year) %>% # only baseline year
  select(familie_id, year, famaekvivadisp_13) %>% # only the columns we use
  collect() # fetch into R

# 4. Join: pnr → familie_id → income
income <- bef_family %>%
  left_join(faik_data, by = c("familie_id", "year")) # two-key join: household and year

The recipe above assumes FAIK holds one row per family per year - that is why the bridge goes through familie_id. On DARTER that stopped being true from 2022 onward: FAIK now also carries pnr, and each family’s row is repeated once per family member. Whether this applies to your project depends on your data delivery, so check rather than assume either way.

names(faik) # is pnr among the columns?

# More than one row per family and year means the duplication applies to you
faik %>%
  count(familie_id, year) %>%
  filter(n > 1) %>%
  count() %>%
  collect()

What it does to the code above. The join on familie_id + year returns one row per family member instead of one per person, so your dataset grows without any error. And the 3-year average further down is then computed over a set weighted by household size: a five-person family counts five times as much as a single person in the same year. The numbers stay plausible throughout, which is what makes it dangerous.

If it applies to you, one line before the join fixes it. Collapse the duplicates back to one row per family-year, and the rest of the recipe works unchanged for every year:

faik <- faik %>%
  distinct(familie_id, year, famaekvivadisp_13) # one row per household per year

If your FAIK carries pnr for all years, you can join income to pnr directly and skip the BEF bridge entirely.

A ready-made alternative: heaven::averageIncome() does the multi-year average in one call, uses only the years strictly before the index year, and keeps one row per person-year - so repeated rows for the same person and year collapse on their own. That happens to cover the FAIK duplication above, though the function was written for a different reason (quarterly duplicates), so do not treat it as a guarantee.

It does not replace the BEF bridge: the income table you hand it must already be linked to pnr. heaven is pre-installed on DST and installs from GitHub elsewhere (pak::pak("tagteam/heaven")), so the choice is about whether you want the dependency - the code below is the version with no packages beyond the ones you already use.

3-year average and quintiles (SEPLINE recommendation)

SEPLINE recommends a 3-year average of income and quintiles stratified by sex × 5-year age group × year. Here is a simplified version with quintiles per year:

What this code does not do: ntile(mean_income, 5) calculates quintile boundaries from the cohort’s own values. The correct SEPLINE approach uses cut-points (Q20/Q40/Q60/Q80) derived from the full BEF population for each reference year, stratified by sex × 5-year age group. This requires an additional BEF extraction without a pnr filter and is not implemented here.

library(dplyr)   # filter, select, left_join, group_by, summarise, mutate

# Fetch 3 years: index year and the two preceding
year_3 <- c(index_year, index_year - 1, index_year - 2)   # 3-year window for average

bef_3yr <- bef %>%
  semi_join(tibble(pnr = cohort$pnr), by = "pnr") %>%   # only the cohort's pnr's
  filter(year %in% !!year_3) %>%                           # the 3 years
  select(pnr, year, familie_id) %>%                       # familie_id is the bridge to FAIK
  collect()                                              # fetch into R

faik_3yr <- faik %>%
  filter(year %in% !!year_3) %>%                           # only the 3 baseline years
  select(familie_id, year, famaekvivadisp_13) %>%         # only the columns we use
  collect()                                              # fetch into R

# Calculate 3-year average per person
income_mean <- bef_3yr %>%
  left_join(faik_3yr, by = c("familie_id", "year")) %>%   # link income via household and year
  group_by(pnr) %>%                                      # group to calculate average per person
  summarise(
    mean_income = mean(famaekvivadisp_13, na.rm = TRUE),   # mean disposable income
    .groups = "drop"                                        # release grouping automatically
  )

# Divide into quintiles
income_quintile <- income_mean %>%
  mutate(income_cat = ntile(mean_income, 5))   # ntile(x, 5): 5 groups - 1 = lowest, 5 = highest

Assemble all SES variables onto the cohort

cohort_ses <- cohort %>%
  left_join(cohort_akm %>% select(pnr, occupation_cat), by = "pnr") %>% # attach employment
  left_join(udda_data %>% select(pnr, education_cat), by = "pnr") %>% # attach education
  left_join(income_quintile %>% select(pnr, income_cat), by = "pnr") # attach income quintile

See also

Next steps

You now have SES covariates. Next steps depend on what you still need:

Back to top