One more thing worth deciding before you write a single line of code: how far back you actually want this archive to go. If the goal is just “keep Chapter 7 straight going forward,” you can start seeding from the current chapter and let the weekly refresh carry you forward from here. If you want a full history back to Chapter 1 in 2017, budget extra time for sourcing those older dates, since the further back you go, the harder it gets to find two independent, cross-checked sources that agree. Scoping this decision up front saves you from a half-finished archive that stalls out on Chapter 3 because the historical research turned out to be the actual bottleneck, not the code.

Step 1: Set Up the Project and Virtual Environment

Start by creating an isolated project folder so this doesn’t collide with any other Python work on your machine. A virtual environment keeps the Flask and requests versions pinned to what this tutorial expects, even if you upgrade Python globally later.

mkdir fortnite-seasons-archive
cd fortnite-seasons-archive
python3 -m venv venv

# Activate it
source venv/bin/activate      # macOS/Linux
# venv\Scripts\activate       # Windows

pip install flask requests
pip freeze > requirements.txt

Create three subfolders now to keep the project organized as it grows: data/ for the SQLite file and CSV backups, scripts/ for the ingestion and export scripts, and templates/ for the Flask HTML views. This structure matters more than it looks once you start automating the weekly refresh in Step 10, since a flat folder gets messy fast.

Windows users should activate the virtual environment with venv\Scripts\activate in PowerShell or Command Prompt, not the Bash syntax shown above. If PowerShell blocks the activation script with an execution-policy error, run Set-ExecutionPolicy -Scope Process RemoteSigned once in that session and try again. macOS and Linux users generally don’t hit this, but it trips up first-time Windows contributors on almost every Python tutorial, not just this one.

Step 2: Design the Season Database Schema

The schema is the part most tutorials rush, and it’s exactly where a seasons archive falls apart later. Store both the raw source date and a confidence flag, because published season dates for the same Chapter 7 season can differ by a day depending on whether a tracker counts the server-restart date or the patch-notes date. Don’t silently pick one. Store both and let your comparison logic decide.

