Canada's Express Entry system publishes every "round of invitations" it runs: the date, the program or category, how many people were invited and the lowest Comprehensive Ranking System (CRS) score that got an invitation. IRCC puts this in a JSON feed behind its rounds of invitations page. I cleaned that feed into three CSV files and put them on GitHub, so I wanted to see what you can learn from it with a few lines of pandas.
Everything below runs as is against the public CSVs. The outputs are pasted straight from my terminal (pandas 3.0, data through round #453 on October 28, 2026).
1. Load the data
import pandas as pd
pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 10)
BASE = "https://raw.githubusercontent.com/izafar0999-gitup/canada-express-entry-draws/main/"
draws = pd.read_csv(BASE + "express_entry_draws.csv",
parse_dates=["date"], dtype={"round_number": "string"})
print(draws.shape)
print(draws["date"].min().date(), "to", draws["date"].max().date())
print(draws[draws["round_number"].str.contains("[a-z]", regex=True)]
[["round_number", "date", "invitations"]])
(454, 9)
2015-01-31 to 2026-10-28
round_number date invitations
362 91b 2018-05-30 200
363 91a 2018-05-30 500
First gotcha: round_number is not an integer. On May 30, 2018 IRCC ran two rounds on the same day and numbered them 91a and 91b. Reading it as string on purpose makes that explicit and stops anyone from casting it to int later and crashing on 91a.
2. Rounds and invitations per year
draws["year"] = draws["date"].dt.year
yearly = draws.groupby("year").agg(
rounds=("round_number", "size"),
invitations=("invitations", "sum"),
median_cutoff=("crs_cutoff", "median"),
)
print(yearly)
rounds invitations median_cutoff
year
2015 23 31063 469.0
2016 27 33782 482.0
2017 30 86023 438.5
2018 28 89800 442.0
2019 26 85300 459.5
2020 37 107350 471.0
2021 42 114224 712.0
2022 24 46539 692.0
2023 42 110266 486.0
2024 52 98903 524.5
2025 58 113998 533.5
2026 65 149409 514.0
2026 is not over, and it already has the most rounds (65) and the most invitations (149,409) of any year in the data.
Second gotcha: that median_cutoff column is misleading. Provincial Nominee Program (PNP) rounds only invite people who already have a provincial nomination, and a nomination is worth 600 CRS points on its own (see IRCC's CRS criteria page). In 2021 IRCC ran only program-specific rounds, 25 of the 42 of them PNP, so the median jumps to 712. Drop PNP rounds before you compare years:
no_pnp = draws[draws["category"] != "Provincial Nominee Program"]
print(no_pnp.groupby("year")["crs_cutoff"].median().loc[2019:])
year
2019 459.5
2020 469.0
2021 401.0
2022 510.0
2023 486.0
2024 499.0
2025 504.5
2026 475.0
Name: crs_cutoff, dtype: float64
Even this still mixes very different draws. The 2021 figure includes round #176 (February 13, 2021), a Canadian Experience Class round that invited 27,332 people with a cut-off of 75, which is still the largest and lowest round in the file. A median per year is a rough summary at best. Group by category if you want a number someone could plan around.
3. The end of the "general" draw
IRCC labels each round as General (all programs), Program-specific (one program, such as CEC or PNP) or Category-based (for example French speakers or healthcare workers). A pivot table shows how the mix changed:
share = (draws.pivot_table(index="year", columns="round_type",
values="invitations", aggfunc="sum", fill_value=0)
.pipe(lambda t: t.div(t.sum(axis=1), axis=0) * 100)
.round(1))
print(share.loc[2019:])
last_general = draws[draws["round_type"] == "General"].sort_values("date").iloc[-1]
print("Last general round:", last_general["round_number"], last_general["date"].date(),
last_general["invitations"], "ITAs, cut-off", last_general["crs_cutoff"])
round_type Category-based General Program-specific
year
2019 0.0 98.8 1.2
2020 0.0 63.4 36.6
2021 0.0 0.0 100.0
2022 0.0 76.8 23.2
2023 23.5 69.6 7.0
2024 42.9 14.6 42.4
2025 59.0 0.0 41.0
2026 54.1 0.0 45.9
Last general round: 294 2024-04-23 2095 ITAs, cut-off 529
In 2019 almost every invitation came from a general round. In 2025 and 2026, none did. The last general round was #294 on April 23, 2024. If you only look at the cut-off column without round_type, you will compare a French-language round with a CEC round and draw the wrong conclusion.
4. 2026 by category
d26 = draws[draws["year"] == 2026]
summary = (d26.groupby("category")
.agg(rounds=("round_number", "size"),
invitations=("invitations", "sum"),
min_cutoff=("crs_cutoff", "min"),
max_cutoff=("crs_cutoff", "max"))
.sort_values("invitations", ascending=False))
print(summary)
rounds invitations min_cutoff max_cutoff
category
French language proficiency 12 60500 382 435
Canadian Experience Class 18 57750 507 523
Healthcare and social services occupations 3 11500 467 475
Provincial Nominee Program 21 10814 697 805
Trade occupations 2 6500 476 477
Senior managers with Canadian work experience 3 1000 389 429
Physicians with Canadian work experience 3 891 169 223
Transport occupations 2 450 470 477
Skilled military recruits 1 4 368 368
The spread is the story: CEC cut-offs stayed in a tight 507 to 523 band all year, while French-language rounds closed between 382 and 435. Same system, same year, a gap of roughly 100 points depending on which door you are eligible for.
5. Where the pool sits
The second file is the CRS score distribution IRCC publishes with most rounds: how many profiles are in the pool in each score band.
pool = pd.read_csv(BASE + "express_entry_crs_score_distribution.csv",
parse_dates=["date", "distribution_as_of"])
latest = pool.sort_values("date").iloc[-1]
bands = ["candidates_601_1200", "candidates_501_600", "candidates_451_500",
"candidates_401_450", "candidates_351_400", "candidates_301_350", "candidates_0_300"]
snap = latest[bands].astype(int)
print("Snapshot as of", latest["distribution_as_of"].date(), "total", int(latest["candidates_total"]))
print((snap / int(latest["candidates_total"]) * 100).round(1).to_string())
Snapshot as of 2026-10-25 total 228724
candidates_601_1200 0.3
candidates_501_600 9.0
candidates_451_500 31.1
candidates_401_450 27.8
candidates_351_400 20.9
candidates_301_350 7.5
candidates_0_300 3.3
Only 9.3% of the 228,724 profiles score above 500. CEC rounds in 2026 needed at least 507, so most of the pool is below the line for those rounds, and the biggest single band is 451 to 500 (31.1%). Two caveats if you use this file: IRCC often reuses the same snapshot for several rounds in one week, so drop_duplicates("distribution_as_of") before you plot a time series, and the bands are fixed by IRCC, so you cannot compute an exact count above, say, 519.
6. Draws come in clusters
d26 = d26.sort_values("date")
weeks = d26["date"].dt.isocalendar().week
per_week = d26.groupby(weeks).size()
print("2026 rounds:", len(d26), "| weeks with at least one round:", per_week.size)
print(per_week.value_counts().sort_index().rename("weeks").rename_axis("rounds_in_week"))
gaps = d26["date"].diff().dt.days.dropna()
print("Longest gap:", int(gaps.max()), "days, ending", d26.loc[gaps.idxmax(), "date"].date())
2026 rounds: 65 | weeks with at least one round: 21
rounds_in_week
1 1
2 3
3 10
4 7
Name: weeks, dtype: int64
Longest gap: 25 days, ending 2026-06-22
The 65 rounds of 2026 fell in just 21 calendar weeks. In 17 of those weeks IRCC ran three or four rounds back to back (typically PNP, then CEC, then a category round). So "how many draws were there" is a less useful question than "how many draw weeks were there". If you are building any kind of "next draw" estimate, model the week, not the individual round.
Things to know before you build on this
- PNP cut-offs include the 600 nomination points. Subtract 600 if you want to compare them with other rounds, or just keep them separate.
- From November 3, 2026, IRCC changes how Canadian work experience and job offers are scored, and says it will pause rounds for up to two weeks while it recalculates every profile (source: IRCC's CRS criteria page and its October 2026 backgrounder on high-wage occupations). Cut-offs after that date will not be directly comparable with earlier ones, so add a
rules_versioncolumn before you mix them. - Tie-breaking timestamps are ISO 8601. A trailing
Zmeans IRCC gave the time in UTC.
Get the data
- GitHub: https://github.com/izafar0999-gitup/canada-express-entry-draws (CSV files, data dictionary and the build script that regenerates them from IRCC's JSON feed)
- Source: IRCC rounds of invitations, https://www.canada.ca/en/immigration-refugees-citizenship/services/immigrate-canada/express-entry/rounds-invitations.html, under the Open Government Licence – Canada
If you would rather read the pool numbers than compute them, I keep a plain-English version on the Express Entry pool statistics page. And if you want to turn the scoring tables themselves into code, the step-by-step CRS calculation guide walks through every factor with a worked example.
Zafar Iqbal maintains crscalcu.ca, an independent site that is not affiliated with IRCC. Always confirm rules on canada.ca before making immigration decisions.
Top comments (0)