Week 4: A web app with Flask
Goal of today: a web page in your browser that shows, for each city and each data set: tomorrow's forecasts, a chart of the last 60 days (forecasts against the measured temperature), the average error of each source, and a table of the latest days.
How to work: do the steps in order. After each step, run the code and compare with the expected output. Go on only when yours looks the same. (Your temperatures will differ, because you run it on another day.)
What you need to know
How the web works
When you type an address into the browser:
browser ── request: GET /city/eger ──▶ server (our Python program)
browser ◀── response: 200 OK + HTML page ── server
- The browser is the client: it asks.
- The server is a program that waits for questions and answers them. Today we write the server.
- The URL (address) says who to ask and what:
http://127.0.0.1:5000/city/eger?set=providers 127.0.0.1means this computer.5000is the port, the "door number" our program listens on./city/egeris the path: which page.?set=providersis a query parameter: extra information, like the parameters of the weather APIs in week 1.- GET is the kind of request the browser sends when you open an address. It only reads, it should never change anything.
- The answer starts with a status code, like in week 1:
200OK,302"go to another address",404not found,500the server program crashed.
HTML in five minutes
A web page is text with tags. A tag is a name in < >, and most tags have an opening and a closing part: <b>bold</b>.
<h2>Eger</h2> a heading
<b>bold text</b><br> bold, then a line break
<div class="box">...</div> a block (a box) we can give a style to
<table> a table:
<tr><th>day</th><th>ecmwf</th></tr> a row (tr) with header cells (th)
<tr><td>2026-10-05</td><td>23.2</td></tr> a row with normal cells (td)
</table>
The look (colours, borders, spacing) is set with CSS, in the <style> part of the page. You do not need to change it.
Templates
We do not want to write HTML inside Python strings. Instead, the HTML lives in files in the templates folder,
and has gaps that Flask fills with our data. This template language is called Jinja:
| Jinja | Meaning | Example |
|---|---|---|
{{ x }} |
print the value of x |
<h2>{{ name }}</h2> → <h2>Eger</h2> |
{% for ... %} ... {% endfor %} |
repeat a part | one <tr> for every day |
{% if ... %} ... {% endif %} |
show a part only sometimes | mark the selected city |
{% extends "base.html" %} |
use the common frame of the pages | title, menu, style |
JavaScript and Chart.js
JavaScript is the programming language that runs inside the browser. We use it only for one thing: drawing the chart. Chart.js is a ready-made JavaScript library for charts. Python gives the numbers, Chart.js draws them. You do not need to learn JavaScript for this course; we explain the few lines we use.
MAE: how wrong is a forecast?
MAE (mean absolute error): for each day take forecast − measured, drop the sign, take the average.
A small example with only 3 days and made-up numbers (on the web page we use the last 60 days):
| day | forecast | measured | error | without sign |
|---|---|---|---|---|
| 1 | 23 | 22 | +1 | 1 |
| 2 | 20 | 22 | −2 | 2 |
| 3 | 21 | 21 | 0 | 0 |
MAE = (1 + 2 + 0) / 3 = 1.0 °C: "on average this source is 1 degree wrong". Lower is better. We drop the sign because +2 and −2 are equally bad; without it they would cancel out and look perfect.
Step 1: Install Flask
Change requirements.txt to:
requirements.txt
requests
tzdata
pandas
flask
And install:
pip install -r requirements.txt
You should see at the end: Successfully installed ... flask-... (or Requirement already satisfied if you installed everything in week 1).
Step 2: The smallest web app
Create app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from the weather app!"
if __name__ == "__main__":
app.run(debug=True)
Run it:
python app.py
You should see in the terminal:
* Serving Flask app 'app'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. ...
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
(The warning is normal: this small server is for development on your own computer.) The program does not end: it waits for requests. Open http://127.0.0.1:5000 in the browser.
The browser shows: Hello from the weather app!
What happened:
- app = Flask(__name__) creates the web application.
- @app.route("/") attaches the function below it to the address / (the main page).
When the browser asks for /, Flask calls home() and sends back what it returns.
- debug=True: when you save a change in a .py file, the server restarts by itself, and errors are shown
in the browser in detail. So you can leave it running while you do the next steps; just reload the page.
(Never use debug=True on a public server.)
To stop the server: Ctrl+C in the terminal.
Step 3: A page for each city
Change app.py to this:
from flask import Flask, abort
from config import CITIES
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from the weather app!"
@app.route("/city/<city>")
def city_page(city):
if city not in CITIES:
abort(404)
return "This is the page of " + CITIES[city]["name"]
if __name__ == "__main__":
app.run(debug=True)
Open http://127.0.0.1:5000/city/eger.
The browser shows: This is the page of Eger
Now open http://127.0.0.1:5000/city/szeged.
The browser shows: Not Found. The requested URL was not found on the server...
<city>in the route is a variable part of the address. Whatever is written there arrives in the parametercity. One function serves every city.abort(404)answers "not found" for a city we do not know, instead of crashing with aKeyError.
Step 4: Data from the database
Now show real data. Change app.py to this:
from flask import Flask, abort, request
from config import CITIES, SETS
from db import load_table
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from the weather app!"
@app.route("/city/<city>")
def city_page(city):
if city not in CITIES:
abort(404)
data_set = request.args.get("set", "models")
if data_set not in SETS:
abort(404)
sources = SETS[data_set]
table = load_table(city, sources).tail(60)
return table.tail(5).to_html()
if __name__ == "__main__":
app.run(debug=True)
Open http://127.0.0.1:5000/city/eger.
The browser shows a simple table with the last 5 days:
ecmwf gfs icon measured
day
2026-10-03 22.1 22.8 23.4 21.9
2026-10-04 22.5 23.2 24.2 22.4
2026-10-05 23.2 23.9 25.0 23.0
2026-10-06 23.2 24.3 25.2 NaN
2026-10-07 22.2 24.2 24.6 NaN
Now open http://127.0.0.1:5000/city/eger?set=providers. The browser shows the columns open_meteo, met_norway, wttr.
load_table()is our function from week 3.tail(60)keeps the last 60 days.request.args.get("set", "models")reads the query parameter?set=.... If there is none, it gives"models".to_html()is a quick pandas trick: it turns the table into HTML. Good for checking, but ugly. From the next step we build the page properly, with a template.- The last days have no measured value (
NaN) yet: today is not over, and tomorrow is in the future.
Step 5: The first template
Create a folder called templates next to app.py. Create two files in it.
templates/base.html: the frame of every page (title, style, menu):
templates/base.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Better Forecast</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
body { font-family: sans-serif; max-width: 960px; margin: 20px auto; padding: 0 16px; }
nav a { margin-right: 12px; }
nav a.active { font-weight: bold; }
table { border-collapse: collapse; margin: 10px 0; }
td, th { border: 1px solid #ccc; padding: 4px 10px; text-align: right; }
.box { background: #eef5ff; padding: 10px 16px; border-radius: 6px; margin: 10px 0; }
.best { background: #d8f5d8; }
</style>
</head>
<body>
<h1>Better Forecast</h1>
<nav>
{% for key, c in cities.items() %}
<a href="{{ url_for('city_page', city=key, set=data_set) }}" {% if key == city %}class="active"{% endif %}>{{ c.name }}</a>
{% endfor %}
|
{% for name in sets %}
<a href="{{ url_for('city_page', city=city, set=name) }}" {% if name == data_set %}class="active"{% endif %}>{{ name }}</a>
{% endfor %}
</nav>
{% block content %}{% endblock %}
</body>
</html>
<script src="...chart.js...">loads Chart.js from the internet. We use it in step 9.- The
<nav>part is the menu: one link for each city, and one for each data set.url_for('city_page', city=key, set=data_set)builds the address of a page, e.g./city/eger?set=models. The current city and set getclass="active", which makes them bold. {% block content %}{% endblock %}is the gap where each page puts its own content.
templates/city.html: for now only a heading:
{% extends "base.html" %}
{% block content %}
<h2>{{ cities[city].name }} – {{ data_set }}</h2>
{% endblock %}
In app.py, add render_template to the first import line:
from flask import Flask, abort, render_template, request
and replace the last line of city_page() (return table.tail(5).to_html()) with:
return render_template("city.html", cities=CITIES, city=city, data_set=data_set, sets=SETS)
Reload http://127.0.0.1:5000/city/eger.
The browser shows:
Better Forecast
Budapest Eger | models providers
Eger – models
Click the menu links: the heading changes, and the selected city and set are bold.
render_template("city.html", ...)fills the template. Everything after the file name becomes a variable inside the template:cities=CITIES→{{ cities }},city=city→{{ city }}, and so on.–is the HTML code of the dash "–".
Step 6: Tomorrow's forecasts
In app.py, add this import:
from sources import tomorrow
In city_page(), after the line table = load_table(...), replace the return line with:
tomorrow_forecasts = table.loc[tomorrow()].drop("measured").to_dict() if tomorrow() in table.index else {}
return render_template(
"city.html", cities=CITIES, city=city, data_set=data_set, sets=SETS,
tomorrow=tomorrow(), tomorrow_forecasts=tomorrow_forecasts,
)
In templates/city.html, add this after the <h2> line:
<div class="box">
<b>Tomorrow ({{ tomorrow }}), max temperature</b><br>
{% for source, value in tomorrow_forecasts.items() %}
{{ source }}: {{ value }} °C
{% else %}
No forecasts for tomorrow yet. Run collect.py.
{% endfor %}
</div>
Reload the page. The browser shows a blue box:
Tomorrow (2026-10-07), max temperature
ecmwf: 22.2 °C gfs: 24.2 °C icon: 24.6 °C
table.loc[tomorrow()]is the row of tomorrow..drop("measured")removes the empty measured value, and.to_dict()turns the row into{"ecmwf": 22.2, "gfs": 24.2, "icon": 24.6}.... if tomorrow() in table.index else {}: ifcollect.pyhas not run today, there is no row for tomorrow. Then we give an empty dictionary, and the template shows the{% else %}part of the loop. is a space that the browser does not remove.
Step 7: Table of the latest days
In app.py, add one more line to render_template(...), after the tomorrow=... line:
rows=table.iloc[::-1].head(15).round(1).to_dict("index"),
In templates/city.html, add this after the </div> of the box (before {% endblock %}):
<h3>Latest days</h3>
<table>
<tr><th>day</th>{% for source in sets[data_set] %}<th>{{ source }}</th>{% endfor %}<th>measured</th></tr>
{% for day, row in rows.items() %}
<tr><td>{{ day }}</td>
{% for source in sets[data_set] %}<td>{{ row[source] if row[source] == row[source] else "" }}</td>{% endfor %}
<td><b>{{ row.measured if row.measured == row.measured else "" }}</b></td></tr>
{% endfor %}
</table>
Reload. The browser shows a table, newest day first:
day ecmwf gfs icon measured
2026-10-07 22.2 24.2 24.6
2026-10-06 23.2 24.3 25.2
2026-10-05 23.2 23.9 25.0 23.0
2026-10-04 22.5 23.2 24.2 22.4
...
iloc[::-1]reverses the order of the rows (newest first).head(15)keeps 15 rows,round(1)keeps 1 decimal.to_dict("index")gives{"2026-10-07": {"ecmwf": 22.2, "gfs": 24.2, ...}, ...}: one dictionary per day.- The first line of the table is the header: "day", one column per source, "measured".
row[source] == row[source]is a trick: a missing value (NaN) is the only value that is not equal to itself. So forNaNwe print an empty cell instead of the text "nan".
Step 8: The error of each source
In app.py, add these two functions after the line app = Flask(__name__):
def mae(predicted, measured):
"""Mean absolute error: on average, how many degrees the forecast is wrong."""
return float((predicted - measured).abs().mean())
def source_errors(table, sources):
"""Average error of each source on the days where we know the measured value."""
known = table.dropna()
if known.empty:
return {}
return {source: round(mae(known[source], known["measured"]), 2) for source in sources}
And add one line to render_template(...), after the "city.html", ... line:
errors=source_errors(table, sources),
In templates/city.html, add this before <h3>Latest days</h3>:
<h3>Average error of each source (last 60 days)</h3>
<table>
<tr>{% for source in errors %}<th>{{ source }}</th>{% endfor %}</tr>
<tr>{% for value in errors.values() %}<td>{{ value }} °C</td>{% endfor %}</tr>
</table>
Reload http://127.0.0.1:5000/city/eger. The browser shows:
Average error of each source (last 60 days)
ecmwf gfs icon
0.68 °C 1.63 °C 1.3 °C
mae()is the formula from What you need to know:(predicted - measured)for every day at once (pandas works on whole columns),.abs()drops the sign,.mean()takes the average.source_errors()uses only the days where the measured value is known (dropna()drops the rows with a missing value). If there are none (e.g.providersin the first days), it returns an empty dictionary, and the table stays empty.round(..., 2): 2 decimals are enough.
On the models set, ECMWF is usually the best. Is it the same in Budapest?
Step 9: The chart
In app.py, in city_page(), add this before the tomorrow_forecasts = ... line:
chart = {
"days": list(table.index),
"measured": [None if v != v else v for v in table["measured"]], # NaN -> None (empty in the chart)
"sources": {s: [None if v != v else v for v in table[s]] for s in sources},
}
and change the errors=... line in render_template(...) to:
chart=chart, errors=source_errors(table, sources),
In templates/city.html, add the place of the chart after the </div> of the box:
<canvas id="chart" height="110"></canvas>
and the drawing, at the end, before {% endblock %}:
<script>
const chart = {{ chart | tojson }};
const datasets = [{ label: "measured", data: chart.measured, borderColor: "black", borderWidth: 3 }];
const colors = ["#1f77b4", "#ff7f0e", "#2ca02c"];
Object.entries(chart.sources).forEach(([name, values], i) => {
datasets.push({ label: name, data: values, borderColor: colors[i], borderWidth: 1 });
});
new Chart(document.getElementById("chart"), {
type: "line",
data: { labels: chart.days, datasets: datasets },
options: { spanGaps: false, pointRadius: 1 },
});
</script>
Reload. The browser shows a line chart of 60 days: a thick black line (measured) and three thin coloured lines (the three sources), very close to the black one. The legend at the top names the lines; click a name to hide or show that line.
How it works:
- In Python we prepare a dictionary with the days and one list of values per line.
- JSON has no NaN, so None if v != v else v turns NaN into None (the same trick as in step 7). None becomes an empty point in the chart.
- {{ chart | tojson }} writes the dictionary into the page as JSON. JavaScript can read JSON directly.
- The JavaScript part: datasets is the list of lines. The first one is "measured" (black, thick). Then we add one line per source,
each with its own colour. new Chart(...) draws them into the <canvas> (an empty drawing area) with the id chart.
Step 10: The main page
The main page still says Hello. Let it jump straight to Budapest.
Change the first import line in app.py to:
from flask import Flask, abort, redirect, render_template, request, url_for
and the home() function to:
@app.route("/")
def home():
return redirect(url_for("city_page", city="budapest"))
Open http://127.0.0.1:5000. The browser jumps to /city/budapest and shows the Budapest page.
redirect(...)answers with status302: "go to this other address". The browser does it automatically.url_for("city_page", city="budapest")builds the address/city/budapestfrom the function name. If you ever change the route, the links still work.
You can also add a description to the very first line of app.py, like in the complete file below. Done!
Your complete files
Compare with yours. If something does not work, copy these.
app.py
"""The web app. python app.py then open http://127.0.0.1:5000"""
from flask import Flask, abort, redirect, render_template, request, url_for
from config import CITIES, SETS
from db import load_table
from sources import tomorrow
app = Flask(__name__)
def mae(predicted, measured):
"""Mean absolute error: on average, how many degrees the forecast is wrong."""
return float((predicted - measured).abs().mean())
def source_errors(table, sources):
"""Average error of each source on the days where we know the measured value."""
known = table.dropna()
if known.empty:
return {}
return {source: round(mae(known[source], known["measured"]), 2) for source in sources}
@app.route("/")
def home():
return redirect(url_for("city_page", city="budapest"))
@app.route("/city/<city>")
def city_page(city):
if city not in CITIES:
abort(404)
data_set = request.args.get("set", "models")
if data_set not in SETS:
abort(404)
sources = SETS[data_set]
table = load_table(city, sources).tail(60)
chart = {
"days": list(table.index),
"measured": [None if v != v else v for v in table["measured"]], # NaN -> None (empty in the chart)
"sources": {s: [None if v != v else v for v in table[s]] for s in sources},
}
tomorrow_forecasts = table.loc[tomorrow()].drop("measured").to_dict() if tomorrow() in table.index else {}
return render_template(
"city.html", cities=CITIES, city=city, data_set=data_set, sets=SETS,
chart=chart, errors=source_errors(table, sources),
tomorrow=tomorrow(), tomorrow_forecasts=tomorrow_forecasts,
rows=table.iloc[::-1].head(15).round(1).to_dict("index"),
)
if __name__ == "__main__":
app.run(debug=True)
templates/city.html
{% extends "base.html" %}
{% block content %}
<h2>{{ cities[city].name }} – {{ data_set }}</h2>
<div class="box">
<b>Tomorrow ({{ tomorrow }}), max temperature</b><br>
{% for source, value in tomorrow_forecasts.items() %}
{{ source }}: {{ value }} °C
{% else %}
No forecasts for tomorrow yet. Run collect.py.
{% endfor %}
</div>
<canvas id="chart" height="110"></canvas>
<h3>Average error of each source (last 60 days)</h3>
<table>
<tr>{% for source in errors %}<th>{{ source }}</th>{% endfor %}</tr>
<tr>{% for value in errors.values() %}<td>{{ value }} °C</td>{% endfor %}</tr>
</table>
<h3>Latest days</h3>
<table>
<tr><th>day</th>{% for source in sets[data_set] %}<th>{{ source }}</th>{% endfor %}<th>measured</th></tr>
{% for day, row in rows.items() %}
<tr><td>{{ day }}</td>
{% for source in sets[data_set] %}<td>{{ row[source] if row[source] == row[source] else "" }}</td>{% endfor %}
<td><b>{{ row.measured if row.measured == row.measured else "" }}</b></td></tr>
{% endfor %}
</table>
<script>
const chart = {{ chart | tojson }};
const datasets = [{ label: "measured", data: chart.measured, borderColor: "black", borderWidth: 3 }];
const colors = ["#1f77b4", "#ff7f0e", "#2ca02c"];
Object.entries(chart.sources).forEach(([name, values], i) => {
datasets.push({ label: name, data: values, borderColor: colors[i], borderWidth: 1 });
});
new Chart(document.getElementById("chart"), {
type: "line",
data: { labels: chart.days, datasets: datasets },
options: { spanGaps: false, pointRadius: 1 },
});
</script>
{% endblock %}
templates/base.html is the same as in step 5.
Check
- ☐ Both cities and both data sets open from the menu.
- ☐ On
models, the chart shows the black measured line and the three model lines close to it. - ☐ The error table: is ECMWF the best for both cities? (It usually is.)
- ☐
/city/szegedgives "Not Found", not a crash. - ☐ On
providersthere is little to see yet. That is normal; it grows every day.
Extra
- Add a parameter
?days=30that changes how many days are shown (default 60). - Show the errors also for
tmin. (You need to changeload_tableor write a second version of it.) - Mark the best (smallest) error in the error table in green. (The CSS class
bestalready exists.)
If something goes wrong
| Problem | Reason |
|---|---|
TemplateNotFound: city.html |
The folder must be called exactly templates and be next to app.py. |
| The page is empty, no chart | No internet for the Chart.js script, or a JavaScript error: press F12 in the browser, look at Console. |
Address already in use |
Another app.py is still running. Close that terminal or press Ctrl+C there. |
KeyError in the table |
weather.db is missing data. Run load_history.py (week 3). |
NameError: name 'render_template' is not defined (or redirect, url_for, tomorrow) |
An import is missing at the top of app.py. Compare with the complete file above. |
| The change does not appear | Save the file, then reload the page. If the server stopped because of an error, start it again with python app.py. |
jinja2.exceptions.UndefinedError |
The template uses a variable that render_template(...) does not pass. Check the list of names in render_template. |