-- schema.sql
CREATE TABLE IF NOT EXISTS seasons (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    chapter INTEGER NOT NULL,
    season_number INTEGER NOT NULL,
    subtitle TEXT,
    start_date TEXT NOT NULL,       -- ISO 8601, e.g. 2026-08-20
    end_date TEXT,                  -- NULL if season is still active
    duration_days INTEGER,          -- calculated, not hand-entered
    battle_pass_theme TEXT,
    date_confidence TEXT,           -- 'official', 'secondary', 'estimated'
    source_url TEXT,
    last_synced TEXT
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_chapter_season
ON seasons (chapter, season_number);

Run this against a fresh database file with the built-in sqlite3 CLI: sqlite3 data/seasons.db < scripts/schema.sql. The unique index on chapter plus season number is what prevents duplicate rows the next time your refresh job runs, which is a pitfall covered further down.

A quick note on why each column earns its place. date_confidence exists because two reasonable people looking at the same season transition can disagree about which calendar day it "really" started on. source_url exists so that six months from now, when you can't remember why a date looks off, you can click through and check instead of guessing. And last_synced exists so your dashboard can eventually show a "data last checked" timestamp, which is a small thing that does a lot to build trust in a hobby project like this one.

Step 3: Pull Season Data From the Fortnite-API Community Endpoint

Fortnite-API exposes current season and cosmetic data as JSON, refreshed as Epic pushes updates. Because it's a community project rather than an official Epic Games product, field names occasionally shift between versions, so write your parser defensively with .get() instead of direct key access. A missing field should degrade gracefully, not crash your ingestion job at 3 a.m.

This defensive pattern matters more here than in a typical API integration. A commercial vendor publishes a changelog and often keeps a versioned endpoint stable for years. A volunteer-maintained project can restructure a response shape in a single commit because a contributor thought the new layout made more sense. Neither approach is wrong, but your ingestion script needs to survive the second scenario without you noticing at 6 a.m. that your cron job has been silently failing for a week.

# scripts/fetch_current_season.py
import requests
import json

API_BASE = "https://fortnite-api.com/v2"

def fetch_current_status():
    resp = requests.get(f"{API_BASE}/status", timeout=10)
    resp.raise_for_status()
    return resp.json()

def fetch_news():
    resp = requests.get(f"{API_BASE}/news/br", timeout=10)
    resp.raise_for_status()
    return resp.json()

if __name__ == "__main__":
    status = fetch_current_status()
    news = fetch_news()

    # Defensive access — the community schema can change
    server_status = status.get("data", {}).get("status", "unknown")
    print(f"Fortnite service status: {server_status}")

    with open("data/latest_news_raw.json", "w") as f:
        json.dump(news, f, indent=2)

For the manual seed rows, a small insert script keeps things reproducible instead of typing SQL by hand in a terminal each time:

# scripts/seed_chapter7.py
import sqlite3

SEED_ROWS = [
    (7, 1, "Pacific Break", "2025-11-29", "2026-03-19", "Tropical/heist theme", "secondary", "https://fortnite-api.com/documentation"),
    (7, 2, "Showdown", "2026-03-19", "2026-06-06", "Competitive/combat theme", "secondary", "https://fortnite-api.com/documentation"),
    (7, 3, "Runners", "2026-06-06", "2026-08-20", "Speed/movement theme", "secondary", "https://fortnite-api.com/documentation"),
    (7, 4, "Override", "2026-08-20", None, "Gaming-icons crossover", "secondary", "https://fortnite-api.com/documentation"),
]

conn = sqlite3.connect("data/seasons.db")
conn.executemany(
    """INSERT OR REPLACE INTO seasons
       (chapter, season_number, subtitle, start_date, end_date,
        battle_pass_theme, date_confidence, source_url)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
    SEED_ROWS,
)
conn.commit()
conn.close()
print(f"Seeded {len(SEED_ROWS)} rows")

Notice the date_confidence value is set to "secondary" rather than "official" for all four rows. That's deliberate. None of these came from a first-party Epic Games press release with an exact timestamp, they came from cross-referenced season listings. Reserve "official" for dates you've confirmed against an Epic Games source directly, and keep everything else labeled honestly.

Fortnite-API's live endpoints are strong for current status and cosmetics, but they aren't a full structured history of season start and end dates going back to Chapter 1. For historical rows, seed your database manually from cross-checked, source-linked entries. That's what the source_url and date_confidence columns from Step 2 are for. Treat the API as a live-status feed and your own curated seed data as the archive's backbone.

Step 4: Normalize Dates and Calculate Season Length

Never hand-type a season's duration. Calculate it from the start and end dates every time you insert or update a row, so a corrected end date automatically recalculates length instead of leaving a stale number sitting in the table.

# scripts/date_utils.py
from datetime import date, datetime

def parse_iso_date(date_str: str) -> date:
    return datetime.strptime(date_str, "%Y-%m-%d").date()

def season_duration_days(start: str, end: str | None) -> int | None:
    if not end:
        return None  # season is still active
    start_d = parse_iso_date(start)
    end_d = parse_iso_date(end)
    return (end_d - start_d).days

# Example: Chapter 7 Season 4 ("Override")
print(season_duration_days("2026-08-20", None))  # -> None, active season

Store dates as plain YYYY-MM-DD strings in UTC, not local time. Fortnite seasons typically flip in the early-morning hours across multiple time zones, and if your archive mixes UTC and local timestamps, two entries for the same season transition can end up a day apart. That's exactly the kind of discrepancy this whole project is trying to eliminate.

If you later want to display dates in a visitor's local timezone on the Flask dashboard, do the conversion at render time with zoneinfo, not at storage time. Keep the database itself in UTC as the single source of truth, and let the presentation layer handle the translation. Mixing storage-time and display-time conversions in the same function is a reliable way to introduce an off-by-one-day bug that only shows up for users in certain time zones, which makes it painful to reproduce and debug later.

Step 5: Build the Battle Pass Comparison Table

Once a handful of seasons are seeded, the payoff shows up immediately: a clean, sortable comparison instead of scrolling through four different wiki pages. Here's what Chapter 7's four seasons look like once normalized, based on currently published season listings as of September 25, 2026 (note that sources differ by a day or two on exact boundaries, which is precisely why the date_confidence column exists):

SeasonStart DateEnd / Battle Pass CloseApprox. DurationTheme
Chapter 7, Season 1 ("Pacific Break")Nov 29, 2025Mar 19, 2026~95 daysTropical/heist theme
Chapter 7, Season 2 ("Showdown")Mar 19, 2026Jun 5-6, 2026~78-79 daysCompetitive/combat theme
Chapter 7, Season 3 ("Runners")Jun 6, 2026Aug 20, 2026~75-77 daysSpeed/movement theme
Chapter 7, Season 4 ("Override")Aug 20, 2026Nov 1, 2026 (scheduled)~73-74 daysGaming-icons crossover

The duration ranges reflect the same start/end-inclusive counting ambiguity mentioned earlier. Your database should store the raw dates and let the app calculate one consistent number using whichever convention you pick in Step 4, rather than copying pre-calculated "days" figures from a source that might use a different rule than you do.

Line them up and a pattern shows up that a plain list would hide: each Chapter 7 season so far has run a little shorter than the one before it, from roughly 95 days for "Pacific Break" down to roughly 73 to 74 days for the current "Override" season. That's not a huge sample to draw conclusions from, only four data points, but it's the kind of trend an archive surfaces automatically once you have three or four chapters loaded. A single wiki page listing dates in a table doesn't invite that comparison nearly as naturally as a database you can query and sort.

Step 6: Track Map and Mechanic Changes Across Chapters

Map and mechanic changes are the hardest part of a seasons archive to source reliably, because unlike start dates, there's rarely one canonical announcement listing every point-of-interest change, new vehicle, and movement mechanic for a season. Add a companion table instead of cramming everything into the seasons table:

CREATE TABLE IF NOT EXISTS season_changes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    season_id INTEGER NOT NULL,
    change_type TEXT,      -- 'map', 'mechanic', 'vehicle', 'poi'
    description TEXT,
    source_url TEXT,
    FOREIGN KEY (season_id) REFERENCES seasons (id)
);

Only add a row here once you have a source URL to attach to it. It's tempting to fill this table from memory or a half-remembered recap video, but an archive that mixes verified and unverified entries with no way to tell them apart is worse than an incomplete one. You can always add rows later as you cross-check them.

A practical way to populate this table without it turning into a chore: keep a running note (a plain text file works fine) every time you personally notice a map change while playing, then batch-convert those notes into rows with source links once a week during the same session as your scheduled refresh in Step 10. Trying to backfill months of mechanic changes from memory in one sitting is how this table ends up empty forever. Little and often beats a marathon session that never happens.

Step 7: Build the Season Comparison Scorecard

This is the feature that separates an archive from a plain spreadsheet: pick any two seasons and get a structured diff. The function below pulls both rows and returns a comparison dictionary the Flask dashboard can render.

# scripts/compare.py
import sqlite3

def get_season(conn, chapter, season_number):
    cur = conn.execute(
        "SELECT * FROM seasons WHERE chapter=? AND season_number=?",
        (chapter, season_number),
    )
    return cur.fetchone()

def compare_seasons(conn, season_a, season_b):
    a = get_season(conn, *season_a)
    b = get_season(conn, *season_b)
    if not a or not b:
        raise ValueError("One or both seasons not found in archive")

    change_count_a = conn.execute(
        "SELECT COUNT(*) FROM season_changes WHERE season_id=?", (a["id"],)
    ).fetchone()[0]
    change_count_b = conn.execute(
        "SELECT COUNT(*) FROM season_changes WHERE season_id=?", (b["id"],)
    ).fetchone()[0]

    return {
        "season_a": dict(a),
        "season_b": dict(b),
        "duration_diff_days": (a["duration_days"] or 0) - (b["duration_days"] or 0),
        "logged_changes_a": change_count_a,
        "logged_changes_b": change_count_b,
    }

Open the database connection with row_factory = sqlite3.Row before running this, so rows behave like dictionaries and the dict(a) call works cleanly. This one line trips up a surprising number of people copying SQLite snippets from tutorials that skip it.

The scorecard above only compares duration and logged change counts, but the same pattern extends to anything you've stored: Battle Pass theme overlap, number of source-linked map changes, or a custom "hype score" if you decide to add one later. The point of returning a plain dictionary instead of pre-formatted text is that the Flask template in the next step can render it however makes sense, and you can also expose the same comparison as JSON for anyone who wants to build their own front end on top of your data.

Step 8: Create a Lightweight Web Dashboard With Flask

You don't need a heavy front-end framework for this. A single-file Flask app with two routes, a season list and a comparison view, covers the core use case and stays easy to maintain.

# app.py
import sqlite3
from flask import Flask, render_template, request

app = Flask(__name__)
DB_PATH = "data/seasons.db"

def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn

@app.route("/")
def index():
    conn = get_db()
    seasons = conn.execute(
        "SELECT * FROM seasons ORDER BY chapter, season_number"
    ).fetchall()
    conn.close()
    return render_template("index.html", seasons=seasons)

@app.route("/compare")
def compare():
    from scripts.compare import compare_seasons
    conn = get_db()
    a = tuple(map(int, request.args.get("a", "7,1").split(",")))
    b = tuple(map(int, request.args.get("b", "7,4").split(",")))
    result = compare_seasons(conn, a, b)
    conn.close()
    return render_template("compare.html", result=result)

if __name__ == "__main__":
    app.run(debug=True, port=5000)

You'll need a minimal template for the season list to render. Save this as templates/index.html:

<!-- templates/index.html -->
<h1>Fortnite Seasons Archive</h1>
<table>
  <tr><th>Chapter</th><th>Season</th><th>Subtitle</th><th>Start</th><th>End</th></tr>
  {% for s in seasons %}
  <tr>
    <td>{{ s.chapter }}</td>
    <td>{{ s.season_number }}</td>
    <td>{{ s.subtitle }}</td>
    <td>{{ s.start_date }}</td>
    <td>{{ s.end_date or "Active" }}</td>
  </tr>
  {% endfor %}
</table>

Run it with python app.py and open http://127.0.0.1:5000. You should see every seeded season listed in chapter and season order. Leave debug=True on while building locally since it gives you a live reloader and readable stack traces, but turn it off before deploying anywhere reachable from outside your machine, because debug mode exposes an interactive code console.

Step 9: Add Search, Filtering, and Sorting

Once you've backfilled a few chapters, a flat list stops being useful. Add query-string filtering so visitors, or future you, can narrow by chapter or search by subtitle without touching the database directly. This also sets up the foundation for the public JSON endpoint mentioned in the advanced tips section further down, so it's worth getting the query-parameter design right the first time rather than bolting filtering on as an afterthought later.

@app.route("/api/seasons")
def api_seasons():
    conn = get_db()
    chapter = request.args.get("chapter", type=int)
    query = request.args.get("q", "").strip().lower()

    sql = "SELECT * FROM seasons WHERE 1=1"
    params = []
    if chapter:
        sql += " AND chapter = ?"
        params.append(chapter)
    if query:
        sql += " AND LOWER(subtitle) LIKE ?"
        params.append(f"%{query}%")
    sql += " ORDER BY chapter, season_number"

    rows = conn.execute(sql, params).fetchall()
    conn.close()
    return {"count": len(rows), "seasons": [dict(r) for r in rows]}

Hitting /api/seasons?chapter=7 returns a clean JSON payload you can also feed into a spreadsheet or a separate front end later:

{
  "count": 4,
  "seasons": [
    {
      "chapter": 7,
      "season_number": 4,
      "subtitle": "Override",
      "start_date": "2026-08-20",
      "end_date": null,
      "duration_days": null,
      "battle_pass_theme": "Gaming-icons crossover",
      "date_confidence": "secondary"
    }
  ]
}

Parameterized queries, the ? placeholders above, aren't optional here. Concatenating the search string directly into the SQL is the single most common way a small side project like this turns into a SQL injection demo, even on a database only you can reach locally.

Add sorting the same way: accept an optional sort query parameter, validate it against a fixed allowlist of column names (never interpolate a raw column name from user input into an ORDER BY clause), and default to chronological order when nothing is specified. A short allowlist like {"chapter", "season_number", "duration_days"} is enough for this project and closes off another class of injection risk that parameterized values alone don't cover, since placeholders work for values but not for column or table names.

Step 10: Automate Weekly Refreshes and Back Up the Archive

A seasons archive that never updates is just a static page. Schedule the ingestion script to check for status changes weekly, and export a backup every time it runs so a bad write never costs you the whole dataset.

# scripts/export_backup.py
import sqlite3
import csv
import json
from datetime import datetime, timezone

def export_all(db_path="data/seasons.db"):
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    rows = [dict(r) for r in conn.execute("SELECT * FROM seasons").fetchall()]
    conn.close()

    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    with open(f"data/backup_{stamp}.json", "w") as f:
        json.dump(rows, f, indent=2)

    with open(f"data/backup_{stamp}.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=rows[0].keys())
        writer.writeheader()
        writer.writerows(rows)

if __name__ == "__main__":
    export_all()

Wire it into cron with a weekly schedule (double-check the syntax against crontab.guru before saving):

# crontab -e
# Runs every Monday at 09:00
0 9 * * 1 cd /path/to/fortnite-seasons-archive && venv/bin/python scripts/fetch_current_season.py && venv/bin/python scripts/export_backup.py >> logs/refresh.log 2>&1

On Windows, Task Scheduler does the same job. Point it at a batch file that activates the virtual environment and runs both scripts in sequence. Either way, keep the JSON and CSV backups outside the repo's working database file, so a corrupted seasons.db is always recoverable from the last export.

Weekly is a deliberate choice, not an arbitrary default. Fortnite seasons run for roughly ten to fourteen weeks based on the Chapter 7 data above, so a season transition is never going to slip through unnoticed between two weekly checks. Polling daily or hourly wouldn't catch changes any sooner in practice, since Epic Games doesn't announce season transitions with that kind of lead time, and it would burn through your Fortnite-API rate limit for no real benefit. Match your refresh cadence to how often the underlying data actually changes, not to how often you're curious.

Common Pitfalls When Building a Fortnite Seasons Archive

A few mistakes show up over and over in projects like this one. Catching them early saves a painful re-seed of the database later, and most of these come from real trial and error rather than hypothetical edge cases.

  • Mixing date-counting conventions. Deciding whether a season's start day counts as day zero or day one matters. Pick one rule in Step 4 and apply it everywhere, instead of copying pre-calculated durations from sources that may use a different rule.
  • Trusting a single wiki entry as ground truth. Cross-check any start or end date against at least two independent sources before marking a row date_confidence = 'official'.
  • Skipping the unique index on chapter and season number. Without it, a re-run of the ingestion script silently duplicates rows instead of updating them.
  • Hardcoding Battle Pass pricing or reward counts without a source. These details change and get corrected. Store a source_url next to any number you're not fully sure of, or leave the field null.
  • Ignoring API rate limits on Fortnite-API. It's a free community service. Hammering it with a script that reruns every minute instead of weekly is how you get temporarily blocked and lose your data feed.
  • Storing local time instead of UTC. Season transitions happen at a fixed UTC moment, and local-time storage makes cross-region comparisons drift by hours or a full day.
  • Building the dashboard before the database is stable. It's tempting to jump straight to Flask because a web page feels like real progress. Get the schema, seed data, and comparison logic solid first, since a front end built on top of a shifting schema means rewriting templates every time you tweak a column name.

Troubleshooting Guide

Most issues with this build fall into a short, predictable list. None of these are exotic, and almost all of them show up in the first week of running the project rather than months later, so it's worth reading through the whole table once before you hit any of them in practice. Here's what tends to go wrong and the fastest fix for each:

IssueLikely CauseFix
requests.exceptions.ConnectionErrorFortnite-API is temporarily down or rate-limiting your IPCheck status.epicgames.com for broader outages, and add retry logic with exponential backoff
sqlite3.OperationalError: database is lockedTwo processes, such as the Flask dev server and a cron script, writing at onceClose one connection before opening another, and avoid running the refresh script while the dashboard is mid-write
Flask: Address already in usePort 5000 already bound by another process or macOS AirPlay ReceiverRun with flask run --port 5001 or disable AirPlay Receiver in System Settings
KeyError on API response fieldsFortnite-API's community schema changed a field nameSwitch to .get() access everywhere and log unexpected shapes instead of crashing
Cron job never runsScript uses a relative path or the venv isn't activatedUse absolute paths in the crontab line and call venv/bin/python directly, not just python
ValueError parsing a dateSource date isn't in strict YYYY-MM-DD formatNormalize incoming dates in a single ingestion function before they ever reach the database
Duplicate season rows after a refreshMissing the unique index from Step 2Add UNIQUE(chapter, season_number) and use INSERT OR REPLACE in your ingestion script
API returns HTTP 429Too many requests in a short windowAdd a free Fortnite-API key for higher limits, and cache responses locally between runs
ModuleNotFoundError: flaskVirtual environment not activated before running app.pyRe-run source venv/bin/activate (or the Windows equivalent) before every session
Dashboard shows old data after a refreshBrowser or Flask dev server caching a stale responseHard-refresh the page, and confirm the cron job actually wrote to seasons.db by checking the file's modified timestamp

Advanced Tips for Extending the Archive

Once the core build is stable, a few extensions make it genuinely useful beyond a personal reference. Add a simple chart on the dashboard's front end to visualize season length trends across Chapter 7. Four data points isn't a lot yet, but the pattern becomes obvious once you backfill Chapters 5 and 6. Export the archive as a public read-only JSON endpoint if you want to share it, but rate-limit it yourself before anyone else does it for you by hammering your Flask dev server.

A second useful extension is a changelog view: whenever your weekly refresh script updates a row, instead of overwriting the old value outright, insert it into a small season_history table first. That turns the archive from "what does the data say right now" into "what did the data say last month, and did it change." For a project built specifically to solve the problem of sources silently editing dates without explanation, keeping your own edit history is a natural next step, and it costs you one extra table and a few lines in the ingestion script.

For deployment beyond your own machine, a free tier on a platform that supports Python web apps is enough for a low-traffic reference tool. Just remember SQLite files don't survive most ephemeral filesystem resets on serverless platforms, so either mount persistent storage or switch the backend to a hosted Postgres instance if you plan to keep it running long-term. Finally, consider adding a lightweight email or RSS alert that fires when your weekly refresh detects a season's end_date field flip from null to a real date. That's the exact moment a season officially closes, and it's a useful signal to have without checking manually. If you're also tracking ladder progress alongside season history, pairing this archive with a rank tracker gives you both timelines in one place, and our breakdown of Fortnite Ranked versus FNCS is a useful reference if you're extending the schema to cover competitive seasons too.

For deeper background on Fortnite's broader competitive ecosystem while you're building out the archive's scope, our esports coverage tracks how ranked seasons, tournament formats, and Battle Royale content seasons intersect across titles.

Frequently Asked Questions About Fortnite Seasons

What is the current Fortnite season as of September 2026?

Fortnite is in Chapter 7, Season 4, subtitled "Override," which began August 20, 2026. Its Battle Pass is currently scheduled to close on November 1, 2026, based on published season listings.

How long do Fortnite seasons usually last?

Chapter 7's four seasons so far have run roughly 73 to 95 days each, with Season 1 ("Pacific Break") the longest at around 95 days and Season 4 ("Override") the shortest so far at around 73 to 74 days. Exact figures vary slightly by source depending on date-counting conventions, which is exactly why this tutorial's archive stores raw dates rather than pre-calculated durations.

Is Fortnite-API an official Epic Games product?

No. Fortnite-API is a community-built, unofficial REST API that surfaces publicly available game data. It isn't operated, endorsed, or guaranteed by Epic Games, so build in retry logic and don't depend on it for anything time-critical.

Can this archive cover Chapters 1 through 6, not just Chapter 7?

Yes. The schema from Step 2 isn't chapter-specific. You'll need to seed those earlier rows yourself from cross-checked sources, since this tutorial's verified data focuses on the current Chapter 7 seasons as of publication.

Do I need to pay for a Fortnite-API key?

No. Basic endpoints are free and don't require a key. A free API key raises your rate limits if you're polling more frequently than the weekly schedule used in this tutorial.

Why store both a start date and a "date confidence" field?

Because published Fortnite season dates sometimes differ by a day between sources, depending on whether they count the patch-notes date or the server-restart date. Flagging a row as "official," "secondary," or "estimated" lets your dashboard be honest about certainty instead of presenting every date as equally solid.

What's the fastest way to back up my season data?

Run the export script from Step 10, which writes both a JSON and a CSV snapshot with a UTC timestamp in the filename. Scheduling it weekly via cron, or Task Scheduler on Windows, means you're never more than a week from a clean recovery point.

Can I turn this into a public tool other people can use?

Yes, the /api/seasons endpoint from Step 9 already returns clean JSON. Add rate limiting and switch from SQLite to a hosted database before opening it up to real traffic, since a single-file database isn't built for concurrent public writes.

What's the difference between this archive and a season tracker?

A tracker is built around the present moment: a countdown to the next season, a live progress bar, a "what's happening right now" view. An archive is built around history: every past season stored with a source and a confidence rating, structured so you can compare any two of them later. They can share a database, but they solve different problems, and this tutorial focuses on the archive side specifically.

Every time a new Fortnite season drops, the same argument breaks out in Discord servers and comment sections: how long did the last one actually run, and which chapter had the longest Battle Pass? Fan wikis disagree by a day or two because some count the patch-release date and others count the server-restart date. As of September 25, 2026, Fortnite is four seasons into Chapter 7, with "Override" live since August 20, 2026, and the arguments about season length still haven't stopped.

This tutorial walks through building your own Fortnite seasons archive: a small local database and web dashboard that stores every season in order, calculates duration automatically, and lets you compare any two seasons side by side. It's not a live countdown tool and it's not a next-season predictor (we've covered building a live tracker and forecasting the next drop separately). This is a reference archive you control, so it doesn't silently overwrite history when Epic Games changes a Battle Pass end date or a wiki entry gets edited without a source.

By the end you'll have a working SQLite database, a Python ingestion script, a Flask dashboard with search and filtering, and a scheduled backup job. The whole build takes about 90 minutes if you follow along step by step.

The core problem this solves is simple, even if the fix usually isn't: most "all Fortnite seasons" pages online are static lists someone typed once and rarely updates. When Epic Games shifts a Battle Pass close date, or a season runs long because of a delayed live event, those pages go stale and nobody flags it. A local archive you sync yourself doesn't have that problem, because you control when it refreshes and you can see exactly which fields came from where. That matters more than it sounds like it should once you're three chapters deep and trying to remember whether "Showdown" or "Runners" ran longer.

Prerequisites: Tools, Versions, and Data Sources You'll Need

You don't need a game studio's budget for this. Everything below is free, and most of it you may already have installed. Grab Python 3.12 or 3.13 from python.org, since both versions ship with the zoneinfo module we'll use for timezone-safe date math. You'll also want a free account (optional, but recommended for higher rate limits) with Fortnite-API, a community-run, unofficial REST API that mirrors publicly available in-game data. It is not operated or endorsed by Epic Games, so treat its uptime and schema as best-effort rather than guaranteed.

Why Python and Flask instead of a Node.js or a no-code spreadsheet tool? Mostly because the standard library already covers everything this project needs. SQLite ships built into Python, date handling doesn't require a third-party package, and Flask stays out of the way for a project this size. If you already work in JavaScript daily, the same architecture translates directly to Express and a small SQLite client library. Nothing here is Python-specific in concept, only in the exact syntax of the code samples.

Here's the full toolchain, with the specific versions this tutorial was tested against:

ToolVersion Used HerePurpose
Python3.12 or 3.13Core scripting language for ingestion and the web app
Flask3.1.xLightweight web framework for the dashboard
SQLite3.46+ (bundled with Python)Local, file-based database, no separate server to manage
requestslatest via pipHTTP client for pulling data from Fortnite-API
Fortnite-APIv2 (community, unofficial)Source for current season and cosmetic metadata
cron / Task SchedulerOS-nativeWeekly automated refresh and backup

You'll also want basic comfort with a terminal and about 200MB of free disk space, mostly for the Python virtual environment. No GPU, no cloud account, and no credit card required for the core build.

One more thing worth deciding before you write a single line of code: how far back you actually want this archive to go. If the goal is just "keep Chapter 7 straight going forward," you can start seeding from the current chapter and let the weekly refresh carry you forward from here. If you want a full history back to Chapter 1 in 2017, budget extra time for sourcing those older dates, since the further back you go, the harder it gets to find two independent, cross-checked sources that agree. Scoping this decision up front saves you from a half-finished archive that stalls out on Chapter 3 because the historical research turned out to be the actual bottleneck, not the code.

Step 1: Set Up the Project and Virtual Environment

Start by creating an isolated project folder so this doesn't collide with any other Python work on your machine. A virtual environment keeps the Flask and requests versions pinned to what this tutorial expects, even if you upgrade Python globally later.

mkdir fortnite-seasons-archive
cd fortnite-seasons-archive
python3 -m venv venv

# Activate it
source venv/bin/activate      # macOS/Linux
# venv\Scripts\activate       # Windows

pip install flask requests
pip freeze > requirements.txt

Create three subfolders now to keep the project organized as it grows: data/ for the SQLite file and CSV backups, scripts/ for the ingestion and export scripts, and templates/ for the Flask HTML views. This structure matters more than it looks once you start automating the weekly refresh in Step 10, since a flat folder gets messy fast.

Windows users should activate the virtual environment with venv\Scripts\activate in PowerShell or Command Prompt, not the Bash syntax shown above. If PowerShell blocks the activation script with an execution-policy error, run Set-ExecutionPolicy -Scope Process RemoteSigned once in that session and try again. macOS and Linux users generally don't hit this, but it trips up first-time Windows contributors on almost every Python tutorial, not just this one.

Step 2: Design the Season Database Schema

The schema is the part most tutorials rush, and it's exactly where a seasons archive falls apart later. Store both the raw source date and a confidence flag, because published season dates for the same Chapter 7 season can differ by a day depending on whether a tracker counts the server-restart date or the patch-notes date. Don't silently pick one. Store both and let your comparison logic decide.

-- schema.sql
CREATE TABLE IF NOT EXISTS seasons (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    chapter INTEGER NOT NULL,
    season_number INTEGER NOT NULL,
    subtitle TEXT,
    start_date TEXT NOT NULL,       -- ISO 8601, e.g. 2026-08-20
    end_date TEXT,                  -- NULL if season is still active
    duration_days INTEGER,          -- calculated, not hand-entered
    battle_pass_theme TEXT,
    date_confidence TEXT,           -- 'official', 'secondary', 'estimated'
    source_url TEXT,
    last_synced TEXT
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_chapter_season
ON seasons (chapter, season_number);

Run this against a fresh database file with the built-in sqlite3 CLI: sqlite3 data/seasons.db < scripts/schema.sql. The unique index on chapter plus season number is what prevents duplicate rows the next time your refresh job runs, which is a pitfall covered further down.

A quick note on why each column earns its place. date_confidence exists because two reasonable people looking at the same season transition can disagree about which calendar day it "really" started on. source_url exists so that six months from now, when you can't remember why a date looks off, you can click through and check instead of guessing. And last_synced exists so your dashboard can eventually show a "data last checked" timestamp, which is a small thing that does a lot to build trust in a hobby project like this one.

Step 3: Pull Season Data From the Fortnite-API Community Endpoint

Fortnite-API exposes current season and cosmetic data as JSON, refreshed as Epic pushes updates. Because it's a community project rather than an official Epic Games product, field names occasionally shift between versions, so write your parser defensively with .get() instead of direct key access. A missing field should degrade gracefully, not crash your ingestion job at 3 a.m.

This defensive pattern matters more here than in a typical API integration. A commercial vendor publishes a changelog and often keeps a versioned endpoint stable for years. A volunteer-maintained project can restructure a response shape in a single commit because a contributor thought the new layout made more sense. Neither approach is wrong, but your ingestion script needs to survive the second scenario without you noticing at 6 a.m. that your cron job has been silently failing for a week.

# scripts/fetch_current_season.py
import requests
import json

API_BASE = "https://fortnite-api.com/v2"

def fetch_current_status():
    resp = requests.get(f"{API_BASE}/status", timeout=10)
    resp.raise_for_status()
    return resp.json()

def fetch_news():
    resp = requests.get(f"{API_BASE}/news/br", timeout=10)
    resp.raise_for_status()
    return resp.json()

if __name__ == "__main__":
    status = fetch_current_status()
    news = fetch_news()

    # Defensive access — the community schema can change
    server_status = status.get("data", {}).get("status", "unknown")
    print(f"Fortnite service status: {server_status}")

    with open("data/latest_news_raw.json", "w") as f:
        json.dump(news, f, indent=2)

For the manual seed rows, a small insert script keeps things reproducible instead of typing SQL by hand in a terminal each time:

# scripts/seed_chapter7.py
import sqlite3

SEED_ROWS = [
    (7, 1, "Pacific Break", "2025-11-29", "2026-03-19", "Tropical/heist theme", "secondary", "https://fortnite-api.com/documentation"),
    (7, 2, "Showdown", "2026-03-19", "2026-06-06", "Competitive/combat theme", "secondary", "https://fortnite-api.com/documentation"),
    (7, 3, "Runners", "2026-06-06", "2026-08-20", "Speed/movement theme", "secondary", "https://fortnite-api.com/documentation"),
    (7, 4, "Override", "2026-08-20", None, "Gaming-icons crossover", "secondary", "https://fortnite-api.com/documentation"),
]

conn = sqlite3.connect("data/seasons.db")
conn.executemany(
    """INSERT OR REPLACE INTO seasons
       (chapter, season_number, subtitle, start_date, end_date,
        battle_pass_theme, date_confidence, source_url)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
    SEED_ROWS,
)
conn.commit()
conn.close()
print(f"Seeded {len(SEED_ROWS)} rows")

Notice the date_confidence value is set to "secondary" rather than "official" for all four rows. That's deliberate. None of these came from a first-party Epic Games press release with an exact timestamp, they came from cross-referenced season listings. Reserve "official" for dates you've confirmed against an Epic Games source directly, and keep everything else labeled honestly.

Fortnite-API's live endpoints are strong for current status and cosmetics, but they aren't a full structured history of season start and end dates going back to Chapter 1. For historical rows, seed your database manually from cross-checked, source-linked entries. That's what the source_url and date_confidence columns from Step 2 are for. Treat the API as a live-status feed and your own curated seed data as the archive's backbone.

Step 4: Normalize Dates and Calculate Season Length

Never hand-type a season's duration. Calculate it from the start and end dates every time you insert or update a row, so a corrected end date automatically recalculates length instead of leaving a stale number sitting in the table.

# scripts/date_utils.py
from datetime import date, datetime

def parse_iso_date(date_str: str) -> date:
    return datetime.strptime(date_str, "%Y-%m-%d").date()

def season_duration_days(start: str, end: str | None) -> int | None:
    if not end:
        return None  # season is still active
    start_d = parse_iso_date(start)
    end_d = parse_iso_date(end)
    return (end_d - start_d).days

# Example: Chapter 7 Season 4 ("Override")
print(season_duration_days("2026-08-20", None))  # -> None, active season

Store dates as plain YYYY-MM-DD strings in UTC, not local time. Fortnite seasons typically flip in the early-morning hours across multiple time zones, and if your archive mixes UTC and local timestamps, two entries for the same season transition can end up a day apart. That's exactly the kind of discrepancy this whole project is trying to eliminate.

If you later want to display dates in a visitor's local timezone on the Flask dashboard, do the conversion at render time with zoneinfo, not at storage time. Keep the database itself in UTC as the single source of truth, and let the presentation layer handle the translation. Mixing storage-time and display-time conversions in the same function is a reliable way to introduce an off-by-one-day bug that only shows up for users in certain time zones, which makes it painful to reproduce and debug later.

Step 5: Build the Battle Pass Comparison Table

Once a handful of seasons are seeded, the payoff shows up immediately: a clean, sortable comparison instead of scrolling through four different wiki pages. Here's what Chapter 7's four seasons look like once normalized, based on currently published season listings as of September 25, 2026 (note that sources differ by a day or two on exact boundaries, which is precisely why the date_confidence column exists):

SeasonStart DateEnd / Battle Pass CloseApprox. DurationTheme
Chapter 7, Season 1 ("Pacific Break")Nov 29, 2025Mar 19, 2026~95 daysTropical/heist theme
Chapter 7, Season 2 ("Showdown")Mar 19, 2026Jun 5-6, 2026~78-79 daysCompetitive/combat theme
Chapter 7, Season 3 ("Runners")Jun 6, 2026Aug 20, 2026~75-77 daysSpeed/movement theme
Chapter 7, Season 4 ("Override")Aug 20, 2026Nov 1, 2026 (scheduled)~73-74 daysGaming-icons crossover

The duration ranges reflect the same start/end-inclusive counting ambiguity mentioned earlier. Your database should store the raw dates and let the app calculate one consistent number using whichever convention you pick in Step 4, rather than copying pre-calculated "days" figures from a source that might use a different rule than you do.

Line them up and a pattern shows up that a plain list would hide: each Chapter 7 season so far has run a little shorter than the one before it, from roughly 95 days for "Pacific Break" down to roughly 73 to 74 days for the current "Override" season. That's not a huge sample to draw conclusions from, only four data points, but it's the kind of trend an archive surfaces automatically once you have three or four chapters loaded. A single wiki page listing dates in a table doesn't invite that comparison nearly as naturally as a database you can query and sort.

Step 6: Track Map and Mechanic Changes Across Chapters

Map and mechanic changes are the hardest part of a seasons archive to source reliably, because unlike start dates, there's rarely one canonical announcement listing every point-of-interest change, new vehicle, and movement mechanic for a season. Add a companion table instead of cramming everything into the seasons table:

CREATE TABLE IF NOT EXISTS season_changes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    season_id INTEGER NOT NULL,
    change_type TEXT,      -- 'map', 'mechanic', 'vehicle', 'poi'
    description TEXT,
    source_url TEXT,
    FOREIGN KEY (season_id) REFERENCES seasons (id)
);

