Geographic distribution of healthcare facilities across Canada
Supply, access and inequity: Open Database of Healthcare Facilities linked to the 2021 Census
Objective
To describe how healthcare facilities are distributed across Canada, and to identify which populations have the weakest geographic access to hospitals.
Describe facility supply per 100,000 population by province and facility type.
Measure population-weighted geographic access to hospitals.
Quantify inequality in the distribution of facilities.
Test which community characteristics are associated with facility supply.
Identify spatial clusters of poor hospital access.
Assess residential care supply relative to the older population, and document data completeness.
Data and setup
Sources. The Open Database of Healthcare Facilities (ODHF, version 1.1, Statistics Canada) is downloaded automatically. Population, age structure, household income and community boundaries for 2021 census subdivisions (CSDs) come from the CensusMapper API through the cancensus package.
Before the first run, you need a free CensusMapper API key (create an account at censusmapper.ca, then open the API tab). Save it once on your computer by running this in R, then restart R:
The first run takes several minutes. Results are cached in the _cache folder next to this file. Delete _cache/analysis_inputs.rds to force a full rebuild.
Code
# ---------- helper: find or download the ODHF csv ----------# If several versions are present, the highest version number is used.pick_latest <-function(paths) { nm <-basename(paths) v <-suppressWarnings(as.numeric(sub(".*v([0-9]+\\.[0-9]+).*", "\\1", nm))) v[!grepl("v[0-9]+\\.[0-9]+", nm) |is.na(v)] <-0 paths[order(v, decreasing =TRUE)][1]}get_odhf <-function() { local <-list.files(".", pattern ="^odhf.*\\.csv$", ignore.case =TRUE, full.names =TRUE)if (length(local)) return(pick_latest(local)) zipf <-"_cache/ODHF.zip"if (!file.exists(zipf))download.file("https://www150.statcan.gc.ca/n1/pub/13-26-0001/2020001/ODHF.zip", zipf, mode ="wb", quiet =TRUE)unzip(zipf, exdir ="_cache/odhf", junkpaths =TRUE) csv <-list.files("_cache/odhf", pattern ="\\.csv$", ignore.case =TRUE, full.names =TRUE) pref <- csv[grepl("odhf", basename(csv), ignore.case =TRUE)]pick_latest(if (length(pref)) pref else csv)}clean_csd_id <-function(x) { x <-trimws(as.character(x)) x[x %in%c("", "NA", "NaN")] <-NA_character_ x <-sub("\\.0+$", "", x) x[!is.na(x) &!grepl("^[0-9]{7}$", x)] <-NA_character_ x}build_inputs <-function(odhf_path) {# ---- API key and cache for cancensus ---- key <-Sys.getenv("CM_API_KEY")if (!nzchar(key)) key <-getOption("cancensus.api_key", "")if (!nzchar(key))stop("No CensusMapper API key found. Get a free key at censusmapper.ca, then run ","cancensus::set_cancensus_api_key('YOUR_KEY', install = TRUE) and restart R.")options(cancensus.api_key = key)dir.create("_cache/cancensus", showWarnings =FALSE, recursive =TRUE)options(cancensus.cache_path =normalizePath("_cache/cancensus"))# ---- 1. ODHF ---- odhf <-read_csv(odhf_path, col_types =cols(.default ="c"), show_col_types =FALSE)names(odhf) <-tolower(names(odhf)) need <-c("facility_name", "odhf_facility_type", "provider", "province","csduid", "latitude", "longitude")stopifnot(all(need %in%names(odhf))) odhf <- odhf |>mutate(latitude =suppressWarnings(as.numeric(latitude)),longitude =suppressWarnings(as.numeric(longitude)),csduid =clean_csd_id(csduid),province =toupper(province),odhf_facility_type =str_squish(odhf_facility_type),row_id =row_number(),has_coords =is.finite(latitude) &is.finite(longitude))# ---- 2. Census 2021 by community ---- pop <-get_census(dataset ="CA21", regions =list(C ="01"), level ="CSD",vectors =c(pop_65plus ="v_CA21_251", median_income ="v_CA21_906"),use_cache =TRUE, quiet =TRUE) |>transmute(csduid =as.character(GeoUID),pruid =as.character(PR_UID),name =`Region Name`,population = Population,area_km2 =`Area (sq km)`, pop_65plus, median_income,density = population / area_km2,share_65 = pop_65plus / population)# ---- 3. Community boundaries ---- csd_sf <-get_census(dataset ="CA21", regions =list(C ="01"), level ="CSD",geo_format ="sf", use_cache =TRUE, quiet =TRUE) |>transmute(csduid_geo =as.character(GeoUID)) |>st_transform(3347) |>st_make_valid()# ---- 4. Place each facility in a community using its coordinates ---- with_xy <- odhf |>filter(has_coords) |>st_as_sf(coords =c("longitude", "latitude"), crs =4326, remove =FALSE) |>st_transform(3347) |>st_join(csd_sf, join = st_intersects) |>st_drop_geometry() |>distinct(row_id, .keep_all =TRUE) odhf2 <-bind_rows(with_xy, filter(odhf, !has_coords)) |>mutate(csduid_final =coalesce(csduid_geo, csduid))# ---- 5. Distance from each community to its nearest hospital ---- hosp <- odhf2 |>filter(odhf_facility_type =="Hospitals", has_coords) |>st_as_sf(coords =c("longitude", "latitude"), crs =4326, remove =FALSE) |>st_transform(3347) cent <-st_sf(csduid = csd_sf$csduid_geo,geometry =st_point_on_surface(st_geometry(csd_sf))) nearest <-st_nearest_feature(cent, hosp) cent$dist_km <-as.numeric(st_distance(cent, hosp[nearest, ], by_element =TRUE)) /1000 dat <- cent |>st_drop_geometry() |>left_join(pop, by ="csduid") |>filter(!is.na(population), population >0)list(odhf2 = odhf2, pop = pop, csd_sf = csd_sf, dat = dat)}odhf_path <-get_odhf()cache_file <-file.path("_cache", paste0("inputs_", tools::file_path_sans_ext(basename(odhf_path)), ".rds"))if (file.exists(cache_file)) { inp <-readRDS(cache_file)} else { inp <-build_inputs(odhf_path)saveRDS(inp, cache_file)}odhf2 <- inp$odhf2; pop <- inp$pop; csd_sf <- inp$csd_sf; dat <- inp$datprov_lab <-c("10"="NL","11"="PE","12"="NS","13"="NB","24"="QC","35"="ON","46"="MB","47"="SK","48"="AB","59"="BC","60"="YT","61"="NT","62"="NU")# ---- community-level analysis table ----counts <- odhf2 |>filter(!is.na(csduid_final)) |>mutate(type =case_when( odhf_facility_type =="Hospitals"~"hospitals", odhf_facility_type =="Ambulatory health care services"~"ambulatory", odhf_facility_type =="Nursing and residential care facilities"~"residential",TRUE~"other")) |>count(csduid_final, type) |>pivot_wider(names_from = type, values_from = n, names_prefix ="n_", values_fill =0)for (v inc("n_hospitals", "n_ambulatory", "n_residential", "n_other"))if (!v %in%names(counts)) counts[[v]] <-0d <- dat |>left_join(counts, by =c("csduid"="csduid_final")) |>mutate(across(starts_with("n_"), ~replace_na(.x, 0)),n_total = n_hospitals + n_ambulatory + n_residential + n_other,prov = prov_lab[pruid],is_iri =grepl("\\(IRI\\)", name),size_class =cut(population, c(0, 1000, 10000, 100000, Inf),labels =c("<1,000", "1,000-9,999", "10,000-99,999", "100,000+"),right =FALSE))wmedian <-function(x, w) { o <-order(x); x <- x[o]; w <- w[o] x[which(cumsum(w) >=sum(w) /2)[1]] }acc <-function(g) g |>summarise(communities =n(), pop_total =sum(population),median_km =round(wmedian(dist_km, population), 1),pct_over_25km =round(100*sum(population[dist_km >25]) /sum(population), 1),pct_over_50km =round(100*sum(population[dist_km >50]) /sum(population), 1),pct_over_100km =round(100*sum(population[dist_km >100]) /sum(population), 1))nat <-acc(d)
The ODHF file used was odhf_v1.csv. The analysis covers 9,039 listed facilities and 4,830 census subdivisions with a non-zero population (36,991,981 people).
Data completeness
ODHF is compiled from provincial and other sources that do not report identically, so counts are not strictly comparable across provinces. This table is the basis for reading every province comparison below.
Code
t_comp <- odhf2 |>group_by(province) |>summarise(facilities =n(),data_sources =n_distinct(provider),hospitals =sum(odhf_facility_type =="Hospitals", na.rm =TRUE),hospitals_with_coordinates =sum(odhf_facility_type =="Hospitals"& has_coords, na.rm =TRUE),ambulatory =sum(odhf_facility_type =="Ambulatory health care services", na.rm =TRUE),residential =sum(odhf_facility_type =="Nursing and residential care facilities", na.rm =TRUE),type_missing =sum(is.na(odhf_facility_type)),pct_no_coordinates =round(100*mean(!has_coords), 1),pct_no_community_code =round(100*mean(is.na(csduid_final)), 1)) |>save_tbl("table_completeness")kable(t_comp)
Table 1: Data completeness by province (ODHF v1.1)
province
facilities
data_sources
hospitals
hospitals_with_coordinates
ambulatory
residential
type_missing
pct_no_coordinates
pct_no_community_code
AB
331
4
207
144
13
111
0
53.5
54.7
BC
1492
3
158
144
226
1107
1
13.7
13.7
MB
214
3
131
104
41
41
1
30.4
30.4
NB
62
4
58
53
2
2
0
8.1
9.7
NL
96
3
44
35
28
23
1
40.6
41.7
NS
195
3
55
46
5
135
0
5.1
5.1
NT
7
3
5
4
2
0
0
14.3
14.3
NU
3
2
3
3
0
0
0
0.0
0.0
ON
3733
3
520
400
1104
2104
5
15.4
15.6
PE
35
3
12
11
12
11
0
2.9
2.9
QC
2359
5
500
344
1114
742
3
12.5
12.6
SK
473
3
143
121
167
161
2
25.2
25.2
YT
39
2
17
13
13
7
0
15.4
15.4
Analysis 1: Facility supply per 100,000 population
Figure 1: Facilities per 100,000 population by province and type
Analysis 2: Geographic access to hospitals
Distance is measured in a straight line from a point inside each community to the nearest listed hospital. Percentages are weighted by population, so they describe people rather than communities. Nationally, 1.3% of the population lives more than 50 km from a hospital (median distance 3.9 km).
Code
t2 <- d |>group_by(prov) |>acc() |>arrange(desc(pct_over_50km)) |>save_tbl("table2_access_by_province")kable(t2)
Table 3: Population-weighted access to hospitals by province or territory
prov
communities
pop_total
median_km
pct_over_25km
pct_over_50km
pct_over_100km
NU
26
36858
250.3
67.0
67.0
64.6
NT
37
41070
2.7
34.0
33.8
28.2
YT
31
40232
1.4
15.1
6.0
1.4
NL
365
510550
8.8
22.0
5.2
1.3
SK
899
1132505
1.4
15.6
4.1
0.9
MB
221
1342153
2.1
9.1
3.2
1.3
AB
406
4262635
5.7
7.9
3.0
0.1
NB
265
775610
8.6
18.6
1.7
0.0
BC
643
5000879
4.2
5.0
1.5
0.3
QC
1193
8501833
3.8
5.7
0.7
0.3
ON
554
14223942
3.3
1.8
0.4
0.2
NS
92
969383
18.1
8.5
0.0
0.0
PE
98
154331
5.1
5.7
0.0
0.0
Code
t3 <- d |>group_by(size_class) |>acc() |>save_tbl("table3_access_by_size")kable(t3)
Table 4: Access to hospitals by community size
size_class
communities
pop_total
median_km
pct_over_25km
pct_over_50km
pct_over_100km
<1,000
2721
993216
24.8
49.3
14.7
5.7
1,000-9,999
1680
5227370
14.5
22.3
4.9
1.5
10,000-99,999
373
10134407
6.2
3.6
0.9
0.0
100,000+
56
20636988
2.6
0.0
0.0
0.0
Code
t4 <- d |>group_by(is_iri) |>acc() |>save_tbl("table4_access_reserves")kable(t4)
Table 5: Access to hospitals: First Nations reserves (IRI) versus other communities
is_iri
communities
pop_total
median_km
pct_over_25km
pct_over_50km
pct_over_100km
FALSE
4054
36641351
3.8
5.1
1.1
0.2
TRUE
776
350630
21.9
47.0
27.1
12.5
Code
csd_sf |>left_join(select(d, csduid, dist_km), by =c("csduid_geo"="csduid")) |>ggplot() +geom_sf(aes(fill = dist_km), color =NA) +scale_fill_viridis_c(trans ="sqrt", na.value ="grey90", name ="km") +theme_void()
Figure 2: Straight-line distance from each community to the nearest hospital (km, square-root scale)
Code
d |>group_by(prov) |>arrange(dist_km) |>mutate(cum_pop =cumsum(population) /sum(population)) |>ggplot(aes(dist_km +1, cum_pop, colour = prov)) +geom_step() +scale_x_log10() +labs(x ="Distance to nearest hospital, km (log scale)",y ="Cumulative share of population", colour =NULL) +theme_minimal()
Figure 3: Cumulative share of population by distance to the nearest hospital
Analysis 3: Inequality in the distribution of facilities
Communities are ranked by facilities per person. The Gini coefficient measures how unevenly facilities are spread relative to population (0 is perfectly even, 1 is maximal inequality). Because hospitals are rare and sit in larger places, high values are expected for them.
Code
lorenz <-function(n, pop) { o <-order(n / pop)tibble(p =c(0, cumsum(pop[o]) /sum(pop)), f =c(0, cumsum(n[o]) /sum(n))) }gini <-function(n, pop) { if (sum(n) ==0) return(NA_real_) l <-lorenz(n, pop); 1-sum(diff(l$p) * (l$f[-1] + l$f[-nrow(l)])) }t5 <- d |>group_by(prov) |>summarise(across(c(n_hospitals, n_ambulatory, n_residential, n_total),~round(gini(.x, population), 3), .names ="gini_{.col}")) |>save_tbl("table5_gini")kable(t5)
Table 6: Gini coefficient of facility distribution across communities, by province
prov
gini_n_hospitals
gini_n_ambulatory
gini_n_residential
gini_n_total
AB
0.803
0.996
0.874
0.800
BC
0.737
0.405
0.356
0.336
MB
0.824
0.927
0.624
0.811
NB
0.811
0.940
0.740
0.786
NL
0.843
0.954
0.784
0.811
NS
0.618
0.686
0.458
0.448
NT
0.522
0.932
NA
0.550
NU
0.773
NA
NA
0.773
ON
0.572
0.479
0.364
0.381
PE
0.774
0.836
0.719
0.759
QC
0.640
0.618
0.582
0.514
SK
0.811
0.766
0.805
0.691
YT
0.769
0.283
0.369
0.399
Code
map_dfr(c("n_hospitals", "n_ambulatory", "n_residential"),~lorenz(d[[.x]], d$population) |>mutate(type =str_remove(.x, "^n_"))) |>ggplot(aes(p, f, colour = type)) +geom_line(linewidth =1) +geom_abline(linetype ="dashed") +labs(x ="Cumulative share of population (ranked by facilities per capita)",y ="Cumulative share of facilities", colour =NULL) +theme_minimal()
Figure 4: Lorenz curves of facility distribution across Canadian communities
Analysis 4: Community characteristics and facility supply
Negative binomial regression models the number of facilities in each community, with population as the offset, so coefficients are rate ratios for facilities per person. Province is adjusted for (Ontario is the reference). Communities with suppressed census values are excluded.
Figure 5: Rate ratios (95% CI) for facilities per capita
Analysis 5: Spatial clustering of poor hospital access
Global Moran’s I tests whether communities with long distances sit next to each other more than chance would predict. Local indicators (LISA) locate the clusters. Distance varies smoothly across space, so a high global value is expected; the local map is the informative output.
Table 8: Global Moran’s I for log distance to nearest hospital
morans_I
p_value
communities
0.451
0
4830
Code
lisa <-localmoran(y, lw, zero.policy =TRUE)z <-as.numeric(scale(y))lagz <-lag.listw(lw, z, zero.policy =TRUE)sig <- lisa[, 5] <0.05sp$cluster <-case_when(!sig ~"Not significant", z >0& lagz >0~"High-High (remote cluster)", z <0& lagz <0~"Low-Low (well-served cluster)", z >0& lagz <0~"High-Low",TRUE~"Low-High")ggplot(sp) +geom_sf(aes(fill = cluster), color =NA) +scale_fill_manual(values =c("Not significant"="grey90","High-High (remote cluster)"="firebrick","Low-Low (well-served cluster)"="steelblue","High-Low"="orange", "Low-High"="lightblue")) +theme_void() +labs(fill =NULL)
Figure 6: Local spatial clusters of hospital access (LISA, p < 0.05)
Code
kable(count(st_drop_geometry(sp), cluster, name ="communities"))
Table 9: Number of communities in each cluster type
cluster
communities
High-High (remote cluster)
258
High-Low
7
Low-High
106
Low-Low (well-served cluster)
161
Not significant
4298
Analysis 6: Residential care supply relative to the older population
Counts of residential care facilities per 10,000 people aged 65 and over. Only communities where the census reports the senior population are included. ODHF has no bed counts, so this is a rough proxy for capacity.
Table 10: Residential care facilities per 10,000 seniors by province
prov
seniors
residential_facilities
per_10k_seniors
AB
629075
4
0.06
BC
1016085
916
9.01
MB
228970
3
0.13
NB
177145
3
0.17
NL
120530
2
0.17
NS
215315
134
6.22
NT
4085
0
0.00
NU
1610
0
0.00
ON
2637615
1666
6.32
PE
32715
11
3.36
QC
1753290
680
3.88
SK
197525
84
4.25
YT
6005
5
8.33
Code
ggplot(t7, aes(seniors, per_10k_seniors, label = prov)) +geom_text() +scale_x_log10() +labs(x ="Population aged 65+ (log scale)", y ="Residential care facilities per 10,000 seniors") +theme_minimal()
Figure 7: Residential care facilities per 10,000 seniors against provincial senior population
Limitations
ODHF records the location and type of facilities, not beds, staff, services or wait times, so access here means geographic proximity only.
Distance is straight-line from a point in each community, so it understates travel in remote areas and where there are no roads. Road-network travel time would strengthen Analysis 2.
Provinces contribute to ODHF differently (see the completeness table), so province comparisons of counts can reflect reporting differences. Hospitals are the most comparable facility type.
The analysis is ecological: communities are the unit, so findings do not apply to individuals.
Census values are suppressed for small communities, mainly seniors’ share and income, which affects Analyses 4 and 6.
A community’s characteristics are linked to its facilities by census subdivision, which is large in rural areas and small in cities.