BonQuery
  • Home
  • Left in the Cold
  • Audits
    • INDEPENDENT OVERSIGHT
    • Discrepancy Tracker
    • Emergency Occupancy Audit
    • Central Intake Audit
    • About the Data
  • Shelter Dashboards
    • Inside Toronto’s Shelter System
    • Monthly System Flow Replication
    • Year-to-Date Capacity Match
    • Historical Trend Lines
    • Referral Requests Analysis
    • Daily Occupancy & Capacity
  • About
  • FR

Historical Trends

Toronto Shelter System — multi-year time series

Multi-year historical trends in Toronto’s shelter system — actively homeless, housing exits, and chronic homelessness from January 2018 to present, with interactive filters.
Author

Miriam Marling

data = FileAttachment("../data/shelter_flow.json").json()
populationGroups = [
  "All Population",
  ...Array.from(new Set(data.map(d => d.population_group)))
    .filter(g => g !== "All Population")
    .sort()
]

availableYears = [
  "All",
  ...Array.from(new Set(data.map(d => d.flow_date.slice(0, 4)))).sort()
]

monthOptions = [
  {label: "All", value: "All"},
  ...Array.from({length: 12}, (_, i) => ({
    label: new Date(2000, i).toLocaleString("default", {month: "long"}),
    value: String(i + 1)
  }))
]
viewof selectedGroup = Inputs.select(populationGroups, {
  label: "Population group", value: "All Population", width: 220
})
viewof selectedYear = Inputs.select(availableYears, {
  label: "Year", value: "All", width: 120
})
viewof selectedMonth = Inputs.select(monthOptions, {
  label: "Month", format: d => d.label, value: monthOptions[0], width: 160
})
filtered = data
  .filter(d => {
    const groupMatch = d.population_group === selectedGroup
    const yearMatch  = selectedYear === "All" || d.flow_date.slice(0, 4) === selectedYear
    const monthMatch = selectedMonth.value === "All" ||
                       parseInt(d.flow_date.slice(5, 7)) === parseInt(selectedMonth.value)
    return groupMatch && yearMatch && monthMatch
  })
  .map(d => {
    const [y, m, day] = d.flow_date.split("-").map(Number)
    return {...d, date: new Date(y, m - 1, day), month: d.flow_date.slice(0, 7)}
  })
  .sort((a, b) => a.date - b.date)

// Short month names used in tick labels.
MON = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]

// Actual time span in months between first and last filtered row.
// This governs tick density — NOT the data point count, which is misleading
// when one specific month is selected across many years (few points, huge span).
spanMonths = filtered.length < 2 ? 1 :
  Math.round((filtered[filtered.length-1].date - filtered[0].date)
             / (30.44 * 24 * 3600 * 1000))

// Three axis display modes for LINE charts:
//   dense  — many years, all months (filtered.length > 20, spanMonths > 18)
//            → yearly ticks, year-only label ("2019")
//   sparse — specific month across all years (filtered.length ≤ 20, spanMonths > 18)
//            → yearly ticks, "Mon-YYYY" label ("Jan-2018")
//   single — one year selected (spanMonths ≤ 18)
//            → monthly ticks, "Mon" with year on January ("Jan-2018", "Feb", …)
xLineDense  = spanMonths > 18 && filtered.length > 20
xLineSingle = spanMonths <= 18

xLine = ({
  type: "time",
  label: null,
  ticks: xLineSingle ? d3.utcMonth.every(1) : 8,
  tickFormat: d => {
    const m = d.getMonth(), y = d.getFullYear()
    if (xLineSingle)  return m === 0 ? `${MON[m]}-${y}` : MON[m]
    if (xLineDense)   return m === 0 ? String(y) : m === 6 ? `Jul ${y}` : ""
    // sparse: specific month across all years
    return m === 0 ? `Jan-${y}` : m === 6 ? `Jul-${y}` : `${MON[m]}-${y}`
  }
})