Only add a row here once you have a source URL to attach to it. It's tempting to fill this table from memory or a half-remembered recap video, but an archive that mixes verified and unverified entries with no way to tell them apart is worse than an incomplete one. You can always add rows later as you cross-check them.

A practical way to populate this table without it turning into a chore: keep a running note (a plain text file works fine) every time you personally notice a map change while playing, then batch-convert those notes into rows with source links once a week during the same session as your scheduled refresh in Step 10. Trying to backfill months of mechanic changes from memory in one sitting is how this table ends up empty forever. Little and often beats a marathon session that never happens.

Step 7: Build the Season Comparison Scorecard

This is the feature that separates an archive from a plain spreadsheet: pick any two seasons and get a structured diff. The function below pulls both rows and returns a comparison dictionary the Flask dashboard can render.

# scripts/compare.py
import sqlite3

def get_season(conn, chapter, season_number):
    cur = conn.execute(
        "SELECT * FROM seasons WHERE chapter=? AND season_number=?",
        (chapter, season_number),
    )
    return cur.fetchone()

def compare_seasons(conn, season_a, season_b):
    a = get_season(conn, *season_a)
    b = get_season(conn, *season_b)
    if not a or not b:
        raise ValueError("One or both seasons not found in archive")

    change_count_a = conn.execute(
        "SELECT COUNT(*) FROM season_changes WHERE season_id=?", (a["id"],)
    ).fetchone()[0]
    change_count_b = conn.execute(
        "SELECT COUNT(*) FROM season_changes WHERE season_id=?", (b["id"],)
    ).fetchone()[0]

    return {
        "season_a": dict(a),
        "season_b": dict(b),
        "duration_diff_days": (a["duration_days"] or 0) - (b["duration_days"] or 0),
        "logged_changes_a": change_count_a,
        "logged_changes_b": change_count_b,
    }

