Daily Shelter Use and Capacity
How many Toronto shelter spaces are used, empty, or unavailable
Latest published City of Toronto shelter beds and spaces for people who need somewhere safe to stay. This is not live bed availability.
function fmtDate(s) {
const [y, m, d] = s.split("-").map(Number)
return new Date(y, m - 1, d).toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric"
})
}
CITY_CENSUS_URL = "https://www.toronto.ca/city-government/data-research-maps/research-reports/housing-and-homelessness-research-and-reports/shelter-census/"
// ── Two sources, one table ────────────────────────────────────────────────
// The open data file carries the detailed and funded-capacity fields, so it is
// the primary source once available. The City's daily webpage is the fallback
// for dates where no open data has been published yet.
//
// Precedence is decided here, at render time, rather than by rewriting stored
// rows. When the City finally publishes the missing dates, those dates drop
// out of contingencyDates on the next build and the table returns to open
// data on its own — there is no catch-up step to remember to run.
openDates = [...new Set(daily_occ.map(r => r.date))].sort().reverse()
latestOpenDate = openDates[0]
cityDates = Object.keys(city_table.entries ?? {}).sort().reverse()
contingencyDates = cityDates.filter(d => d > latestOpenDate)
allDates = [...new Set([...openDates, ...contingencyDates])].sort().reverse()
latestDate = allDates[0]This table combines two City sources. You can choose any date from January 2021 to today; the City’s daily webpage shows only its newest date. When the detailed open-data file is delayed, BonQuery uses newer webpage rows as a clearly labelled temporary fallback. The Funded but unavailable column stays blank for those dates until the detailed file arrives.
Do not use either City table as live bed availability. Both are 4 a.m. snapshots, not a list of what is available now. A space shown as unoccupied may already be used, unavailable, or unsuitable for the person calling. Use the verified contacts below when someone needs a place now.
latestDate > latestOpenDate
? html`<div class="bq-source-note" role="status">
<p><strong>The two City sources are on different publication dates.</strong>
The detailed Open Data shelter file currently reaches
<strong>${fmtDate(latestOpenDate)}</strong>; the City's daily webpage
reaches <strong>${fmtDate(latestDate)}</strong>. BonQuery uses the newer
webpage as a temporary fallback where possible and labels the source.</p>
</div>`
: html``Toronto shelter use on
latestRows = daily_occ.filter(r => r.date === latestOpenDate)
function getVal(rows, key) {
const row = rows.find(r => r.key === key)
return row ? row.ind : null
}
validationChecks = {
const rows = latestRows
const checks = []
// Check 1: all expected keys present
const expectedKeys = [
"all_shelter","room_based","singles_sector","singles_shelter","allied_summ",
"temp_summ","iso_summ","fam_total","fam_emerg","fam_trans_r","fam_hotel",
"sng_hotel","singles_total","emerg_total","mix_emerg","men_emerg","wom_emerg",
"yth_emerg","trans_total","mix_trans","fam_trans_b","men_trans","wom_trans",
"yth_trans","allied_total","respites","dropin","temp_resp","hotels","iso"
]
const presentKeys = new Set(rows.map(r => r.key))
const missingKeys = expectedKeys.filter(k => !presentKeys.has(k))
checks.push({
name: "All expected rows present",
pass: missingKeys.length === 0,
detail: missingKeys.length > 0 ? `Missing: ${missingKeys.join(", ")}` : null
})
// Check 2: All Shelter Programs total = sum of components
const allShelter = getVal(rows, "all_shelter")
const compSum = ["fam_total","sng_hotel","singles_total","allied_total","temp_resp","hotels","iso"]
.reduce((s, k) => s + (getVal(rows, k) ?? 0), 0)
checks.push({
name: "All Shelter Programs rollup matches components",
pass: allShelter === compSum,
detail: allShelter !== compSum
? `all_shelter.ind=${allShelter}, component sum=${compSum}, diff=${allShelter - compSum}`
: null
})
// Check 3: Singles sectors total = emergency + transitional
const singlesTotal = getVal(rows, "singles_total")
const emergTotal = getVal(rows, "emerg_total")
const transTotal = getVal(rows, "trans_total")
checks.push({
name: "Singles sectors rollup: emergency + transitional = singles total",
pass: singlesTotal === (emergTotal + transTotal),
detail: singlesTotal !== (emergTotal + transTotal)
? `singles=${singlesTotal}, emerg+trans=${emergTotal + transTotal}`
: null
})
// Check 4: For every row with non-null cap, occ + unocc = cap (within 1, for rounding)
const capRows = rows.filter(r => r.cap !== null && r.cap > 0)
const arithFails = capRows.filter(r => Math.abs(r.occ + r.unocc - r.cap) > 0.5)
checks.push({
name: "Internal arithmetic: occupied + unoccupied = actual capacity",
pass: arithFails.length === 0,
detail: arithFails.length > 0
? arithFails.map(r => `${r.key}: ${r.occ}+${r.unocc}≠${r.cap}`).join("; ")
: null
})
return checks
}
allPass = validationChecks.every(c => c.pass)
// ── Validation banner ─────────────────────────────────────────────────────
validationBanner = {
if (!allPass) {
const failedChecks = validationChecks.filter(c => !c.pass)
return html`<div style="
background:var(--bq-warn-bg);border:1px solid var(--bq-warn-border);border-radius:4px;
padding:8px 14px;font-size:0.88em;color:var(--bq-warn-fg);margin-bottom:12px">
<strong>⚠ Validation issue detected for ${fmtDate(latestOpenDate)}</strong>
<details style="margin-top:6px">
<summary style="cursor:pointer">Show details</summary>
<ul style="margin:6px 0 0;padding-left:20px">
${failedChecks.map(c => html`<li>${c.name}${c.detail ? html` — <em>${c.detail}</em>` : ""}</li>`)}
</ul>
</details>
</div>`
}
return html``
}// Tells the reader which of the two sources produced the date they are
// looking at, and what that costs them.
sourceNote = {
if (selectedSource !== "city") {
return html`<p class="bq-ckan-note">
Newest open data: <strong>${fmtDate(latestOpenDate)}</strong>.
</p>`
}
return html`<div class="bq-warn" style="margin:0.5rem 0 1rem;font-size:0.88em">
<p style="margin:0">
<strong>Temporary webpage fallback.</strong>
The detailed Open Data file currently reaches ${fmtDate(latestOpenDate)}, so this date
comes from its <a href="${CITY_CENSUS_URL}" target="_blank">daily
webpage</a>. Funded but unavailable stays blank until the detailed file
is published.
</p>
</div>`
}rowTemplate = new Map(
daily_occ.filter(r => r.date === latestOpenDate).map(r => [r.key, r])
)
// The City's webpage reuses one row where the open data has two: a summary
// entry and a detail entry. The scrape keeps only the first occurrence, so a
// single scraped row has to serve both positions.
cityKeyAliases = ({
singles_shelter_total: ["singles_shelter_total", "singles_shelter"],
allied_total: ["allied_total", "allied_summ"],
iso: ["iso", "iso_summ"]
})
// The City sometimes prints a section total with only an individuals count and
// leaves the rest of the row blank, as it did for Allied Services on Aug 6.
// Where every component row is present, rebuild the missing columns from them.
// The identity holds exactly on every date the City published both, so this
// reproduces the City's own arithmetic rather than inventing a figure.
//
// Done here and not in the scrape on purpose: audit_city_scrape.py reads
// city_daily_table.json as evidence of what the City actually published, and
// a derived value written into that archive would show up in the daily audit
// as though the City had published it.
cityTotalComponents = ({ allied_total: ["respites", "dropin"] })
function cityDerivedTotals(rows) {
const byKey = new Map(rows.map(r => [r.key, r]))
const out = new Map()
for (const [total, parts] of Object.entries(cityTotalComponents)) {
const t = byKey.get(total)
if (!t || t.city_occ !== null) continue // already published in full
const ps = parts.map(k => byKey.get(k))
if (ps.some(p => !p || p.city_occ === null || p.city_unocc === null
|| p.city_cap === null)) continue
const sum = f => ps.reduce((a, p) => a + p[f], 0)
const occ = sum("city_occ"), cap = sum("city_cap")
out.set(total, {
city_occ: occ,
city_unocc: sum("city_unocc"),
city_cap: cap,
city_rate: cap > 0 ? Math.round(1000 * occ / cap) / 10 : null
})
}
return out
}
function cityRowsFor(date) {
const rows = (city_table.entries ?? {})[date]
if (!rows) return []
const patch = cityDerivedTotals(rows)
return rows.flatMap(row => {
const keys = cityKeyAliases[row.key] ?? [row.key]
return keys.map(key => {
const t = rowTemplate.get(key)
return {
date,
key,
label: t ? t.label : row.label,
indent: t ? t.indent : 0,
is_total: t ? t.is_total : false,
col_type: t ? t.col_type : row.col_type,
ind: row.city_ind,
occ: (patch.get(row.key) ?? row).city_occ,
unocc: (patch.get(row.key) ?? row).city_unocc,
cap: (patch.get(row.key) ?? row).city_cap,
rate: (patch.get(row.key) ?? row).city_rate,
cap_funding: null, // the City's webpage does not publish it
source: "city"
}
})
})
}
dayRows = {
const open = daily_occ.filter(r => r.date === selectedDate)
if (open.length) return open.map(r => ({...r, source: "open"}))
return cityRowsFor(selectedDate)
}
selectedSource = dayRows.length > 0 ? dayRows[0].source : "open"
function n(v) { return v !== null && v !== undefined ? v.toLocaleString() : "—" }
function pct(v) { return v !== null && v !== undefined ? v.toFixed(1) + "%" : "—" }
function offlineCell(r) {
if (r.cap === null || r.cap === undefined) return `<td></td>`
if (r.cap_funding === null || r.cap_funding === undefined) return `<td></td>`
const offline = r.cap_funding - r.cap
if (offline <= 0) {
return `<td class="bq-num">${offline.toLocaleString()}</td>`
}
return `<td class="bq-num bq-danger">${offline.toLocaleString()}</td>`
}
function tableRow(r, col1Label) {
const pad = (r.indent ?? 0) * 16
const cls = r.is_total ? "bq-row-total" : "bq-row"
const bld = r.is_total ? " bq-bold" : ""
if (r.col_type === "summary") {
return `<tr class="${cls}">
<td style="padding-left:${pad}px" class="${bld}">${r.label}</td>
<td class="bq-num${bld}">${n(r.ind)}</td>
<td></td><td></td><td></td><td></td><td></td>
</tr>`
}
return `<tr class="${cls}">
<td style="padding-left:${pad}px" class="${bld}">${r.label}</td>
<td class="bq-num${bld}">${n(r.ind)}</td>
<td class="bq-num">${n(r.occ)}</td>
<td class="bq-num">${n(r.unocc)}</td>
<td class="bq-num">${n(r.cap)}</td>
${offlineCell(r)}
<td class="bq-num">${pct(r.rate)}</td>
</tr>`
}
function sectionHeader(label, col1, col2, col3, col4) {
return `<tr class="bq-th">
<td>${label}</td>
<td class="bq-num">People</td>
<td class="bq-num">${col1}</td>
<td class="bq-num">${col2}</td>
<td class="bq-num">${col3}</td>
<td class="bq-num">${col4}</td>
<td class="bq-num">Percent full</td>
</tr>`
}
function summaryHeader() {
return `<tr class="bq-th">
<td>People using shelters</td>
<td class="bq-num">People</td>
<td colspan="5"></td>
</tr>`
}
dailyTable = {
const s = (key) => dayRows.find(r => r.key === key)
const allShelterInd = s("all_shelter") ? s("all_shelter").ind : null
const btEntry = bt_data && bt_data.entries ? bt_data.entries[selectedDate] : null
const btInd = btEntry ? btEntry.bridging_triage : null
const btStart = bt_data && bt_data.first_captured
? bt_data.first_captured
: "the date scraping began"
const totalInd = (allShelterInd !== null && btInd !== null)
? allShelterInd + btInd
: allShelterInd
const totalAccomRow = `<tr class="bq-row-total bq-bold">
<td>Total People Accommodated †</td>
<td class="bq-num">${n(totalInd)}</td>
<td></td><td></td><td></td><td></td><td></td>
</tr>`
const bridgingRow = btInd !== null
? `<tr class="bq-row-alt">
<td style="padding-left:26px">Bridging & Triage Programs</td>
<td class="bq-num">${n(btInd)}</td>
<td></td><td></td><td></td><td></td><td></td>
</tr>`
: `<tr class="bq-row-na">
<td style="padding-left:26px">Bridging & Triage Programs</td>
<td class="bq-num" style="font-size:0.85em"
>Not available for this date</td>
<td></td><td></td><td></td><td></td><td></td>
</tr>`
const summaryKeys = ["all_shelter","room_based","singles_sector","singles_shelter","allied_summ","temp_summ","iso_summ"]
const roomKeys = ["fam_total","fam_emerg","fam_trans_r","fam_hotel","sng_hotel"]
const bedKeys = ["singles_total","emerg_total","mix_emerg","men_emerg","wom_emerg","yth_emerg",
"trans_total","mix_trans","fam_trans_b","men_trans","wom_trans","yth_trans"]
const alliedKeys = ["allied_total","respites","dropin"]
return html`
<div style="overflow-x:auto;margin:0 0 1rem">
<table class="bq-table">
${summaryHeader()}
${totalAccomRow}
${bridgingRow}
${summaryKeys.map(k => s(k) ? tableRow(s(k)) : "").join("")}
${sectionHeader("Shelter programs with rooms", "Rooms used", "Rooms empty", "Rooms open to use", "Funded but unavailable")}
${roomKeys.map(k => s(k) ? tableRow(s(k)) : "").join("")}
${sectionHeader("Shelter programs with beds", "Beds used", "Beds empty", "Beds open to use", "Funded but unavailable")}
${bedKeys.map(k => s(k) ? tableRow(s(k)) : "").join("")}
${sectionHeader("Other shelter services", "Spaces used", "Spaces empty", "Spaces open to use", "Funded but unavailable")}
${alliedKeys.map(k => s(k) ? tableRow(s(k)) : "").join("")}
<tr class="bq-th">
<td>Temporary programs with beds or spaces</td>
<td class="bq-num">People</td>
<td class="bq-num">Used</td>
<td class="bq-num">Empty</td>
<td class="bq-num">Open to use</td>
<td class="bq-num">Funded but unavailable</td>
<td class="bq-num">Percent full</td>
</tr>
${s("temp_resp") ? tableRow(s("temp_resp")) : ""}
${sectionHeader("Temporary programs with rooms", "Rooms used", "Rooms empty", "Rooms open to use", "Funded but unavailable")}
${s("hotels") ? tableRow(s("hotels")) : ""}
${sectionHeader("Temporary health and recovery programs", "Rooms used", "Rooms empty", "Rooms open to use", "Funded but unavailable")}
${s("iso") ? tableRow(s("iso")) : ""}
</table>
<p class="bq-footnote">
† Total people = All Shelter Programs + Bridging & Triage Programs.
The detailed open-data file does not include Bridging & Triage. BonQuery copies
that number from the City's daily webpage when it appears. We have saved it
since ${btStart}. If the City has not posted it, the row says "Not available
for this date" and the total does not include it.
</p>
</div>`
}We rebuild this table from the City’s public data file. The detailed file does not include Bridging & Triage Programs, so we add the number from the City’s daily webpage when it is available. The total includes that number when we have it. The webpage and open-data file can be published on different schedules. See how BonQuery combines and labels the two sources.
Contains information licensed under the Open Government Licence - Toronto. Source: City of Toronto Open Data, Daily Shelter & Overnight Service Occupancy & Capacity. Rebuilt by BonQuery.
Download BonQuery’s daily data from 2021 to today: JSON · CSV