Overwatch 2 does not hand you an API key for your own rank. Blizzard’s Battle.net developer portal covers account and game data broadly, but competitive tier and division numbers are not exposed through a documented public endpoint. If you want a running log of your Tank, Damage, Support, and Open Queue rank across a season, you build it yourself. This tutorial walks through a working Python project that logs your rank after every session, stores the history in SQLite, charts your climb, and pings a Discord channel the moment you rank up. It targets the Season 4 tier structure live in Overwatch 2 as of September 2026, including the Emerald tier Blizzard inserted into the ladder in August.

Why Build Your Own Overwatch 2 Rank Tracker

Third-party sites like Overbuff and the Overwatch Tracker Network already show your competitive history, so the first question is why bother. Three reasons keep coming up from players who build their own tool anyway. First, ownership: your rank history lives in a file on your machine, not on a site that can change its layout, paywall stats, or shut down. Second, alerts: a tracker you built can message you the second you rank up or derank, something most public sites don’t do for free. Third, precision: you decide exactly what gets logged, whether that’s per-hero win rate, queue time, or your own notes on a rough match.

This build mirrors the same pattern used for tracking Marvel Rivals rank history and FACEIT level checks: a local SQLite store, a Python script, a chart, and a webhook. If you play more than one competitive shooter, the same skeleton works with a different data source swapped in. If you’re coming from Valorant or CS2 and comparing how the ranking systems stack up before deciding where to spend your time, our breakdown of Valorant ranks against Overwatch 2 ranks covers the tier math side by side.

There’s also a practical reason this matters more in Overwatch 2 than in most shooters: you’re not tracking one rank, you’re tracking four. Tank, Damage, and Support each carry an independent Role Queue rank, and Open Queue runs a separate ladder on top of that. A public stats page usually shows you the current snapshot of all four, but rarely lets you export the trend, filter by season, or set your own alert threshold. Building the tracker yourself fixes all three in about 45 minutes of setup.

Prerequisites: What You Need Before Starting

Nothing here is exotic. You need a desktop or laptop that can run Python scripts, an Overwatch 2 account, and about 45 minutes for the first pass. Here’s the exact toolchain this tutorial uses:

  • Python 3.12 or newer (3.11 works too, but f-string and typing improvements in 3.12 make the code cleaner)
  • pip 24.x, bundled with modern Python installs
  • SQLite 3, built into Python’s standard library, no separate install needed
  • requests 2.32 or newer, for any HTTP calls the tracker makes
  • matplotlib 3.9 or newer, for the progress chart
  • python-dotenv 1.0 or newer, to keep your Discord webhook URL out of source control
  • A free Discord server where you can create a webhook (optional, but Step 8 depends on it)
  • A code editor, VS Code or similar, though any text editor works
  • 15 to 20 minutes of Overwatch 2 competitive play logged so you have real numbers to enter

You don’t need a Blizzard developer account for the core build. If you later want to explore the official Battle.net OAuth flow (covered in Step 1), you’ll register a client at Blizzard’s developer documentation portal, but that step is optional and the tutorial works fully without it.

Overwatch 2 Season 4 Rank System, Explained

Before writing any code, it helps to know exactly what you’re logging. Overwatch 2 replaced the old numeric SR ladder with a tier-and-division system at launch: Bronze through Grandmaster, five divisions each, division 5 at the bottom and division 1 at the top, with Top 500 sitting above all tiers without divisions of its own. Blizzard’s original competitive update pegged each division at roughly a 100 SR band, and rank updates originally posted every 7 wins or 20 losses rather than after each individual match.

That structure has grown since. Champion tier arrived above Grandmaster in an earlier season, and an August 11, 2026 patch summary reports that Blizzard added a new Emerald tier between Platinum and Diamond, redistributing the ladder in the process. As of September 2026, guides tracking the live season describe nine earned tiers plus Top 500, and reference Season 4 as the current competitive season. Patch notes from December 2025 also introduced a “Challenger Score” table that maps specific divisions in the upper tiers to numeric values, which is the closest thing to a visible rating Blizzard currently publishes for high-rank players. The table below reflects that patch-note mapping.