// Helper: first and last day of a month as Date objects (local time).
// Used by rectY bar charts to draw bars spanning exactly one calendar month.
monthStart = d => new Date(d.date.getFullYear(), d.date.getMonth(), 1)
monthEnd   = d => new Date(d.date.getFullYear(), d.date.getMonth() + 1, 1)
// Mid-month date used to anchor pointerX tooltip snapping.
monthMid   = d => new Date(d.date.getFullYear(), d.date.getMonth(), 15)

// Shared y formatter — no scientific notation.
fmtN = d => d.toLocaleString()
fmtPct = d => (d * 100).toFixed(0) + "%"

Toronto publishes monthly data on its taxed shelter system, and the City’s own dashboards do a good job presenting the headline numbers. This section goes a step further: interactive filters on every chart, a clear note on what the data captures and what it leaves out, and findings that usually stay buried in technical documentation.

The data covers every month from January 2018 to the most recent published month, sourced directly from the City of Toronto’s Shelter System Flow dataset. New data loads automatically each month, usually around the 15th, after the City publishes the prior month’s figures.


People actively homeless in the last 3 months

Plot.plot({
  width, height: 380, marginLeft: 70,
  x: xLine,
  y: {label: "People actively homeless", grid: true, tickFormat: fmtN},
  marks: [
    Plot.ruleY([0]),
    Plot.line(filtered, {x: "date", y: "actively_homeless", stroke: "#FF2D55", strokeWidth: 2}),
    Plot.dot(filtered,  {x: "date", y: "actively_homeless", fill:   "#FF2D55", r: 2}),
    Plot.tip(filtered, Plot.pointerX({
      x: "date", y: "actively_homeless",
      title: d => `${d.month}\n${d.actively_homeless.toLocaleString()} people`
    }))
  ]
})

Net Change (Inflow minus Outflow) each month

netData = filtered.map(d => ({
  date: d.date, month: d.month,
  net_change: d.newly_identified + d.returned_from_housing + d.returned_to_shelter
              - d.moved_to_housing - d.became_inactive
}))

Plot.plot({
  width, height: 380, marginLeft: 70,
  x: xLine,
  y: {label: "Net change (people)", grid: true, tickFormat: fmtN},
  marks: [
    Plot.ruleY([0], {stroke: getComputedStyle(document.body).getPropertyValue("--bq-fg-secondary"), strokeWidth: 1.5}),
    Plot.line(netData, {x: "date", y: "net_change", stroke: "#5856D6", strokeWidth: 2}),
    Plot.dot(netData,  {x: "date", y: "net_change", fill:   "#5856D6", r: 2}),
    Plot.tip(netData, Plot.pointerX({
      x: "date", y: "net_change",
      title: d => {
        const sign = d.net_change >= 0 ? "+" : ""
        return `${d.month}\n${sign}${d.net_change.toLocaleString()} people`
      }
    }))
  ]
})

Total Inflow and Outflow each month

totalRectData = filtered.map(d => {
  const inflow  = d.newly_identified + d.returned_from_housing + d.returned_to_shelter
  const outflow = d.moved_to_housing + d.became_inactive
  const lbl     = `${MON[parseInt(d.month.slice(5,7))-1]}-${d.month.slice(0,4)}`
  return {
    x1: monthStart(d), x2: monthEnd(d), xMid: monthMid(d),
    inflow, outflow,
    tipTitle: `${lbl}\nInflow:  +${inflow.toLocaleString()}\nOutflow: −${outflow.toLocaleString()}\nNet:      ${(inflow-outflow)>=0?"+":""}${(inflow-outflow).toLocaleString()}`
  }
})

Plot.plot({
  width, height: 380, marginLeft: 70,
  x: xLine,
  y: {label: "People", grid: true, tickFormat: fmtN},
  color: {domain: ["Inflow","Outflow"], range: ["#E63946","#66CC66"], legend: true},
  marks: [
    Plot.ruleY([0]),
    Plot.rectY(totalRectData, {x1: "x1", x2: "x2", y1: 0, y2: "inflow",          fill: "#E63946", insetLeft: 0.5, insetRight: 0.5}),
    Plot.rectY(totalRectData, {x1: "x1", x2: "x2", y1: d => -d.outflow, y2: 0,  fill: "#66CC66", insetLeft: 0.5, insetRight: 0.5}),
    Plot.tip(totalRectData, Plot.pointerX({x: "xMid", title: d => d.tipTitle}))
  ]
})