Open the database connection with row_factory = sqlite3.Row before running this, so rows behave like dictionaries and the dict(a) call works cleanly. This one line trips up a surprising number of people copying SQLite snippets from tutorials that skip it.

The scorecard above only compares duration and logged change counts, but the same pattern extends to anything you've stored: Battle Pass theme overlap, number of source-linked map changes, or a custom "hype score" if you decide to add one later. The point of returning a plain dictionary instead of pre-formatted text is that the Flask template in the next step can render it however makes sense, and you can also expose the same comparison as JSON for anyone who wants to build their own front end on top of your data.

Step 8: Create a Lightweight Web Dashboard With Flask

You don't need a heavy front-end framework for this. A single-file Flask app with two routes, a season list and a comparison view, covers the core use case and stays easy to maintain.

# app.py
import sqlite3
from flask import Flask, render_template, request

app = Flask(__name__)
DB_PATH = "data/seasons.db"

def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn

@app.route("/")
def index():
    conn = get_db()
    seasons = conn.execute(
        "SELECT * FROM seasons ORDER BY chapter, season_number"
    ).fetchall()
    conn.close()
    return render_template("index.html", seasons=seasons)

@app.route("/compare")
def compare():
    from scripts.compare import compare_seasons
    conn = get_db()
    a = tuple(map(int, request.args.get("a", "7,1").split(",")))
    b = tuple(map(int, request.args.get("b", "7,4").split(",")))
    result = compare_seasons(conn, a, b)
    conn.close()
    return render_template("compare.html", result=result)

