Skip to contents

onet2r is an R package for working with O*NET Web Services, archived O*NET database releases, O*NET-SOC taxonomy bridges, BLS OEWS wage and employment context, and reproducible user-supplied occupation measures.

The package is built for analysts who need tidy current-release O*NET data and for researchers who need to ask careful historical questions. O*NET was not designed as a longitudinal panel, so onet2r makes the plumbing visible: archive versions, taxonomy seams, source dates, employment weights, coverage, and provenance.

For a worked analysis of common activities, TF-IDF-style rarity, employment weighting, skills, and the differences lost through aggregation, see What Makes Occupations Different?. It combines a reproducible public-archive activity snapshot, an information-loss audit of broader labels, equal-code-prefix-group weighting sensitivity, a size-preserving randomized partition reference, and clearly labeled formula examples. Figures show the findings and their measurement limits.

The companion What Software Lists Tell Us About Occupations examines listed software examples, their occupational neighbors, and the detail lost when examples are grouped. It keeps documented links separate from claims about workers’ actual software use.

Installation

You can install onet2r from CRAN after release:

You can install the development version from GitHub:

# install.packages("pak")
pak::pak("farach/onet2r")

Authentication

Live O*NET API calls require a free API key from https://services.onetcenter.org/developer/. Store it in .Renviron:

ONET_API_KEY=your-api-key-here

The archive, OEWS, and measure examples below run without a key.

Read Archived Releases

archive_base <- system.file("extdata", "onet-mini", package = "onet2r")
archives <- c(
  `24.3` = file.path(archive_base, "db_24_3_text"),
  `25.1` = file.path(archive_base, "db_25_1_text")
)
release_dates <- c(`24.3` = "2020-08-01", `25.1` = "2020-11-01")

abilities <- onet_panel(
  "Abilities",
  versions = c("24.3", "25.1"),
  scale = "IM",
  archives = archives,
  release_dates = release_dates
)

abilities |>
  select(release_version, onet_soc_code, soc_code, element_name, data_value) |>
  head(6) |>
  knitr::kable(digits = 3, align = "l")
release_version onet_soc_code soc_code element_name data_value
24.3 15-1132.00 15-1132 Oral Comprehension 4.20
24.3 15-1132.00 15-1132 Problem Sensitivity 4.40
24.3 29-1141.00 29-1141 Oral Comprehension 4.60
25.1 15-1252.00 15-1252 Oral Comprehension 4.48
25.1 15-1253.00 15-1253 Oral Comprehension 4.30
25.1 29-1141.00 29-1141 Oral Comprehension 4.66

O*NET-SOC remains at the native 8-digit detail level in onet_soc_code. The 6-digit soc_code exists for labor-market joins.

Reconcile Historical Change

bridge_2010_2019 <- tibble::tibble(
  from_vintage = "2010",
  to_vintage = "2019",
  from_onet_soc_code = c("15-1132.00", "15-1132.00", "29-1141.00"),
  to_onet_soc_code = c("15-1252.00", "15-1253.00", "29-1141.00"),
  map_type = c("split", "split", "one_to_one"),
  crosswalk_weight = c(0.5, 0.5, 1)
)

changes <- onet_panel_reconcile(
  abilities,
  bridge = bridge_2010_2019
)

changes |>
  select(
    from_onet_soc_code,
    to_onet_soc_code,
    element_name,
    from_value,
    to_value,
    value_change,
    change_type,
    safely_comparable
  ) |>
  arrange(desc(abs(value_change))) |>
  head(8) |>
  knitr::kable(digits = 3, align = "l")
from_onet_soc_code to_onet_soc_code element_name from_value to_value value_change change_type safely_comparable
15-1132.00 15-1252.00 Oral Comprehension 4.2 4.48 0.28 transition_data FALSE
15-1132.00 15-1253.00 Oral Comprehension 4.2 4.30 0.10 transition_data FALSE
29-1141.00 29-1141.00 Oral Comprehension 4.6 4.66 0.06 real_update TRUE
15-1132.00 15-1252.00 Problem Sensitivity 4.4 NA NA dropped FALSE
15-1132.00 15-1253.00 Problem Sensitivity 4.4 NA NA dropped FALSE
changes |>
  count(change_type, name = "rows") |>
  arrange(desc(rows)) |>
  knitr::kable(digits = 3, align = "l")
change_type rows
transition_data 2
dropped 2
real_update 1

Rows marked as transition data, suppressed estimates, new content, or dropped content are visible in change_type. They are not counted as safely comparable updates.

Bring Your Own Measure

The package does not ship an AI exposure score or any other substantive measure. You supply a score, and onet2r validates keys, performs mechanical rollups, adds weights, and records provenance.

tasks <- onet_archive_read(
  "30.3",
  "Task Statements",
  path = file.path(archive_base, "db_30_3_text"),
  release_date = "2026-05-01"
)
task_ratings <- onet_archive_read(
  "30.3",
  "Task Ratings",
  path = file.path(archive_base, "db_30_3_text"),
  release_date = "2026-05-01"
)

task_scores <- tibble::tibble(
  task_id = c("1001", "1002", "2001"),
  score = c(0.80, 0.40, 0.20)
)

measure <- onet_measure(
  task_scores,
  key = "task_id",
  score = "score",
  key_type = "task",
  universe = tasks$task_id,
  measure_id = "stylized_task_score",
  release_version = "30.3"
)

onet_coverage(measure) |>
  knitr::kable(digits = 3, align = "l")