Detailed Inflow and Outflow each month

detailedFlowTypes = [
  "Inflow - Newly Identified",
  "Inflow - Returned from Permanent Housing",
  "Inflow - Returned to Shelter",
  "Outflow - Became Inactive",
  "Outflow - Moved to Permanent Housing"
]

detailedColors = ["#E63946","#FFD600","#FB8C00","#512DA8","#66CC66"]

// Pre-compute stacked y1/y2 for each series manually — avoids stackY on rectY
// (which doesn't support x1/x2 channels) and eliminates the band-scale warning.
detailRectData = filtered.flatMap(d => {
  const x1 = monthStart(d), x2 = monthEnd(d), xMid = monthMid(d)
  const ni = d.newly_identified, rfh = d.returned_from_housing,
        rts = d.returned_to_shelter, bi = d.became_inactive, mh = d.moved_to_housing
  return [
    {x1, x2, xMid, type: "Inflow - Newly Identified",                y1: 0,       y2: ni},
    {x1, x2, xMid, type: "Inflow - Returned from Permanent Housing",  y1: ni,      y2: ni+rfh},
    {x1, x2, xMid, type: "Inflow - Returned to Shelter",              y1: ni+rfh,  y2: ni+rfh+rts},
    {x1, x2, xMid, type: "Outflow - Became Inactive",                 y1: 0,       y2: -bi},
    {x1, x2, xMid, type: "Outflow - Moved to Permanent Housing",      y1: -bi,     y2: -(bi+mh)}
  ]
})

detailTipData = filtered.map(d => {
  const lbl = `${MON[parseInt(d.month.slice(5,7))-1]}-${d.month.slice(0,4)}`
  return {
    xMid: monthMid(d),
    tipTitle: [
      lbl,
      `Newly Identified:       +${d.newly_identified.toLocaleString()}`,
      `Returned from Housing:  +${d.returned_from_housing.toLocaleString()}`,
      `Returned to Shelter:    +${d.returned_to_shelter.toLocaleString()}`,
      `Became Inactive:        −${d.became_inactive.toLocaleString()}`,
      `Moved to Housing:       −${d.moved_to_housing.toLocaleString()}`
    ].join("\n")
  }
})

Plot.plot({
  width, height: 420, marginLeft: 70,
  x: xLine,
  y: {label: "People", grid: true, tickFormat: fmtN},
  color: {domain: detailedFlowTypes, range: detailedColors, legend: true},
  marks: [
    Plot.ruleY([0]),
    Plot.rectY(detailRectData, {x1: "x1", x2: "x2", y1: "y1", y2: "y2", fill: "type", insetLeft: 0.5, insetRight: 0.5}),
    Plot.tip(detailTipData, Plot.pointerX({x: "xMid", title: d => d.tipTitle}))
  ]
})

Age of people actively homeless in the last 3 months

ageBandDefs = [
  {key: "age_under_16", label: "Under 16"},
  {key: "age_16_24",    label: "16–24"},
  {key: "age_25_34",    label: "25–34"},
  {key: "age_35_44",    label: "35–44"},
  {key: "age_45_54",    label: "45–54"},
  {key: "age_55_64",    label: "55–64"},
  {key: "age_65_over",  label: "65+"}
]

ageData = filtered.flatMap(d => {
  const total = ageBandDefs.reduce((s, b) => s + (d[b.key] || 0), 0)
  return ageBandDefs.map(b => ({
    date: d.date, month: d.month, band: b.label,
    pct: total > 0 ? d[b.key] / total : null
  }))
})