TierDivisionsChallenger Score RangeNotes
Bronze5 – 1Not scoredEntry tier, division-based only
Silver5 – 1Not scoredDivision-based only
Gold5 – 1Not scoredDivision-based only
Platinum5 – 1Not scoredDivision-based only
Emerald5 – 1Not scoredAdded between Platinum and Diamond, August 2026
Diamond5 – 130 – 38Challenger Score first appears here
Master5 – 142 – 58Per December 2025 patch notes
Grandmaster5 – 170 – 128Per December 2025 patch notes
Champion5 – 1152 – 248Per December 2025 patch notes
Top 500No divisionsLeaderboard rank onlyTracked separately per role and queue

One more wrinkle matters for a tracker: Overwatch 2 keeps separate ranks per role and queue. Role Queue gives you three independent ranks, Tank, Damage, and Support, and Open Queue gives you a fourth. A single account can sit at Diamond 3 Tank, Gold 1 Damage, and Emerald 5 Open Queue at the same time. Your database schema needs to account for that from the start, which Step 2 handles directly. A February 2025 patch also reset every rank to Unranked and required 10 placement matches per role and queue before a new tier showed up, so if you’re starting the tracker mid-season, expect a placement gap in your first entries.

Top 500 also gets tracked separately per role and queue rather than as one leaderboard, so a player can hold a Top 500 Damage rank while sitting in Champion on Tank. Blizzard posts patch and balance updates that touch competitive play on its official news page, which is worth bookmarking if you want to catch a mid-season tier change before your tracker’s chart shows an unexplained jump.

Step 1: Pick Your Data Source

This is the decision that shapes everything downstream, so it gets its own step before any code. You have four realistic options, and they trade off setup time against reliability.

MethodSetup EffortReliabilityAuth RequiredBest For
Manual entry after each sessionLowHigh (you control the data)NoneGetting started today, zero dependencies on external sites
Battle.net OAuth 2.0 APIHighLimited (no documented public competitive-rank endpoint)OAuth client ID and secretReading general profile/game data, not rank specifically
Public profile scrapeMediumMedium (breaks if the profile page layout changes)None, but profile must be publicSemi-automated logging if you’re comfortable maintaining a parser
Third-party stats site APIMediumDepends on the provider’s uptime and termsUsually an API keyOffloading scraping to a service that already maintains it

This tutorial builds around manual entry as the reliable core, then shows where a profile-based fetch could slot in later without rewriting anything. That choice is deliberate. Blizzard does not currently publish a documented, stable endpoint for third-party apps to pull a player’s competitive tier and division, and building a tracker around an undocumented workaround means it can break the day Blizzard changes a page. A 10-second manual entry after each session is less code you have to maintain.

Step 2: Set Up Your Project Environment

Create a project folder and an isolated virtual environment so the packages you install here don’t collide with anything else on your machine.

mkdir ow2-rank-tracker
cd ow2-rank-tracker
python3 -m venv venv
source venv/bin/activate   # on Windows: venv\Scripts\activate
pip install requests matplotlib python-dotenv
pip freeze > requirements.txt

Create a .env file to hold your Discord webhook URL once you set one up in Step 8, and add both venv/ and .env to a .gitignore if you plan to version this with git. Then lay out the project files:

ow2-rank-tracker/
  venv/
  .env
  .gitignore
  tracker.db          # created automatically on first run
  db.py                # schema and database helpers
  entry.py             # CLI for logging a new rank snapshot
  analytics.py          # delta and climb-velocity calculations
  chart.py              # matplotlib chart generation
  discord_alert.py      # webhook notifications
  export.py             # CSV season report
  run.py                # ties everything together

Step 3: Design the SQLite Schema for Rank History

Each snapshot needs a timestamp, a role or queue label, the tier, the division, and an optional Challenger Score for players in Diamond and above. Create db.py:

import sqlite3
from datetime import datetime, timezone

DB_PATH = "tracker.db"

TIER_ORDER = [
    "Bronze", "Silver", "Gold", "Platinum", "Emerald",
    "Diamond", "Master", "Grandmaster", "Champion", "Top500"
]

def get_connection():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("PRAGMA foreign_keys = ON")
    return conn