key_type n_input n_universe n_matched coverage_share employment_coverage_share
task 3 3 3 1 NA
occupation_scores <- onet_task_to_occupation(
  measure,
  task_ratings = task_ratings,
  task_metadata = tasks,
  include_supplemental = FALSE
)

occupation_scores |>
  select(onet_soc_code, soc_code, n_tasks, total_task_weight, measure_score) |>
  knitr::kable(digits = 3, align = "l")
onet_soc_code soc_code n_tasks total_task_weight measure_score
15-1252.00 15-1252 1 95 0.8
29-1141.00 29-1141 1 98 0.2

Add Employment Weights

If BLS rejects an automated OEWS ZIP download, onet_oews_national() can use a matching ZIP that you downloaded in your browser. Leave the ZIP in your Downloads folder, or set options(onet2r.oews_download_dir = "path/to/downloads"). Passing path is always supported, keeps examples offline, and remains the fallback if BLS changes OEWS URLs or file names.

oews_sample <- onet_oews_national(
  path = system.file("extdata", "oews-national-sample.csv", package = "onet2r")
)

weights <- onet_weight_panel_oews(oews_sample, year = 2024)
#> Dropped 2 OEWS aggregate rows; keeping "detailed" occupations.

weights |>
  knitr::kable(digits = 3, align = "l")
reference_soc_code year employment weight_share source source_taxonomy reference_taxonomy
11-1011 2024 211230 0.040 OEWS 2018 SOC 2018 SOC
15-1252 2024 1847900 0.353 OEWS 2018 SOC 2018 SOC
29-1141 2024 3175400 0.607 OEWS 2018 SOC 2018 SOC
aggregate <- onet_measure_aggregate(
  occupation_scores,
  weights
)

aggregate |>
  select(-coverage, -provenance) |>
  knitr::kable(digits = 3, align = "l")
measure_id aggregate total_employment covered_employment employment_coverage_share n_occupations n_reference_soc
stylized_task_score 0.421 5234530 5023300 0.96 2 2

onet_provenance(aggregate) |>
  knitr::kable(digits = 3, align = "l")
measure_id measure_release weight_source weight_year source_taxonomy reference_taxonomy bridge_used crosswalk_path
stylized_task_score 30.3 OEWS 2024 2018 SOC 2018 SOC FALSE 2018 SOC -> 2018 SOC

OEWS publishes a few occupations only as combined codes, such as 31-1120 Home Health and Personal Care Aides. Build a bridge with onet_oews_bridge(occupation_scores, weights) and pass it as bridge to onet_measure_aggregate() so O*NET occupations inside those codes count toward coverage.

Stress Test the Plumbing

pums_weights <- onet_weight_panel_pums(
  tibble::tibble(
    SOCP = c("151252", "151252", "291141", "291141"),
    PWGTP = c(80, 120, 200, 80)
  ),
  year = 2022
)

sensitivity <- onet_measure_sensitivity(
  measure,
  weight_panels = list(oews = weights, pums = pums_weights),
  task_ratings = task_ratings,
  task_metadata = tasks,
  include_supplemental = c(FALSE, TRUE)
)

sensitivity |>
  select(
    scenario,
    aggregate,
    employment_coverage_share,
    movement,
    movement_percent
  ) |>
  knitr::kable(digits = 3, align = "l")
scenario aggregate employment_coverage_share movement movement_percent
RT_core / task_release / oews / no_bridge 0.421 0.96 0.000 0.000
RT_core / task_release / pums / no_bridge 0.450 1.00 0.029 0.070
RT_core_plus_supplemental / task_release / oews / no_bridge 0.373 0.96 -0.047 -0.112
RT_core_plus_supplemental / task_release / pums / no_bridge 0.396 1.00 -0.024 -0.058

Decompose Aggregate Change

from_scores <- tibble::tibble(
  reference_soc_code = c("15-1252", "29-1141"),
  measure_score = c(1.0, 2.0),
  safely_comparable = c(TRUE, FALSE)
)
to_scores <- tibble::tibble(
  reference_soc_code = c("15-1252", "29-1141"),
  measure_score = c(2.0, 2.5),
  safely_comparable = c(TRUE, FALSE)
)
from_weights <- tibble::tibble(
  reference_soc_code = c("15-1252", "29-1141"),
  employment = c(100, 100)
)
to_weights <- tibble::tibble(
  reference_soc_code = c("15-1252", "29-1141"),
  employment = c(150, 50)
)

decomp <- onet_decompose_change(from_scores, to_scores, from_weights, to_weights)

decomp |>
  select(component, value) |>
  knitr::kable(digits = 3, align = "l")
component value
within 0.500
between -0.250
interaction 0.125
unclassifiable 0.250
total_change 0.625

onet_coverage(decomp) |>
  knitr::kable(digits = 3, align = "l")
n_common n_safely_comparable leakage
2 1 0
  • ONETr (CRAN) wraps the retired O*NET Web Services v1 XML API with username/password auth; it predates the v2 API this package targets.
  • blscrapeR / blsAPI wrap the BLS time-series API, which does not serve the OEWS research flat files onet2r ingests; they are complementary, not overlapping.
  • tidycensus / ipumsr are the right tools for fetching ACS PUMS microdata; onet2r deliberately has no Census fetcher and consumes their output for employment weights.
  • No other R or Python tool assembles longitudinal panels from historical O*NET database releases; that machinery is unique to onet2r.