Plot.plot({
  width, height: 400, marginLeft: 60,
  x: xLine,
  y: {label: "Share of age-banded total", grid: true, tickFormat: fmtPct},
  color: {legend: true},
  marks: [
    Plot.ruleY([0]),
    Plot.line(ageData, {x: "date", y: "pct", stroke: "band", strokeWidth: 1.8}),
    Plot.tip(ageData, Plot.pointerX({
      x: "date", y: "pct", stroke: "band",
      title: d => {
        const lbl = `${MON[parseInt(d.month.slice(5,7))-1]} ${d.month.slice(0,4)}`
        return `${d.band} — ${lbl}\n${(d.pct * 100).toFixed(1)}%`
      }
    }))
  ]
})

Gender of people actively homeless in the last 3 months

genderDefs = [
  {key: "gender_male",                label: "Men"},
  {key: "gender_female",              label: "Women"},
  {key: "gender_trans_nb_two_spirit", label: "Transgender, Non-Binary, or Two-Spirit"}
]

genderData = filtered.flatMap(d => {
  const total = genderDefs.reduce((s, g) => s + (d[g.key] || 0), 0)
  return genderDefs.map(g => ({
    date: d.date, month: d.month, gender: g.label,
    pct: total > 0 ? d[g.key] / total : null
  }))
})

Plot.plot({
  width, height: 400, marginLeft: 60,
  x: xLine,
  y: {label: "Share of gender total", grid: true, tickFormat: fmtPct},
  color: {legend: true},
  marks: [
    Plot.ruleY([0]),
    Plot.line(genderData, {x: "date", y: "pct", stroke: "gender", strokeWidth: 1.8}),
    Plot.tip(genderData, Plot.pointerX({
      x: "date", y: "pct", stroke: "gender",
      title: d => {
        const lbl = `${MON[parseInt(d.month.slice(5,7))-1]} ${d.month.slice(0,4)}`
        return `${d.gender} — ${lbl}\n${(d.pct * 100).toFixed(1)}%`
      }
    }))
  ]
})

Explore other dashboards:

  • Monthly Snapshot
  • YTD Comparison

This dashboard is also available as an Oracle APEX dashboard built on the same database — useful if you want to see how the same data renders in Oracle’s native low-code platform, or if you’re curious about the SQL and APEX configuration behind it.

How to read this data

These numbers reflect the shelter system, not all homelessness. The data captures people who used a City-funded overnight service at least once in the past three months. It doesn’t include people sleeping outside, in shelters that aren’t City-funded, or staying temporarily with friends or family. The City estimates that roughly 18% of people experiencing absolute homelessness in Toronto aren’t reflected in these numbers. Trends here reflect changes in the shelter system specifically — not in total homelessness in the city.

Key terms

The dashboards use a few terms that have specific meanings worth knowing before you read the charts.

Actively homeless. A person who has used City-funded shelter services at least once in the past three months and hasn’t been recorded as moving to permanent housing. This is the headline number on every dashboard. Because it looks back three months, the count includes people who may not be in shelter on any given night.

Chronic homelessness. The federal definition: someone who has spent at least 180 nights in shelter over the past year, or at least 546 nights over the past three years. A person can meet this definition while still actively in the shelter system — they don’t need to have left and returned.

Inflow categories (people entering the shelter system this month):

  • Newly Identified. People entering the shelter system for the first time. One exception for the “Chronic” group: in that row, this column counts people who became chronically homeless during the reporting month, regardless of how long they’d already been using the shelter system.
  • Returned from Permanent Housing. People who previously moved to permanent housing and have come back to the shelter system.
  • Returned to Shelter. People who were in the system, didn’t use it for at least three months, and have now returned.

Outflow categories (people leaving the shelter system this month):

  • Moved to Permanent Housing. People who left the shelter system for permanent housing.
  • Became Inactive. People who haven’t used shelter services in the past three months, including the reporting month.

Definitions are based on the City of Toronto’s Shelter System Flow Data page.

Read all data findings →

Data source

All data comes from the City of Toronto’s Shelter System Flow dataset, published monthly on the City’s open data portal.

Contains information licensed under the Open Government Licence – Toronto.

 

An independent data audit for Toronto residents. / Un audit de données indépendant pour les résidents de Toronto.
© 2026 Miriam Marling · BonQuery