DEV Community

Zafar Iqbal
Zafar Iqbal

Posted on

Analyzing 454 Express Entry draws with pandas: what IRCC's own data says about Canada's CRS cut-offs

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"]])
Enter fullscreen mode Exit fullscreen mode
(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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
      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
Enter fullscreen mode Exit fullscreen mode

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:])
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Last general round: 294 2024-04-23 2095 ITAs, cut-off 529
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
                                               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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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_version column before you mix them.
  • Tie-breaking timestamps are ISO 8601. A trailing Z means IRCC gave the time in UTC.

Get the data

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)