def init_db():
    conn = get_connection()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS snapshots (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            logged_at TEXT NOT NULL,
            queue TEXT NOT NULL,
            tier TEXT NOT NULL,
            division INTEGER,
            challenger_score INTEGER,
            season TEXT NOT NULL,
            note TEXT
        )
    """)
    conn.commit()
    conn.close()

def insert_snapshot(queue, tier, division, challenger_score, season, note=""):
    conn = get_connection()
    conn.execute(
        """INSERT INTO snapshots
           (logged_at, queue, tier, division, challenger_score, season, note)
           VALUES (?, ?, ?, ?, ?, ?, ?)""",
        (datetime.now(timezone.utc).isoformat(), queue, tier,
         division, challenger_score, season, note)
    )
    conn.commit()
    conn.close()

if __name__ == "__main__":
    init_db()
    print("Database initialized at", DB_PATH)

Run python db.py once to create tracker.db. The queue column is what separates your Tank, Damage, Support, and Open Queue ranks in the same table, and season lets you filter charts to a single competitive season instead of blending every reset together.

Step 4: Build the Manual Rank-Entry CLI

Now build the script you’ll actually run after a play session. It prompts for the queue, tier, division, and, if you’re Diamond or higher, your Challenger Score.

import argparse
from db import init_db, insert_snapshot, TIER_ORDER

SCORED_TIERS = {"Diamond", "Master", "Grandmaster", "Champion"}

def prompt_entry():
    print("Queues: tank, damage, support, open")
    queue = input("Queue: ").strip().lower()
    print("Tiers:", ", ".join(TIER_ORDER))
    tier = input("Tier: ").strip().capitalize()
    division = input("Division (1-5, blank for Top 500): ").strip()
    division = int(division) if division else None
    score = None
    if tier in SCORED_TIERS:
        raw = input("Challenger Score (optional, blank to skip): ").strip()
        score = int(raw) if raw else None
    season = input("Season label (e.g. season-4): ").strip()
    note = input("Note (optional): ").strip()
    return queue, tier, division, score, season, note

def main():
    parser = argparse.ArgumentParser(description="Log an Overwatch 2 rank snapshot")
    parser.add_argument("--auto", action="store_true",
                         help="skip prompts, read from CLI flags instead")
    parser.add_argument("--queue")
    parser.add_argument("--tier")
    parser.add_argument("--division", type=int)
    parser.add_argument("--score", type=int)
    parser.add_argument("--season")
    parser.add_argument("--note", default="")
    args = parser.parse_args()

    init_db()

    if args.auto:
        insert_snapshot(args.queue, args.tier, args.division,
                         args.score, args.season, args.note)
    else:
        queue, tier, division, score, season, note = prompt_entry()
        insert_snapshot(queue, tier, division, score, season, note)

    print("Snapshot saved.")

if __name__ == "__main__":
    main()

Run it with python entry.py for the interactive prompts, or script it with flags for automation later, for example python entry.py --auto --queue tank --tier Diamond --division 3 --score 33 --season season-4. A real run looks like this:

$ python entry.py
Queues: tank, damage, support, open
Queue: damage
Tiers: Bronze, Silver, Gold, Platinum, Emerald, Diamond, Master, Grandmaster, Champion, Top500
Tier: Emerald
Division (1-5, blank for Top 500): 2
Challenger Score (optional, blank to skip):
Season label (e.g. season-4): season-4
Note (optional): won a 5-game streak after switching to Sombra
Snapshot saved.

Step 5: Track Role Queue and Open Queue Separately

Because Overwatch 2 keeps four independent ranks per account, every query you write from here on needs to filter by queue. Add a small helper to db.py that pulls the full history for one queue at a time, which every downstream script (chart, delta calculator, CSV export) will call:

def get_history(queue, season=None):
    conn = get_connection()
    if season:
        rows = conn.execute(
            "SELECT * FROM snapshots WHERE queue = ? AND season = ? ORDER BY logged_at",
            (queue, season)
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT * FROM snapshots WHERE queue = ? ORDER BY logged_at",
            (queue,)
        ).fetchall()
    conn.close()
    return rows

This is also the point to decide how much you care about each queue. Most players log Role Queue ranks after nearly every session but only check Open Queue once a week. That’s fine, the schema doesn’t require even sampling across queues, and a sparse Open Queue table won’t break any of the charting or delta logic below.

Step 6: Calculate Challenger Score Delta and Climb Velocity

Raw tier and division labels are useful, but a number that tells you whether you’re climbing faster or slower than last week is more useful. Since tier and division alone aren’t linearly comparable across the whole ladder, this function converts a snapshot into a single sortable rank index, then measures how many indices you moved between two dates.

from db import TIER_ORDER

def rank_index(tier, division):
    tier_pos = TIER_ORDER.index(tier)
    div = division if division else 0
    # each tier has 5 divisions, division 5 lowest, division 1 highest
    return tier_pos * 5 + (5 - div if div else 5)

def climb_velocity(rows):
    if len(rows) < 2:
        return None
    first, last = rows[0], rows[-1]
    idx_first = rank_index(first[3], first[4])
    idx_last = rank_index(last[3], last[4])
    delta = idx_last - idx_first
    days = (
        __import__("datetime").datetime.fromisoformat(last[1])
        - __import__("datetime").datetime.fromisoformat(first[1])
    ).days or 1
    return round(delta / days, 3)

A positive velocity means you’re climbing, a negative one means you’re deranking faster than you’re recovering. Printing this after every entry gives you a faster signal than staring at the in-game tier badge, especially in Diamond and above where the Challenger Score inside a single division still moves session to session.

Step 7: Chart Your Rank Progress Over Time

A line chart makes seasonal trends obvious in a way a table of rows never does. Create chart.py:

import matplotlib.pyplot as plt
from db import get_history
from analytics import rank_index

def plot_progress(queue, season=None, out_file="progress.png"):
    rows = get_history(queue, season)
    if not rows:
        print("No snapshots found for", queue)
        return

    dates = [r[1][:10] for r in rows]
    indices = [rank_index(r[3], r[4]) for r in rows]

    plt.figure(figsize=(10, 5))
    plt.plot(dates, indices, marker="o", linewidth=2)
    plt.title(f"Overwatch 2 {queue.title()} Rank Progress")
    plt.xlabel("Date")
    plt.ylabel("Rank Index (higher = better)")
    plt.xticks(rotation=45, ha="right")
    plt.tight_layout()
    plt.savefig(out_file, dpi=150)
    print("Chart saved to", out_file)

if __name__ == "__main__":
    plot_progress("damage", season="season-4")

Run python chart.py and open progress.png. Every dip in the line marks a losing streak or a placement match, and once you have three or four weeks of data the shape of your climb becomes obvious in a way individual snapshots hide.

Step 8: Wire Up Discord Webhook Alerts

In Discord, go to a channel’s settings, open Integrations, and create a webhook. Copy the URL into your .env file as DISCORD_WEBHOOK_URL=your_url_here. Then create discord_alert.py:

import os
import requests
from dotenv import load_dotenv

load_dotenv()
WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL")

def send_rank_alert(queue, old_tier, old_div, new_tier, new_div):
    if not WEBHOOK_URL:
        print("No webhook configured, skipping alert.")
        return

    direction = "ranked up" if new_tier != old_tier or (new_div or 0) < (old_div or 0) else "ranked down"
    message = (
        f"**Overwatch 2 {queue.title()}** {direction}: "
        f"{old_tier} {old_div or ''} -> {new_tier} {new_div or ''}"
    )

    response = requests.post(WEBHOOK_URL, json={"content": message}, timeout=10)
    if response.status_code != 204:
        print("Webhook failed:", response.status_code, response.text)
    else:
        print("Alert sent.")

A successful call returns HTTP 204 with no body, which is normal for Discord webhooks and not an error. Call send_rank_alert() from run.py right after you detect a tier or division change between the two most recent snapshots for a queue.

Step 9: Automate Snapshots With Cron

Manual entry works, but a scheduled reminder keeps the data consistent. On macOS or Linux, add a cron entry that runs a lightweight reminder script rather than trying to auto-detect your rank, since that still requires you to check the client:

# crontab -e
# Reminder every evening at 9pm to log today's rank
0 21 * * * /path/to/ow2-rank-tracker/venv/bin/python /path/to/ow2-rank-tracker/reminder.py

On Windows, use Task Scheduler instead: create a Basic Task, set the trigger to Daily at 9:00 PM, and point the action at your venv’s python.exe with reminder.py as the argument. A minimal reminder.py just posts a Discord message asking you to run entry.py, which keeps the automation simple and avoids trying to scrape a rank value that Blizzard doesn’t expose reliably.

Step 10: Export Season Reports to CSV

At the end of a season, you’ll want a portable record separate from the SQLite file. Add export.py:

import csv
from db import get_connection

def export_season(season, out_file="season_report.csv"):
    conn = get_connection()
    rows = conn.execute(
        "SELECT logged_at, queue, tier, division, challenger_score, note "
        "FROM snapshots WHERE season = ? ORDER BY queue, logged_at",
        (season,)
    ).fetchall()
    conn.close()

    with open(out_file, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["Date", "Queue", "Tier", "Division", "Challenger Score", "Note"])
        writer.writerows(rows)

    print(f"Exported {len(rows)} rows to {out_file}")

if __name__ == "__main__":
    export_season("season-4")

Run python export.py after Blizzard closes out a season and archive the CSV. It’s small, readable in a spreadsheet, and survives even if you eventually rebuild the whole project from scratch.

Step 11: Handle Season Resets and Placement Matches

Every new competitive season, Overwatch 2 pushes you back through placement matches, 10 per role and queue based on the pattern set by the February 2025 rank-reset patch. Your tracker needs to treat this as a normal event, not an error. Two adjustments handle it cleanly. First, always tag snapshots with a season label, which your chart and export functions already filter on, so a placement dip doesn’t get plotted against the prior season’s climb. Second, skip velocity calculations for the first 10 entries of a new season, since placement results swing wildly and a velocity number computed across them is close to meaningless.

def is_still_placing(rows, placement_count=10):
    return len(rows) < placement_count

Call this before climb_velocity() and print a “still in placements” message instead of a velocity figure until the player clears their tenth game of the season.

Step 12: The Complete Working Project

Here’s run.py, the script that ties every module above into one command you’ll actually use day to day:

import argparse
from db import init_db, insert_snapshot, get_history
from analytics import rank_index, climb_velocity, is_still_placing
from chart import plot_progress
from export import export_season
from discord_alert import send_rank_alert

def log_and_check(queue, tier, division, score, season, note):
    init_db()
    history_before = get_history(queue, season)
    insert_snapshot(queue, tier, division, score, season, note)
    history_after = get_history(queue, season)

    if history_before:
        old = history_before[-1]
        if old[3] != tier or (old[4] or 0) != (division or 0):
            send_rank_alert(queue, old[3], old[4], tier, division)

    if is_still_placing(history_after):
        print(f"Still in placements: {len(history_after)}/10 games logged.")
    else:
        velocity = climb_velocity(history_after)
        print(f"Climb velocity: {velocity} rank-index points/day")

def main():
    parser = argparse.ArgumentParser(description="Overwatch 2 rank tracker")
    sub = parser.add_subparsers(dest="command", required=True)

    log_cmd = sub.add_parser("log")
    log_cmd.add_argument("--queue", required=True)
    log_cmd.add_argument("--tier", required=True)
    log_cmd.add_argument("--division", type=int)
    log_cmd.add_argument("--score", type=int)
    log_cmd.add_argument("--season", required=True)
    log_cmd.add_argument("--note", default="")

    chart_cmd = sub.add_parser("chart")
    chart_cmd.add_argument("--queue", required=True)
    chart_cmd.add_argument("--season")

    export_cmd = sub.add_parser("export")
    export_cmd.add_argument("--season", required=True)

    args = parser.parse_args()

    if args.command == "log":
        log_and_check(args.queue, args.tier, args.division,
                       args.score, args.season, args.note)
    elif args.command == "chart":
        plot_progress(args.queue, args.season)
    elif args.command == "export":
        export_season(args.season)

if __name__ == "__main__":
    main()

With everything in place, three commands cover your whole season: python run.py log --queue damage --tier Emerald --division 2 --season season-4 to record a snapshot, python run.py chart --queue damage --season season-4 to render the progress graph, and python run.py export --season season-4 to archive the season as a CSV once it closes.

What Else Your Overwatch 2 Tracker Can Log

The core schema covers tier, division, and Challenger Score, but the note column is doing more work than it looks like once you start using it consistently. A few fields players commonly add once the base tracker is running for a couple of weeks:

  • Hero played, so you can later group climb velocity by main and see which roles or heroes actually correlate with rank gains
  • Win/loss streak length, logged at the moment you check your rank, useful for spotting whether long losing streaks precede a derank or follow one
  • Queue time in minutes, which tends to spike at Emerald and Diamond during off-peak hours and can explain a stalled climb that has nothing to do with performance
  • Session length, since a lot of players lose more rank in the last hour of a long session than they gain in the first two
  • Patch version, so a sudden shift in climb velocity can be cross-referenced against a balance patch instead of assumed to be a skill change

None of these require a schema migration beyond adding a column and a prompt in entry.py. Add them incrementally instead of building all five in at once, since the value comes from having consistent data over several weeks, not from the number of fields you’re capturing on day one.

Common Pitfalls When Building an Overwatch 2 Tracker

PitfallWhy It HappensFix
Mixing queues in one chartForgetting to filter by queue in a queryAlways pass queue to get_history(), never query the raw table directly
Comparing across season resetsVelocity math treats a placement reset as a massive derankFilter every calculation by season and use is_still_placing()
Storing division as textSorting “1”, “10”, “2” alphabetically instead of numericallyKeep division as an INTEGER column, never a string
Hardcoding a scrape targetRelying on an undocumented profile page structure that Blizzard can change without noticeDefault to manual entry, treat any scrape as a bonus, optional layer
Skipping Top 500 handlingCode assumes every rank has a division 1 through 5Allow division to be NULL and branch on tier == “Top500”
Committing the webhook URLPasting the Discord URL directly into a script instead of .envLoad it with python-dotenv and add .env to .gitignore

Troubleshooting Guide

  • “sqlite3.OperationalError: no such table: snapshots” means you skipped init_db(). Run python db.py once before anything else.
  • Chart shows a flat line usually means every row shares the same season value by coincidence, or you only have one snapshot logged. Log at least three or four entries before judging the chart.
  • Discord webhook returns 401 means the URL is wrong or the webhook was deleted from the channel. Regenerate it in Discord’s Integrations settings and update .env.
  • Discord webhook returns 429 means you hit Discord’s rate limit, usually from testing too many alerts back to back. Wait a few seconds between calls during testing.
  • Velocity always prints None means get_history() is returning fewer than two rows for that queue and season. Confirm you’re passing the exact same season string used at entry time, since it’s case-sensitive.
  • matplotlib fails with “no display name” on a headless server means it’s trying to open an interactive window. Add matplotlib.use("Agg") at the top of chart.py before importing pyplot.
  • CSV export is empty almost always means the season string in export_season() doesn’t match what you typed during logging. Print distinct seasons with a quick query to check: SELECT DISTINCT season FROM snapshots.
  • python-dotenv doesn’t load the webhook usually means .env is in the wrong directory. It must sit next to the script you’re running, or you need to pass an explicit path to load_dotenv().
  • Rank index looks backwards if a higher tier shows a lower index than a lower tier. Double-check that TIER_ORDER in db.py lists Bronze first and Champion last, since rank_index() depends on that exact order.

Advanced Tips for a Better Tracker

Once the base project runs reliably, a few extensions make it genuinely useful over a full season. Add a hero column to log which hero you queued as, then group your climb velocity by hero to see which picks actually correlate with rank gains rather than just win rate. Layer in queue time by prompting for it during entry, since long Damage queues at Emerald and above often correlate with account-wide matchmaking congestion rather than anything about your play. If you want a dashboard instead of a static PNG, swap matplotlib for a small Flask app that reads from the same SQLite file and renders an interactive Plotly chart in the browser.

For players tracking multiple accounts (a smurf or a duo partner’s account, for example), add an account column and filter every query by it the same way you filter by queue. And if you eventually decide to explore Blizzard’s official Battle.net OAuth 2.0 flow described in their developer documentation, the general pattern follows the standard OAuth 2.0 authorization code flow: register a client, redirect the user to authorize, exchange the code for a token, then call whatever profile endpoints Blizzard exposes for your app’s scope. Treat that as a research project alongside the tracker, not a replacement for the manual-entry core, since competitive rank specifically isn’t guaranteed to be in scope.

If you play CS2 or Valorant alongside Overwatch 2 and want to compare how the tier systems differ before deciding where to invest tracking effort, the ladder structures diverge more than they look on the surface. Our CS2 ranks breakdown covers a Premier-based numeric system that behaves nothing like Overwatch’s tier-and-division model, and browsing the wider esports coverage on the site has season-by-season breakdowns for most major competitive shooters if you’re building trackers for more than one game.

The whole project leans on Python’s standard library plus three small packages. If any function in this tutorial throws an error you can’t place, check the argument list against the official docs for requests first, since a mismatched keyword argument there is the most common source of a traceback that otherwise looks like a Discord or Blizzard problem.

How This Compares to Manual Rank-Checking

It’s fair to ask whether any of this beats just opening the client and glancing at your rank. For a single session, it doesn’t, checking in-game takes five seconds. The value shows up over weeks. A player who logs every session for a full Overwatch 2 season ends up with a dataset that answers questions the client’s UI can’t: which week had the fastest climb, whether Support climbed faster than Damage this season, and how many days it actually took to go from Platinum to Emerald once the new tier landed. Those answers require a history, and Blizzard’s client only shows you the present.

The tradeoff is discipline. A tracker built on manual entry only works if you actually run entry.py after your sessions, which is why Step 9’s cron reminder matters more than it looks like on paper. Players who skip that step tend to abandon the log within two weeks, ending up with the same three data points and none of the season-long picture the chart is built to show.

Frequently Asked Questions

Does Blizzard offer an official API for Overwatch 2 competitive rank?

Blizzard’s Battle.net developer platform covers account and general game data through OAuth 2.0, but there is no documented, stable public endpoint specifically for Overwatch 2 competitive tier and division data. Most third-party stat sites rely on public profile pages rather than a dedicated competitive-rank API, which is why this tutorial defaults to manual entry as the reliable core.

How many tiers does Overwatch 2 have in the current season?

As of September 2026, guides tracking the live season describe nine earned tiers, Bronze, Silver, Gold, Platinum, Emerald, Diamond, Master, Grandmaster, and Champion, plus Top 500 above them. Emerald is the newest addition, inserted between Platinum and Diamond in an August 2026 patch.

How many placement matches does Overwatch 2 require?

Per Blizzard’s competitive rank-reset patch from February 2025, players complete 10 placement matches for each Role Queue role, Tank, Damage, and Support, and 10 for Open Queue, before receiving a new tier and division.

Can I track Role Queue and Open Queue ranks in the same database?

Yes. The schema in this tutorial stores every snapshot with a queue column, so Tank, Damage, Support, and Open Queue history all live in one snapshots table and get filtered per queue at query time.

Why does my Challenger Score field stay empty below Diamond?

Blizzard’s published Challenger Score tables only cover Diamond and above. Below Diamond, tier and division are the only rank signals available, so the tracker’s SCORED_TIERS check skips the Challenger Score prompt for Bronze through Platinum and Emerald.

Is scraping my own Overwatch 2 profile page against the rules?

Scraping your own public profile for personal use is generally low-risk, but the page structure is not a stable, documented API and can change without notice, which is exactly why this tutorial treats it as an optional add-on rather than the primary data source. Always check Blizzard’s current terms of service before automating requests against any of their pages.

What’s the easiest way to back up my rank history?

Copy tracker.db to cloud storage on a schedule, or run export.py at the end of each season and keep the resulting CSV files alongside the database. SQLite databases are single portable files, so a simple scheduled copy is enough for most players. See SQLite’s own documentation for backup strategies if you want something more resilient than a file copy.

Can I adapt this tracker for other competitive shooters?

Yes, the schema and scripts are generic enough to reuse. Swap the TIER_ORDER list for whatever ladder the other game uses, and the SQLite schema, chart generator, Discord alerts, and CSV export all work unchanged. Check the same manual-entry-first approach used for the FACEIT level checker if you want a second reference implementation.