if __name__ == "__main__":
    app.run(debug=True, port=5000)

You'll need a minimal template for the season list to render. Save this as templates/index.html:

<!-- templates/index.html -->
<h1>Fortnite Seasons Archive</h1>
<table>
  <tr><th>Chapter</th><th>Season</th><th>Subtitle</th><th>Start</th><th>End</th></tr>
  {% for s in seasons %}
  <tr>
    <td>{{ s.chapter }}</td>
    <td>{{ s.season_number }}</td>
    <td>{{ s.subtitle }}</td>
    <td>{{ s.start_date }}</td>
    <td>{{ s.end_date or "Active" }}</td>
  </tr>
  {% endfor %}
</table>

Run it with python app.py and open http://127.0.0.1:5000. You should see every seeded season listed in chapter and season order. Leave debug=True on while building locally since it gives you a live reloader and readable stack traces, but turn it off before deploying anywhere reachable from outside your machine, because debug mode exposes an interactive code console.

Step 9: Add Search, Filtering, and Sorting

Once you've backfilled a few chapters, a flat list stops being useful. Add query-string filtering so visitors, or future you, can narrow by chapter or search by subtitle without touching the database directly. This also sets up the foundation for the public JSON endpoint mentioned in the advanced tips section further down, so it's worth getting the query-parameter design right the first time rather than bolting filtering on as an afterthought later.

