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:
install.packages("onet2r")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-hereThe 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 |
Main Function Groups
- Current O*NET API data:
onet_search(),onet_occupation(),onet_skills(),onet_tasks(),onet_table(). - Archived O*NET data:
onet_releases(),onet_archive_download(),onet_archive_read(),onet_archive_reference(),onet_panel(),onet_panel_reconcile(). - Resurvey cycles and content change:
onet_resurvey_panel(),onet_condition_on_resurvey(),onet_content_change(). - Wage and employment context:
onet_oews_national(),onet_weight_panel_oews(),onet_oews_bridge(),onet_weight_panel_pums(). - User-measure plumbing:
onet_measure(),onet_task_to_occupation(),onet_measure_aggregate(),onet_measure_sensitivity(),onet_provenance(),onet_coverage(),onet_decompose_change(). - Published exposure adapters:
onet_import_eloundou(),onet_import_felten_aioe().
Related tools
- 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.
