
An audit-ready R toolkit for cleaning, geocoding, validating,
reviewing, and exporting messy address and location data. It sits
on top of tidygeocoder:
tidygeocoder fetches coordinates; locatr
decides whether those coordinates are trustworthy and produces a
dashboard- and GIS-ready crosswalk.
locatr is built on top of tidygeocoder,
not as a replacement. tidygeocoder is the geocoding engine: hand it an
address, pick a service, and it returns coordinates and service results.
locatr uses that engine for its main geocoding passes and
adds the workflow layer around it: deciding whether a coordinate can be
trusted, recording how it was produced, and tying it to local geography
for review and reuse.
If you just need coordinates from already-clean addresses via one
service, use tidygeocoder::geo() / geocode()
directly. locatr earns its place when the data is messy,
the matches need to be defensible, and you need local geography
enrichment.
| Concern | tidygeocoder | locatr |
|---|---|---|
| Address -> coordinates | core purpose | delegates main geocoding passes to tidygeocoder |
| Breadth of services / reverse geocoding | broad support | focused workflow |
| Normalise messy address text | assumes prepared input | clean_addresses() |
| Flag PO boxes / placeholders before calls | outside scope | flag_bad_addresses() |
| Reject out-of-region false matches | outside scope | validate_geocodes(), region_bbox() |
| Multi-service cascade | geocode_combine() cascades not-found records |
geocode_records() adds region validation, tier stamps,
and name-match score/type gates |
| Audit trail / review status | service results, but no standard review trail | method, pass, match, validation, review, and override fields |
| Local geography crosswalk | Census geographies possible for Census results | point-in-polygon/key joins to Census or user boundary layers |
| Human review + reusable crosswalk | outside scope | review export, manual overrides, final crosswalk |
| Single-address candidate lookup | geo(limit = ..., full_results = TRUE) can return
candidates |
geocode_address() adds cleaning, score filtering,
context hints, and geography |
| No-code app | outside scope | Shiny app / Hugging Face Space |
In short: tidygeocoder turns an address into geocoder
results; locatr decides whether those results can be
trusted, records why, and ties them to local geography for review and
reuse.
Geocoders are imperfect. Strict services miss real addresses; fuzzy
services can confidently place a bad match far outside the intended
service area. locatr wraps that reality with three
guards:
Every function records an audit trail (geocode_method,
geocode_pass, match_status,
validation_status, review_status,
manual_override_used) so a reviewer can see how each
coordinate was produced. In finished geocoding output,
review_status uses outcome-oriented values such as
auto_accepted, needs_manual_review,
manual_override_applied, and rejected.
Those signals are also distilled into a single
match_confidence (0-1) with a plain-language
confidence_reason, via add_match_confidence().
It is attached automatically by geocode_records(), carried
through the crosswalk, and included in geocode_address()
candidates, so you can sort or threshold on one trust column instead of
reading four.
# from a local clone
devtools::document()
devtools::install()library(locatr)
cleaned <- records %>%
clean_addresses(id = `Facility ID`, address = Address,
city = City, zip = Zip, name = `Facility Name`,
state = "NJ") %>%
flag_bad_addresses()
geocoded <- geocode_records(cleaned, bbox = region_bbox("NJ"))
with_geography <- add_county_muni(geocoded, state = "NJ")
write_geocode_review(with_geography, "manual_review.csv")
final <- with_geography %>%
apply_manual_overrides("manual_review_completed.csv", bbox = region_bbox("NJ")) %>%
export_location_crosswalk("location_crosswalk.csv")At minimum, clean_addresses() needs an address and city.
If no ID is supplied, locatr creates row-number IDs; if no
ZIP is supplied, zip_clean stays NA and the
single-line address omits the trailing ZIP:
cleaned <- records %>%
clean_addresses(address = Address, city = City, state = "NJ") %>%
flag_bad_addresses()Rows with missing ZIP remain geocodable. Rows missing address/city, PO boxes, placeholder addresses, and test records still go to manual review.
To catch data-entry errors the geocoder would silently accept, run
flag_field_conflicts(): it flags a ZIP that cannot belong
to the stated state (a conservative USPS-region check that never
false-flags) and, given a stated county column, a county that disagrees
with the geocoded location_county. It adds
zip_state_conflict, county_conflict, and a
combined field_conflict column without changing any
coordinate.
checked <- flag_field_conflicts(with_geography, stated_county = "County")To check one address interactively - no data frame required - use
geocode_address(). It cleans the text, asks ArcGIS for
candidate matches ranked by confidence (highest first), and attaches
county/municipality when state is supplied, a candidate
state can be inferred, or you provide a geography layer:
geocode_address("1600 Pennsylvania Ave NW")
# ambiguous street-only searches often need a little location context
geocode_address("24 Peachton", state = "NJ")
# keep only high-confidence matches, coordinates only
geocode_address("1 City Hall Sq", city = "Boston", state = "MA",
min_score = 90, geography = FALSE)Set min_score to a confidence threshold (0-100),
max_candidates to cap how many come back, and
geography = FALSE to skip the county/municipality join.
min_score filters candidates ArcGIS already returned; use
city, state, zip, or
bbox when you need to change where ArcGIS searches. In
interactive sessions, geocode_address() prints short
progress messages; set show_progress = FALSE to silence
them.
After a run, use the small audit helpers to understand and compare results:
summarise_geocoding(geocoded)
explain_geocode_result(geocoded, row = 1)
suggest_geography_level("PA")
compare_geocode_runs(previous_geocoded, geocoded)
if (interactive()) {
plot_geocode_review_map(geocoded)
}For users who would rather not write R, the same pipeline is
available as a Shiny app: upload a CSV/Excel/Parquet file, geocode it
with a session cache, and download the geocoded records immediately as
CSV, Excel, or Parquet. If you also need county/locality fields, attach
geography from Census TIGER/Line or an uploaded shapefile first,
optionally append tract/ZCTA/district/school-district GEOIDs, then
download the geography crosswalk. The app also exposes field conflict
flags, cache/provenance summaries, and a downloadable Markdown audit
report with a methods paragraph. The download step lets you remove
columns before exporting. In the app, Unique ID and ZIP are optional:
leave them as (auto) / (none) when your file
only has address and city.
install.packages(c("shiny", "bslib", "DT", "leaflet",
"readr", "readxl", "tibble", "writexl",
"arrow", "tigris"))
run_locatr_app()The app is also published as a Hugging Face Space (Docker): https://huggingface.co/spaces/Prigas89/locatr_reviewer.
The deployment scaffolding lives in the huggingface/
folder on GitHub.
A separate Shiny app takes records from upload through geocoding to
map-based review: upload a file, map the ID and address columns, then
geocode the addresses (clean, flag, cascade, and attach
county/municipality) or use existing coordinates. The flagged records
appear on a map to accept, reject, or relocate, and the app produces a
completed override CSV that feeds straight into
apply_manual_overrides().
run_locatr_review_app()The decision logic is a plain, testable function you can also use headless:
overrides <- build_review_overrides(geocoded, decisions) # accept / reject / relocate
final <- apply_manual_overrides(geocoded, "manual_review_completed.csv")accept confirms the automated coordinate,
relocate substitutes a reviewer coordinate, and
reject leaves the point unplaced - all recorded in the
override table’s manual_note.
geocode_records() runs progressively fuzzier internet
services, retrying only rows that began with geocodable addresses and
are still unplaced after each validation pass:
| Tier | Function | Service | What it catches |
|---|---|---|---|
| 0 | backfill_from_reference() |
trusted table (no network) | records already verified in a prior cycle |
| 1 | geocode_census() |
US Census (structured) | clean, in-range street addresses |
| 2 | geocode_arcgis() |
ArcGIS (address line) | typos, range gaps, fuzzy streets |
| 3 | geocode_by_name() |
ArcGIS (name + city) | named places, campuses, landmarks |
| manual | apply_manual_overrides() |
human | the rest |
Tier 0 is optional and authoritative: pass
geocode_records(cleaned, reference = verified) or call
backfill_from_reference() directly to place records whose
coordinates were checked before. These rows are bbox-validated, stamped
pass_0_reference, and skipped by every later tier.
The input readiness value ready_for_geocoding is
internal to the cascade; after geocode_records() completes,
valid matched rows are marked auto_accepted.
Name lookup is score-gated when the geocoder returns score/type
fields. Precise point-address hits above min_score pass
cleanly; fuzzier POI, locality, or low-score hits keep coordinates for
reviewer context but remain in needs_manual_review. The
Shiny app exposes the name score threshold and the address types that
are allowed to pass without review.
Geocoding hits external services, so a run is only reproducible if
you can replay it without re-querying. Pass a
locatr_cache() to geocode_records() (it also
works on the single tiers and on geocode_address()):
repeated addresses are served from the cache, and a later run reproduces
the same coordinates offline instead of re-hitting Census or ArcGIS.
cache <- locatr_cache("nj_geocode_cache.rds") # persistent; omit the path for memory-only
geocoded <- geocode_records(cleaned, bbox = region_bbox("NJ"), cache = cache)
# a second run reuses the cache - no network for addresses already placed
geocoded_again <- geocode_records(cleaned, bbox = region_bbox("NJ"), cache = cache)
cache_info(cache) # rows, distinct keys, per-method counts, file size, oldest/newestThe cache stores one row per candidate result (plus a no-match
sentinel, so misses are replayed too), keyed by the exact query and
request parameters. Pass refresh = TRUE to force a re-query
and overwrite. Nothing is written to disk unless you give
locatr_cache() a path.
Every geocode_records() result also carries a run
manifest and two per-row provenance columns:
geocode_provenance(geocoded) # run id, timestamp, versions, tiers, cache activity, status counts
geocoded[, c("record_id", "placed_at", "cache_status")]cache_status is one of fresh (geocoded this
run), cached (the cache already held the coordinate before
this run), reference, manual, or
unplaced. placed_at records when a coordinate
actually entered the output - the cached timestamp for cached rows, or
an explicit reference/override timestamp when your data carries one,
rather than the current run time. Both columns flow through
export_location_crosswalk(). The manifest is attached as an
attribute (attr(geocoded, "locatr_run")), so read it with
geocode_provenance() directly off the
geocode_records() result, before further data-frame
operations that might drop attributes.
For a shareable summary, geocode_report() rolls the
manifest and audit columns into counts by review status, placing tier,
and cache status, a match_confidence summary, and an
auto-generated plain-language methods paragraph for a report or paper.
Print it, or write Markdown with file =:
geocode_report(geocoded) # printed summary + methods paragraph
geocode_report(geocoded, file = "run.md") # Markdown reportregion_bbox() ships coarse guard boxes for every US
state and DC (case-insensitive), so validation works out of
the box beyond NJ:
geocoded <- geocode_records(cleaned, bbox = region_bbox("CA"))These presets are deliberately generous sanity boxes, not precise
boundaries. For a tighter or non-state region, pass your own bbox, or
derive one from an sf layer with
bbox_from_sf():
custom_bbox <- c(lat_min = 38.0, lat_max = 39.0, lon_min = -77.5, lon_max = -76.0)
geocoded <- geocode_records(cleaned, bbox = custom_bbox)For stricter validation, pass an sf boundary polygon
with boundary =.
In production, location_locality and
location_county come from a spatial join against an
authoritative NJ municipal boundary layer, not from the geocoder
response or Census reverse-geocoding. The workflow is: load boundaries,
derive a bbox, geocode, validate, convert to sf points,
spatially join to the polygons, and write location_county,
location_locality, geography_match_status.
munis <- sf::st_read("path/to/Municipal_Boundaries_of_NJ.shp") %>%
sf::st_make_valid() %>%
dplyr::transmute(location_county = COUNTY, location_locality = MUN)
bbox <- bbox_from_sf(munis)
geocoded <- geocode_records(cleaned, bbox = bbox)
with_geography <- add_local_geography(
geocoded,
geography_shapes = munis,
county_col = "location_county",
locality_col = "location_locality"
)Or build the packaged default once with
data-raw/local_geography.R, sourced from NJGIN/NJOGIS
Municipal Boundaries of NJ.
add_county_muni() is the shortest path when Census
TIGER/Line boundaries are good enough for your workflow:
final <- add_county_muni(geocoded, state = "PA", geography = "county_subdivision")Under the hood, build_local_geography() pulls Census
boundaries through tigris and standardises them to the
location_county / location_locality schema,
plus stable join fields when the source provides them:
county_code, county_fips,
municipality_code, municipality_geoid,
municipality_name_standard, municipality_type,
and muni_join_key.
areas <- build_local_geography(state = "PA", geography = "county_subdivision")
bbox <- bbox_from_sf(areas)
geocoded <- geocode_records(cleaned, bbox = bbox)
final <- add_local_geography(
geocoded,
geography_shapes = areas,
county_col = "location_county",
locality_col = "location_locality"
)Pick geography for the state: county is
easy everywhere; county_subdivision maps well to
townships/municipalities in NJ, PA, NY, and New England;
place covers incorporated places/CDPs but misses townships
and many unincorporated areas; tract uses tract GEOIDs. For
high-stakes, state-specific reporting where “municipality” has legal
meaning, use an official state GIS layer and pass it directly.
Muni Key is kept as a readable fallback, but production
joins should prefer muni_join_key,
municipality_geoid, or another official code from your
boundary source.
Use add_muni_from_shapes() for point-in-polygon joins
against your own boundary layer, or add_muni_from_key()
when your records and geography share a code column such as ZIP, FIPS,
or GEOID.
For analysis and policy geographies beyond county/municipality -
census tract, block group, ZCTA, congressional and state legislative
districts, or unified school districts -
add_census_geographies() attaches any combination in one
call, each as a <level>_geoid /
<level>_name pair:
enriched <- add_census_geographies(
geocoded, state = "NJ",
levels = c("tract", "congressional_district", "school_district")
)