reverse_geocode() answers one question — what is near
this point — but the database behind it holds every civic address in the
country and is open to any query you care to write.
nar_connection() returns a plain DBI connection, so the
whole dplyr vocabulary works against it, evaluated inside DuckDB rather
than in R.
This vignette assumes you have already worked through
vignette("cangeocode"), in particular the
NAR_CACHE_PATH setup and the one-time import.
library(cangeocode)
library(dplyr)
con <- nar_connection()The tables
DBI::dbListTables(con)
#> [1] "Addresses" "Locations" "MunAlias" "PostalMun" "RnfSegments"
#> [6] "RnfStreets" "RqaAddresses" "RqaStreets" "Streets" "nar_metadata"Two of those are NAR’s addresses and locations;
MunAlias, PostalMun and Streets
are gazetteers normalize_address() builds at import. The
rest are companion sources, present only if their own import has been
run and kept beside NAR rather than merged into it:
RqaAddresses and RqaStreets are Quebec’s own
register (rqa_import()), and RnfSegments and
RnfStreets are Statistics Canada’s road network file
(rnf_import()), which carries street centrelines with an
address range on each side rather than addresses.
vignette("geocoding") covers what each is for.
Addresses has one row per civic address
— the street name and number, the mailing address, the municipality, the
postal code, and the point geometry.
tbl(con, "Addresses") |> colnames()
#> [1] "LOC_GUID" "ADDR_GUID" "APT_NO_LABEL"
#> [4] "CIVIC_NO" "CIVIC_NO_SUFFIX" "OFFICIAL_STREET_NAME"
#> [7] "OFFICIAL_STREET_TYPE" "OFFICIAL_STREET_DIR" "PROV_CODE"
#> [10] "CSD_ENG_NAME" "CSD_FRE_NAME" "CSD_TYPE_ENG_CODE"
#> [13] "CSD_TYPE_FRE_CODE" "MAIL_STREET_NAME" "MAIL_STREET_TYPE"
#> [16] "MAIL_STREET_DIR" "MAIL_MUN_NAME" "MAIL_PROV_ABVN"
#> [19] "MAIL_POSTAL_CODE" "BG_DLS_LSD" "BG_DLS_QTR"
#> [22] "BG_DLS_SCTN" "BG_DLS_TWNSHP" "BG_DLS_RNG"
#> [25] "BG_DLS_MRD" "BF_REPPOINT_X" "BF_REPPOINT_Y"
#> [28] "BU_N_CIVIC_ADD" "BU_USE" "x"
#> [31] "y" "geom_source" "geom"Locations has one row per
location, which is what NAR calls a distinct site. Several
addresses can share one (an apartment building is the obvious case). It
carries the census and electoral geography that Addresses
does not: census subdivision code, federal electoral district, economic
region.
tbl(con, "Locations") |> colnames()
#> [1] "LOC_GUID" "CSD_CODE" "FED_CODE"
#> [4] "FED_ENG_NAME" "FED_FRE_NAME" "ER_CODE"
#> [7] "ER_ENG_NAME" "ER_FRE_NAME" "BF_REPPOINT_LATITUDE"
#> [10] "BF_REPPOINT_LONGITUDE" "geom" "x"
#> [13] "y"LOC_GUID joins the two, and ADDR_GUID is
the address key.
Column names are kept exactly as StatCan ships them, in
SCREAMING_SNAKE_CASE, so the NAR User Guide reads directly
onto what you see here.
Filtering
Anything dplyr can express becomes SQL. Nothing is fetched until you collect.
kingsway <- tbl(con, "Addresses") |>
filter(MAIL_MUN_NAME == "VANCOUVER", MAIL_STREET_NAME == "KINGSWAY")
kingsway |> count() |> collect()
#> # A tibble: 1 × 1
#> n
#> <dbl>
#> 1 2555Aggregation runs in the database too, over all 17 million rows, and stays fast because DuckDB scans columns rather than rows.
tbl(con, "Addresses") |>
count(PROV_CODE, sort = TRUE) |>
head(5) |>
collect()
#> # A tibble: 5 × 2
#> PROV_CODE n
#> <chr> <dbl>
#> 1 35 6246543
#> 2 24 4568811
#> 3 59 2371842
#> 4 48 1900424
#> 5 46 572158You can inspect the generated SQL at any point with
show_query(), which is often the quickest way to see
whether a verb translated the way you expected.
kingsway |> count() |> show_query()
#> <SQL>
#> SELECT COUNT(*) AS n
#> FROM Addresses
#> WHERE (MAIL_MUN_NAME = 'VANCOUVER') AND (MAIL_STREET_NAME = 'KINGSWAY')Joining to Locations
Join when you need the geography that only Locations
carries.
tbl(con, "Addresses") |>
filter(MAIL_MUN_NAME == "VANCOUVER", MAIL_STREET_NAME == "KINGSWAY") |>
left_join(tbl(con, "Locations") |> select(LOC_GUID, FED_ENG_NAME, ER_ENG_NAME),
by = "LOC_GUID") |>
count(FED_ENG_NAME, sort = TRUE) |>
collect()
#> # A tibble: 3 × 2
#> FED_ENG_NAME n
#> <chr> <dbl>
#> 1 Vancouver Kingsway 2167
#> 2 Vancouver East 214
#> 3 Vancouver Fraserview—South Burnaby 174Getting the geometry out: collect_nar()
collect() brings back a plain data frame, and the
geom column arrives as something unusable — DuckDB geometry
has no R representation. Use collect_nar() instead. It
transfers the geometry as WKB, reads the storage CRS from the database
rather than assuming one, and hands you an sf object.
addresses <- tbl(con, "Addresses") |>
filter(MAIL_MUN_NAME == "VANCOUVER", MAIL_STREET_NAME == "KINGSWAY") |>
select(ADDR_GUID, CIVIC_NO, MAIL_STREET_NAME, MAIL_POSTAL_CODE,
geom_source, geom) |>
collect_nar()
addresses
#> Simple feature collection with 2555 features and 5 fields
#> Geometry type: POINT
#> Dimension: XY
#> Bounding box: xmin: 4019124 ymin: 1999098 xmax: 4022369 ymax: 2004787
#> Projected CRS: NAD83 / Statistics Canada Lambert
#> # A tibble: 2,555 × 6
#> ADDR_GUID CIVIC_NO MAIL_STREET_NAME MAIL_POSTAL_CODE geom_source
#> <chr> <dbl> <chr> <chr> <chr>
#> 1 c5fd5fb6-8011-4e7f-8d… 1929 KINGSWAY V5N2T1 building
#> 2 d96ea3dc-1269-4dc4-86… 1929 KINGSWAY V5N2T1 building
#> 3 28afd7a1-c85b-40cd-b4… 1155 KINGSWAY V5V3C9 building
#> 4 9821cc62-94a7-4515-a3… 1155 KINGSWAY V5V3C9 building
#> 5 13255ff9-72fe-4753-9b… 1163 KINGSWAY V5V3C9 building
#> 6 7849e06b-8c1d-4b0a-93… 1282 KINGSWAY V5V3E1 building
#> 7 87a821b4-e0a1-4377-b6… 1290 KINGSWAY V5V3E1 building
#> 8 842f7475-8db0-4851-b8… 1350 KINGSWAY V5V3E4 building
#> 9 baf08b63-2b9b-483d-bf… 1350 KINGSWAY V5V3E4 building
#> 10 89ef9a3c-206a-48df-af… 1350 KINGSWAY V5V3E4 building
#> # ℹ 2,545 more rows
#> # ℹ 1 more variable: geom <POINT [m]>
sf::st_crs(addresses)$input
#> [1] "EPSG:3347"EPSG:3347 is Statistics Canada Lambert, a projected CRS in metres —
which is why distances from reverse_geocode() need no
conversion.
Pass crs to reproject on the way out. This happens
inside DuckDB, and the argument accepts a bare EPSG number, an authority
string, or an sf::st_crs() object.
tbl(con, "Addresses") |>
filter(MAIL_MUN_NAME == "VANCOUVER", MAIL_STREET_NAME == "KINGSWAY") |>
select(ADDR_GUID, geom) |>
head(3) |>
collect_nar(crs = 4326)
#> Simple feature collection with 3 features and 1 field
#> Geometry type: POINT
#> Dimension: XY
#> Bounding box: xmin: -123.0806 ymin: 49.24591 xmax: -123.0661 ymax: 49.25274
#> Geodetic CRS: WGS 84
#> # A tibble: 3 × 2
#> ADDR_GUID geom
#> <chr> <POINT [°]>
#> 1 c5fd5fb6-8011-4e7f-8d72-dcc7d4f569e0 (-123.0661 49.24591)
#> 2 d96ea3dc-1269-4dc4-8643-9c85bd6c09ba (-123.0661 49.24591)
#> 3 28afd7a1-c85b-40cd-b4c4-854d07246870 (-123.0806 49.25274)collect_nar() needs a lazy table still attached to its
connection — it looks the connection up to read the CRS. Handed a data
frame you have already collected, it says so rather than failing
somewhere deep in dbplyr.
already_collected <- tbl(con, "Addresses") |> head(1) |> collect()
collect_nar(already_collected)
#> Error in `collect_nar()`:
#> ! collect_nar() needs a lazy table backed by a NAR database connection; got an object that has already been collected. Call it directly on the result of dplyr::tbl(con, ...), and use sf::st_transform() to reproject afterwards.Addresses with no geometry
About 65,000 addresses have neither a building nor a blockface point.
collect_nar() maps their NULL geometry to an
empty point rather than dropping the row, so counts
stay honest. Filter them out with sf::st_is_empty() before
anything that expects real coordinates.
tbl(con, "Addresses") |>
filter(is.na(geom_source)) |>
select(ADDR_GUID, MAIL_MUN_NAME, geom) |>
head(3) |>
collect_nar() |>
mutate(empty = sf::st_is_empty(geom))
#> Simple feature collection with 3 features and 3 fields (with 3 geometries empty)
#> Geometry type: POINT
#> Dimension: XY
#> Bounding box: xmin: NA ymin: NA xmax: NA ymax: NA
#> Projected CRS: NAD83 / Statistics Canada Lambert
#> # A tibble: 3 × 4
#> ADDR_GUID MAIL_MUN_NAME geom empty
#> * <chr> <chr> <POINT [m]> <lgl>
#> 1 a1529ed5-d530-4c43-8912-79b6ab3… CORNER BROOK EMPTY TRUE
#> 2 fbae7224-13cd-4461-abd3-85051bd… CORNER BROOK EMPTY TRUE
#> 3 7f72ddd1-ce97-4692-a69a-7f2d3a7… CORNER BROOK EMPTY TRUEA note on internal columns
Addresses and Locations both carry
x and y columns. They duplicate
geom and exist only to let DuckDB skip whole blocks of rows
during a radius query — the single largest performance win in the
package. They are dropped at the collection boundary by
both collect_nar() and reverse_geocode(),
because a reprojected result would leave them silently stale. Treat them
as package internals rather than data.
Read-only connections
The connection is opened read-only, so the database cannot be modified by accident and several R sessions can query the same file at once. Writing to it is an error:
DBI::dbExecute(con, "CREATE TABLE scratch (id INTEGER)")
#> Error in `duckdb_result()`:
#> ! Invalid Error: Invalid Input Error: Cannot execute statement of type "CREATE" on database "2026-06" which is attached in read-only mode!
#> ℹ Context: rapi_execute
#> ℹ Error type: INVALIDMaterialise anything you want to keep in R, or in a database of your own.
DBI::dbDisconnect(con)