@app.route("/api/seasons")
def api_seasons():
    conn = get_db()
    chapter = request.args.get("chapter", type=int)
    query = request.args.get("q", "").strip().lower()

    sql = "SELECT * FROM seasons WHERE 1=1"
    params = []
    if chapter:
        sql += " AND chapter = ?"
        params.append(chapter)
    if query:
        sql += " AND LOWER(subtitle) LIKE ?"
        params.append(f"%{query}%")
    sql += " ORDER BY chapter, season_number"

    rows = conn.execute(sql, params).fetchall()
    conn.close()
    return {"count": len(rows), "seasons": [dict(r) for r in rows]}

Hitting /api/seasons?chapter=7 returns a clean JSON payload you can also feed into a spreadsheet or a separate front end later:

{
  "count": 4,
  "seasons": [
    {
      "chapter": 7,
      "season_number": 4,
      "subtitle": "Override",
      "start_date": "2026-08-20",
      "end_date": null,
      "duration_days": null,
      "battle_pass_theme": "Gaming-icons crossover",
      "date_confidence": "secondary"
    }
  ]
}

Parameterized queries, the ? placeholders above, aren't optional here. Concatenating the search string directly into the SQL is the single most common way a small side project like this turns into a SQL injection demo, even on a database only you can reach locally.

