
Crafting joins that fit. Heuristic record linkage and fuzzy joins for R.
joinery provides a tidy, declarative interface for heuristic index-based record linkage, fuzzy joins, and duplicate detection. It uses token-based indexing, flexible text normalization, phonetic encoders, and optional blocking to match imperfect or inconsistent records. The package is inspired by the ideas behind Thorsten Doherr’s searchengine project (see: https://github.com/ThorstenDoherr/searchengine/).
Like good woodworking, the goal is to make the joins clean, without reaching for the mallet.
Most record linkage tools rely on edit distances or probabilistic pairwise comparison after blocking. This works well for clean data and short strings, but often breaks down for messy administrative data where rare tokens matter more than character-level similarity.
joinery follows a different paradigm inspired by Doherr’s SearchEngine:
This approach works especially well when:
It also runs where the data does not fit in memory. The same strategy you prototype on an in-memory table runs unchanged against a DuckDB database, which does the large joins out-of-core, on disk, a piece at a time. Little else in R links and resolves entities this way: in practice it has turned a 51-million-row business directory, a job that would classically call for a cluster, into an overnight run on a laptop.
joinery is not a drop-in replacement for probabilistic linkage engines. It is a complementary tool for transparent, strategy-driven matching where robustness and explainability matter more than calibrated match probabilities.
To place it among the alternatives: reach for fuzzyjoin or zoomerjoin when you need string- or edit-distance joins on short, mostly clean fields; reach for fastLink or reclin2 when you need a Fellegi-Sunter model with calibrated m/u match probabilities. Reach for joinery when records are long and messy, rare tokens carry the signal, and you want to see exactly why a pair matched. A probabilistic scoring strategy is on the roadmap, so that boundary is expected to narrow over time.
install.packages("devtools") # if needed
devtools::install_github("edubruell/joinery")This example uses the package’s built-in sample data. The Getting
started vignette walks the same path step by step and scores the
result against a known answer key
(target_example$actual_link holds the true link for each
copied record).
library(joinery)
# Load example data (shipped as tibbles; joinery also takes data.frames / data.table / DuckDB)
data("base_example")
data("target_example")
# Define a simple first-pass strategy
strat <- search_strategy(
Nachname ~ normalize_text() + word_tokens(min_nchar = 3),
Vorname ~ normalize_text() + word_tokens(min_nchar = 3),
Strasse ~ normalize_street(lang = "de") + word_tokens(min_nchar = 3),
Hausnummer ~ numeric_tokens,
Ort ~ normalize_text(),
block_by = "Kreis",
threshold = 0.8
)
# Inspect the tokenization
inspect_tokens(base_example, "id_base", strat, Vorname)
# Detect and collapse duplicates within the base data
duplicates <- detect_duplicates(base_example, id = "id_base", strategy = strat)
base_dedupped <- deduplicate_table(base_example, duplicates, id = "id_base")
# Cross-table candidate matches
matches <- search_candidates(
base_dedupped,
target_example,
base_id = "id_base",
target_id = "id_target",
strategy = strat
)
# Check the result, then read the residuals
summarise_matches(matches, threshold = 0.8)
extract_unmatched(base_dedupped, "id_base", matches)
extract_unmatched(target_example, "id_target", matches)A cheap, strict exact pass first, then a tolerant fuzzy pass only on what is left:
staged <- multi_stage_search(
base_dedupped,
target_example,
base_id = "id_base",
target_id = "id_target",
strategies = list(
exact = exact_strategy(
Nachname ~ normalize_text() + word_tokens(min_nchar = 3),
Vorname ~ normalize_text() + word_tokens(min_nchar = 3),
Ort ~ normalize_text(),
block_by = "Kreis"
),
fuzzy = strat
)
)The package website carries the full guide. Start with Getting started, which walks the whole path on a pair of built-in tables and scores the result against a known answer key. From there, five articles each take on one problem:
The tutorial also ships as a vignette:
vignette("joinery", package = "joinery")MIT