A dataset-agnostic Shiny app for cleaning messy string variables: typos, capitalization noise, duplicate tokens, and near-duplicate variants.
It serves two kinds of users:
- Spreadsheet-comfortable, not-fluent-in-R reviewers get a point-and-click way to clean values with no code at all, and can download a cleaned copy of their data.
- R users get a fast, repeatable way to generate and update recoding
syntax: let the app find variant clusters and spellcheck flags, then export
a tidy
dplyrscript and re-importable rule CSVs instead of hand-writing and re-editingcase_when()blocks every time the data changes.
Load any CSV or Excel file, pick a text column, and Recode Studio helps you:
- Normalize whole columns first: trim spaces, collapse repeated spaces, and set lower, UPPER or Title Case.
- Browse unique values alphabetically or grouped by string similarity.
-
Cluster with 10 algorithms (Jaro-Winkler, Levenshtein, OSA, longest common substring, Cosine, Jaccard, linear
$O(N)$ Key-Collision Fingerprint, N-gram Fingerprint, Soundex, Metaphone) with built-in pros/cons reference guides. - Match Taxonomies against uploaded reference standard lists (e.g. ISO codes, state names, customer taxonomies).
- Spot likely typos via Hunspell spellcheck and rare-member-in-cluster flags.
-
Build "old value → new value" recode rules, with optional propagation to sibling columns (e.g.
drink1,drink2, ...). - Recategorize across multiple variables with conditional include/exclude rule priorities.
- Preview exactly which cells change, and which rules did nothing, before exporting.
- Export the rule CSVs plus a copyable R script, and optionally a zip with a cleaned copy of the data.
- Re-import previous rule CSVs (even ones edited in Excel) and merge them with new work, so you never start from scratch on a new batch of data.
The app never changes your original data file. It reads a copy. What you download is separate: the rule files, an R script you can run yourself, and, if you ask for it, a cleaned copy of the data as a new file.
New to R or GitHub? This section walks you through the one-time setup. If you already use R and Git, skip to Quick start.
Download and install R from cran.r-project.org. That's the engine the app runs on.
Optional but recommended: an editor that makes running R pleasant, Positron or RStudio. Either lets you open this project and click "Run" instead of typing at a console.
You need a copy of this project on your computer. Two ways:
Option A: Download a ZIP (easiest, no Git needed)
- Go to the project page: https://github.com/amelia-m/recode-studio
- Click the green
< > Codebutton, then Download ZIP. - Unzip it somewhere you'll remember (e.g. your Documents folder). You'll get
a folder called
recode-studio.
The downside: to get later updates you re-download and replace the folder.
Option B: Clone with Git (better for staying up to date)
Git is a tool for copying and updating code. Once it's installed, open a terminal (Command Prompt / PowerShell on Windows, Terminal on Mac) and run:
git clone https://github.com/amelia-m/recode-studio.gitThis creates a recode-studio folder. Later, to pull in updates, run
git pull from inside that folder. No re-downloading.
Opening the project points your R session at the recode-studio folder, so the
commands in the next section "just work". Steps differ slightly by editor.
RStudio:
- File → Open Project…
- Browse into the
recode-studiofolder and pickrecode-studio.Rproj. - The Console is the bottom-left pane. That's where you'll type commands.
- Easiest way to launch the app: open
app.R(click it in the Files pane, bottom-right) and click the Run App button at the top of the editor.
Positron:
- File → Open Folder… and pick the
recode-studiofolder. - Positron starts an R session automatically; the Console tab is at the bottom. That's where you'll type commands. (If no R console appears, click the interpreter picker in the top-right and choose your R version, or open the Command Palette with Ctrl/Cmd+Shift+P → "Interpreter: Select".)
- Easiest way to launch the app: open
app.Rand click the Run App / ▶ button at the top of the editor.
In either editor you can skip the button and just type the commands from Quick start into the Console.
Tip: "from the repo root" means your R session's working directory is the
recode-studiofolder. Opening the project / folder (above) handles that for you. Confirm withgetwd(): it should end in/recode-studio.
This project uses renv for reproducible
dependencies. On a fresh clone:
renv::restore() # installs the pinned package versions from renv.lock
shiny::runApp() # run from the repo rootIf
shiny::runApp()says "there is no package called ..." right after a successful restore, runrenv::repair()and try again. Don't click the editor's "Install" button in the error pane - it installs outsiderenvand hides the real problem.
Without renv, install the direct dependencies manually:
install.packages(c(
"shiny", "bslib", "DT", "dplyr", "stringr", "readr", "tibble", "purrr",
"tidyr", "ggplot2", "htmltools", "stringdist", "igraph", "phonics",
"hunspell", "readxl", "clipr", "jsonlite", "rlang", "wordcloud2",
"digest", "zip"
))
shiny::runApp()Then on the Data tab, click Load bundled example dataset to try it immediately, or upload your own CSV/Excel.
The tabs run left to right in the order you would normally work. The column you are working on is shown in the Recoding: bar above the tabs; you can change it there from any tab, or by clicking a row on the Variable tab. The two always agree.
- Data: upload a CSV/Excel (or load the example). Every column is read as text. The app flags which columns look like free text (excluding dates, numbers, and small choice sets like Yes/No), and tags each as short (a few words / labels) or long (sentences / paragraphs).
- Variable: a sortable, filterable table of columns (with a
kindcolumn: short / long). Click one to work on. - Normalize: tidy how every value in a column is written, before
anything else happens. Tick what to do (trim spaces, collapse repeated
spaces, lower / UPPER / Title Case), read the before / after list, then add
the rule. You can apply the same rule to sibling columns (e.g.
drink1,drink2). Things to know:- Normalizations always run first, then recodes, then recategorization. The Browse values, Clusters and Spellcheck tabs show the normalized values.
- Normalizing cannot be undone: Title Case turns "McKenzie" into "Mckenzie". That is why the preview is shown before you add the rule.
- One rule per column. Adding a column again replaces its rule.
- This is not the same as the "normalize before clustering" options on the Clusters tab. Those only change how values are grouped for display and never change a value; a Normalize rule rewrites the column.
- Browse values: frequency table of unique values; rows with duplicated
adjacent tokens (e.g.
"oat milk oat milk") are highlighted. Long values are shortened to one line; hover to read the whole value. - Clusters: values grouped by similarity. Choose the metric (edit-distance: Jaro-Winkler, OSA, Levenshtein, longest-common-substring; token overlap: cosine, Jaccard q-gram; fingerprints: key collision, n-gram; phonetic: Soundex, Metaphone), the q-gram size, and which normalizations to apply before clustering (lowercase, strip punctuation, collapse whitespace, dedupe adjacent words, ignore word order). Each cluster proposes the most common spelling as canonical; pick a different member or type your own target, then recode the rest to it, optionally across sibling columns. A second sub-tab matches the column against a reference list. (Best for short columns.)
- Spellcheck: Hunspell flags + clickable suggestions, a free-form correction box, selectable discipline dictionaries, and an "add to dictionary" button. (Best for short columns.)
- Text analysis: for long (sentence/paragraph) columns where
clustering and spellcheck don't help: length distribution (chars / words /
sentences), top words and phrases (n-grams) with stopword removal,
interactive
wordcloud2word cloud, and keyword-in-context search. This tab reads the values as they are in your file, before any normalization. - Recodes: an editable grid of all rules with a validator (duplicate keys, rule chains, blanks, invalid enums/regex, stale rules). Rules apply in a single pass: each cell is claimed by the first rule that matches it. Editing a rule keeps it in its place in the list.
- Recategorize: derive a new column from term logic across several existing columns, instead of rewriting values in place. A row matches when any of the rule's columns contains any include term and none contains an exclude term; rules sharing an output column run in priority order, first match wins. The match counts are taken after normalizations and recodes. Exports its own CSV + R script.
- Preview & export:
- Three counts per rule: matched (cells the rule hit), changed (cells whose value actually differs afterwards) and shadowed (cells the rule hit that an earlier rule had already claimed).
- A yellow box listing rules that did less than expected: a normalization or recode that changed nothing, a recode written under a different normalization than the column has now, or a recategorization rule that lost rows because a recode rewrote the words it looks for.
- A before / after diff, and downloads for the recodes CSV, the normalizations CSV and the R script.
- Download cleaned data: a zip with a cleaned copy of your data (see Cleaned data download).
- Import an existing recodes or normalizations CSV to merge.
recodes_master.csv is the canonical, re-importable rule set:
| column | meaning |
|---|---|
rule_id |
stable hash of (variable, match_type, old_value) |
variable |
column the rule targets |
apply_to_siblings |
TRUE → also apply across sibling_pattern |
sibling_pattern |
regex (e.g. ^drink[0-9]+$); NA otherwise |
match_type |
how old_value is matched (see below) |
old_value |
value (or regex pattern) to match |
new_value |
replacement (<NA> literally means "set to NA") |
action |
recode or delete |
notes, author, created_at, updated_at, source_dataset |
provenance |
norm_spec |
the column normalization in force when the rule was made (empty = none) |
NA round-trips as the literal <NA> so the file survives editing in Excel.
Files saved before norm_spec existed still import; their rules are simply
unstamped. Import refuses a file with columns it doesn't recognise, rather than
quietly corrupting your rules.
Match types:
trimmed_ci(default): compare afterstr_squish(tolower(...))." Cappuccino "matches"cappuccino".exact_ci: case-insensitive exact compare.exact: byte-for-byte exact compare.regex:old_valueis an (unanchored) regular expression matched against the value withgrepl; a match replaces the whole cell withnew_value(no partial substitution or backreferences). Anchor with^…$to require a full match. An invalid pattern is flagged by Validate and matches nothing.
Rules are matched against the values after column normalizations. An
exact or regex rule written against the raw values may stop matching once
that column is normalized (an exact rule for "latte" misses a column that
is now "Latte"). norm_spec is how the app notices: Preview & export warns
about any rule whose column has a different normalization now.
normalizations_master.csv holds the Normalize tab's rules, one per column
(or sibling family):
| column | meaning |
|---|---|
norm_id |
stable hash of the target (variable + sibling pattern) |
variable |
column the rule targets |
apply_to_siblings, sibling_pattern |
as in the recode CSV |
ops |
what to do, ;-separated: trim, squish, lowercase, uppercase, titlecase |
notes, author, created_at, updated_at, source_dataset |
provenance |
Blank cells mean "not set". Import it on Preview & export, next to the recode CSV.
The exported recode_<dataset>.R runs the normalization steps first, one
mutate() per normalization rule, then the recodes. The recodes are one
dplyr::case_when() block per sibling-pattern. All match types share the
block, so every arm is tested against the column as the normalizations left
it, and the first matching arm wins:
df <- df |>
mutate(across(matches("^drink[0-9]+$", ignore.case = FALSE), function(.x) str_squish(.x)))
df <- df |>
mutate(across(matches("^drink[0-9]+$", ignore.case = FALSE), function(.x) {
case_when(
str_squish(tolower(.x)) == "capuccino" ~ "cappuccino",
str_squish(tolower(.x)) == "capccino" ~ "cappuccino",
.default = .x
)
}))Run it against a data frame named df to apply the normalizations and
recodes. The script and the app's in-app preview are guaranteed to agree: a
test evaluates the generated script and compares it cell-for-cell against the
app's own results.
On Preview & export, Download cleaned data (zip) gives you one zip file. Your original file is not touched. Inside:
| file | what it is |
|---|---|
<name>_cleaned.csv |
the cleaned copy of your data: normalized, then recoded, then recategorized |
normalizations_master.csv |
the normalization rules that were applied |
recodes_master.csv |
the recode rules that were applied |
recat_<name>.csv |
the recategorization rules that were applied (present even if there are none) |
clean_<name>.R |
one R script that repeats the whole cleaning on your original file |
provenance.txt |
a plain-language record: which file was cleaned (name only, no folder), its size and fingerprint, when, rows and columns before and after, how many rules, and any warnings |
provenance.json |
the same record, for software |
Cleaning never adds or removes rows; if it somehow did, the export stops instead of producing a file.
About the cleaned CSV: text is UTF-8, and a missing value is an empty
cell (no NA text), which spreadsheets, R and Python all read as missing.
The trade-off: in this file an empty text value and a missing value look the
same.
To repeat the cleaning later (for example, to show exactly how a file was produced):
- Put your original data file in the same folder as
clean_<name>.R, or edit theinput_fileline at the top of the script. - Open the script in R, Positron or RStudio and run the whole thing (Source).
- It writes
<name>_cleaned_reproduced.csvnext to the original file. That file matches<name>_cleaned.csvbyte for byte (a test checks this).
The script warns you if the file it reads is not byte-for-byte the one the rules were exported from.
recode-studio/
app.R # entry point
R/ # helpers + modules
string_helpers.R # cluster / validate / apply / codegen, normalizations (pure R)
text_helpers.R # long-text: tokens, n-grams, KWIC (pure R)
recat_helpers.R # recategorization logic + codegen (pure R)
rule_warnings.R # "rules that did nothing" checks (pure R)
export_bundle.R # cleaned data zip + provenance + script (pure R)
data_loader.R # read CSV/Excel + column metadata
rule_proxies.R # in-session rule stores
ui_helpers.R # help text, empty states, table helpers
mod_data_input.R # upload / example loader
mod_variable_picker.R # Variable tab + "Recoding:" bar
mod_normalize.R
mod_value_table.R
mod_cluster_view.R
mod_spellcheck_view.R
mod_text_analysis.R
mod_recode_editor.R
mod_recategorize.R
mod_preview_export.R
mod_import_recodes.R
dictionary/ # spellcheck tiers (seed / custom / user)
inst/extdata/ # bundled example dataset
tests/testthat/ # unit and module tests
testthat::test_dir("tests/testthat")Recode Studio uses Hunspell (en_US) plus
layered supplementary word lists. A token is accepted if any layer
recognises it.
| File | Scope | Git |
|---|---|---|
dictionary/seed_terms.txt |
Domain-neutral seed terms (ships empty) | committed |
dictionary/custom_terms.txt |
Project-shared additions | committed |
dictionary/user_terms.txt |
Personal additions | gitignored |
To add a word permanently for the whole team, append it to custom_terms.txt
and commit. To add a word just for yourself, use user_terms.txt (never
committed).
dictionary/disciplines/ holds domain-specific word lists. medical.txt,
public_health.txt, public_policy.txt, and education.txt ship bundled. Any
.txt file dropped in that folder appears as a selectable option on the
Spellcheck tab.
To add a new discipline via the UI: use the Import discipline dictionary
button on the Spellcheck tab. The file is copied into dictionary/disciplines/
and auto-selected. To share it with the team, git add and commit it.
To add one manually, create dictionary/disciplines/<name>.txt (one
lowercase term per line, # for comments), then restart the app.
- Fork the repo and create a feature branch off
main. - Run
renv::restore()to get the pinned dependencies. - Make your changes. The pure-R core (
R/string_helpers.R,R/text_helpers.R,R/recat_helpers.R,R/rule_warnings.R,R/export_bundle.R,R/data_loader.R) must stay Shiny-free so it remains unit-testable. - Run
testthat::test_dir("tests/testthat"). All tests must pass. - Open a pull request against
mainwith a short description of what changed and why.
Conventions: R + tidyverse, native pipe |>, 2-space indent, no em dashes. See
CLAUDE.md for architecture details and gotchas.
MIT. See LICENSE.