Add sorting the same way: accept an optional sort query parameter, validate it against a fixed allowlist of column names (never interpolate a raw column name from user input into an ORDER BY clause), and default to chronological order when nothing is specified. A short allowlist like {"chapter", "season_number", "duration_days"} is enough for this project and closes off another class of injection risk that parameterized values alone don't cover, since placeholders work for values but not for column or table names.

Step 10: Automate Weekly Refreshes and Back Up the Archive

A seasons archive that never updates is just a static page. Schedule the ingestion script to check for status changes weekly, and export a backup every time it runs so a bad write never costs you the whole dataset.

# scripts/export_backup.py
import sqlite3
import csv
import json
from datetime import datetime, timezone

def export_all(db_path="data/seasons.db"):
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    rows = [dict(r) for r in conn.execute("SELECT * FROM seasons").fetchall()]
    conn.close()

    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    with open(f"data/backup_{stamp}.json", "w") as f:
        json.dump(rows, f, indent=2)

    with open(f"data/backup_{stamp}.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=rows[0].keys())
        writer.writeheader()
        writer.writerows(rows)

if __name__ == "__main__":
    export_all()

Wire it into cron with a weekly schedule (double-check the syntax against crontab.guru before saving):

# crontab -e
# Runs every Monday at 09:00
0 9 * * 1 cd /path/to/fortnite-seasons-archive && venv/bin/python scripts/fetch_current_season.py && venv/bin/python scripts/export_backup.py >> logs/refresh.log 2>&1

On Windows, Task Scheduler does the same job. Point it at a batch file that activates the virtual environment and runs both scripts in sequence. Either way, keep the JSON and CSV backups outside the repo's working database file, so a corrupted seasons.db is always recoverable from the last export.

Weekly is a deliberate choice, not an arbitrary default. Fortnite seasons run for roughly ten to fourteen weeks based on the Chapter 7 data above, so a season transition is never going to slip through unnoticed between two weekly checks. Polling daily or hourly wouldn't catch changes any sooner in practice, since Epic Games doesn't announce season transitions with that kind of lead time, and it would burn through your Fortnite-API rate limit for no real benefit. Match your refresh cadence to how often the underlying data actually changes, not to how often you're curious.

Common Pitfalls When Building a Fortnite Seasons Archive

A few mistakes show up over and over in projects like this one. Catching them early saves a painful re-seed of the database later, and most of these come from real trial and error rather than hypothetical edge cases.

  • Mixing date-counting conventions. Deciding whether a season's start day counts as day zero or day one matters. Pick one rule in Step 4 and apply it everywhere, instead of copying pre-calculated durations from sources that may use a different rule.
  • Trusting a single wiki entry as ground truth. Cross-check any start or end date against at least two independent sources before marking a row date_confidence = 'official'.
  • Skipping the unique index on chapter and season number. Without it, a re-run of the ingestion script silently duplicates rows instead of updating them.
  • Hardcoding Battle Pass pricing or reward counts without a source. These details change and get corrected. Store a source_url next to any number you're not fully sure of, or leave the field null.
  • Ignoring API rate limits on Fortnite-API. It's a free community service. Hammering it with a script that reruns every minute instead of weekly is how you get temporarily blocked and lose your data feed.
  • Storing local time instead of UTC. Season transitions happen at a fixed UTC moment, and local-time storage makes cross-region comparisons drift by hours or a full day.
  • Building the dashboard before the database is stable. It's tempting to jump straight to Flask because a web page feels like real progress. Get the schema, seed data, and comparison logic solid first, since a front end built on top of a shifting schema means rewriting templates every time you tweak a column name.

Troubleshooting Guide

Most issues with this build fall into a short, predictable list. None of these are exotic, and almost all of them show up in the first week of running the project rather than months later, so it's worth reading through the whole table once before you hit any of them in practice. Here's what tends to go wrong and the fastest fix for each:

IssueLikely CauseFix
requests.exceptions.ConnectionErrorFortnite-API is temporarily down or rate-limiting your IPCheck status.epicgames.com for broader outages, and add retry logic with exponential backoff
sqlite3.OperationalError: database is lockedTwo processes, such as the Flask dev server and a cron script, writing at onceClose one connection before opening another, and avoid running the refresh script while the dashboard is mid-write
Flask: Address already in usePort 5000 already bound by another process or macOS AirPlay ReceiverRun with flask run --port 5001 or disable AirPlay Receiver in System Settings
KeyError on API response fieldsFortnite-API's community schema changed a field nameSwitch to .get() access everywhere and log unexpected shapes instead of crashing
Cron job never runsScript uses a relative path or the venv isn't activatedUse absolute paths in the crontab line and call venv/bin/python directly, not just python
ValueError parsing a dateSource date isn't in strict YYYY-MM-DD formatNormalize incoming dates in a single ingestion function before they ever reach the database
Duplicate season rows after a refreshMissing the unique index from Step 2Add UNIQUE(chapter, season_number) and use INSERT OR REPLACE in your ingestion script
API returns HTTP 429Too many requests in a short windowAdd a free Fortnite-API key for higher limits, and cache responses locally between runs
ModuleNotFoundError: flaskVirtual environment not activated before running app.pyRe-run source venv/bin/activate (or the Windows equivalent) before every session
Dashboard shows old data after a refreshBrowser or Flask dev server caching a stale responseHard-refresh the page, and confirm the cron job actually wrote to seasons.db by checking the file's modified timestamp

Advanced Tips for Extending the Archive

Once the core build is stable, a few extensions make it genuinely useful beyond a personal reference. Add a simple chart on the dashboard's front end to visualize season length trends across Chapter 7. Four data points isn't a lot yet, but the pattern becomes obvious once you backfill Chapters 5 and 6. Export the archive as a public read-only JSON endpoint if you want to share it, but rate-limit it yourself before anyone else does it for you by hammering your Flask dev server.

A second useful extension is a changelog view: whenever your weekly refresh script updates a row, instead of overwriting the old value outright, insert it into a small season_history table first. That turns the archive from "what does the data say right now" into "what did the data say last month, and did it change." For a project built specifically to solve the problem of sources silently editing dates without explanation, keeping your own edit history is a natural next step, and it costs you one extra table and a few lines in the ingestion script.

For deployment beyond your own machine, a free tier on a platform that supports Python web apps is enough for a low-traffic reference tool. Just remember SQLite files don't survive most ephemeral filesystem resets on serverless platforms, so either mount persistent storage or switch the backend to a hosted Postgres instance if you plan to keep it running long-term. Finally, consider adding a lightweight email or RSS alert that fires when your weekly refresh detects a season's end_date field flip from null to a real date. That's the exact moment a season officially closes, and it's a useful signal to have without checking manually. If you're also tracking ladder progress alongside season history, pairing this archive with a rank tracker gives you both timelines in one place, and our breakdown of Fortnite Ranked versus FNCS is a useful reference if you're extending the schema to cover competitive seasons too.

For deeper background on Fortnite's broader competitive ecosystem while you're building out the archive's scope, our esports coverage tracks how ranked seasons, tournament formats, and Battle Royale content seasons intersect across titles.

Frequently Asked Questions About Fortnite Seasons

What is the current Fortnite season as of September 2026?

Fortnite is in Chapter 7, Season 4, subtitled "Override," which began August 20, 2026. Its Battle Pass is currently scheduled to close on November 1, 2026, based on published season listings.

How long do Fortnite seasons usually last?

Chapter 7's four seasons so far have run roughly 73 to 95 days each, with Season 1 ("Pacific Break") the longest at around 95 days and Season 4 ("Override") the shortest so far at around 73 to 74 days. Exact figures vary slightly by source depending on date-counting conventions, which is exactly why this tutorial's archive stores raw dates rather than pre-calculated durations.

Is Fortnite-API an official Epic Games product?

No. Fortnite-API is a community-built, unofficial REST API that surfaces publicly available game data. It isn't operated, endorsed, or guaranteed by Epic Games, so build in retry logic and don't depend on it for anything time-critical.

Can this archive cover Chapters 1 through 6, not just Chapter 7?

Yes. The schema from Step 2 isn't chapter-specific. You'll need to seed those earlier rows yourself from cross-checked sources, since this tutorial's verified data focuses on the current Chapter 7 seasons as of publication.

Do I need to pay for a Fortnite-API key?

No. Basic endpoints are free and don't require a key. A free API key raises your rate limits if you're polling more frequently than the weekly schedule used in this tutorial.

Why store both a start date and a "date confidence" field?

Because published Fortnite season dates sometimes differ by a day between sources, depending on whether they count the patch-notes date or the server-restart date. Flagging a row as "official," "secondary," or "estimated" lets your dashboard be honest about certainty instead of presenting every date as equally solid.

What's the fastest way to back up my season data?

Run the export script from Step 10, which writes both a JSON and a CSV snapshot with a UTC timestamp in the filename. Scheduling it weekly via cron, or Task Scheduler on Windows, means you're never more than a week from a clean recovery point.

Can I turn this into a public tool other people can use?

Yes, the /api/seasons endpoint from Step 9 already returns clean JSON. Add rate limiting and switch from SQLite to a hosted database before opening it up to real traffic, since a single-file database isn't built for concurrent public writes.

What's the difference between this archive and a season tracker?

A tracker is built around the present moment: a countdown to the next season, a live progress bar, a "what's happening right now" view. An archive is built around history: every past season stored with a source and a confidence rating, structured so you can compare any two of them later. They can share a database, but they solve different problems, and this tutorial focuses on the archive side specifically.