Weather Forecasting Web App using Flask and OpenWeather API
AdvancedBuild a responsive Flask app that integrates OpenWeather with caching and fallbacks
1. Project Overview
What it does
A user-facing web application that shows:
- Current weather (temperature, humidity, wind, description, icon),
- 5-day / 3-hourly forecast (visualized as a simple timeline),
- Search by city name (with suggestions), and
- Basic interpretation / recommendations (e.g., umbrella advice).
Real-world use case
Useful for building small meteorology dashboards, travel assistants, smart-home decisioning (e.g., turn on irrigation), and teaching how to integrate third-party APIs, caching, error handling, and deployment-ready Flask apps.
Technical goals
- Integrate external REST API (OpenWeather) safely.
- Provide robust UX: input validation, error messages, and fallback.
- Implement lightweight caching (in-memory TTL) to reduce API calls.
- Present data neatly using simple HTML/CSS and client-friendly layout.
- Make the project runnable offline (sample data) for testing.
2. Key Technologies & Libraries
- Python 3.8+
- Flask — web framework
- Requests — HTTP client for API calls
- Jinja2 — Flask templating (bundled)
- Bootstrap (CDN) — minimal responsive styling used by templates
- (Optional) python-dotenv — load .env for local env vars
Install required packages:
pip install flask requests python-dotenv3. Learning Outcomes
By completing this project you will learn:
- How to integrate and secure third-party APIs (use environment variables).
- Proper error handling and fallback strategies (sample data).
- Simple caching to reduce API usage and speed up responses.
- Basic front-end templating with Jinja2 and Bootstrap for responsive UI.
- How to structure a small but production-friendly Flask app.
4. Step-by-Step Explanation
- Create project folder and a Python virtual environment.
- Install dependencies (flask, requests, python-dotenv).
- Get OpenWeather API Key (optional for full functionality) and set in environment:
- On Linux/macOS:
export OPENWEATHER_API_KEY=your_key_here - On Windows (PowerShell):
$env:OPENWEATHER_API_KEY="your_key_here"
- On Linux/macOS:
- Create
app.py(code below) which:- Provides / homepage with a search form.
- Calls OpenWeather /weather and /forecast endpoints.
- Caches responses for CACHE_TTL_SECONDS (default 300s).
- Falls back to sample JSON if API key missing or API fails.
- Run the app:
python app.pyand openhttp://127.0.0.1:5000/. - Test with city names (e.g., London, Karachi, New York).
- Extend: add graphs (Plotly), geolocation, unit switching (metric/imperial), or deployment (Gunicorn + Docker).
5. Full Working and Verified Python Code
Save the entire content below as a single file app.py. It is self-contained (templates are embedded via render_template_string) and includes bundled sample data to run without an API key.
"""
app.py - Weather Forecasting Web App (Flask + OpenWeather)
Run:
1) (optional) set OPENWEATHER_API_KEY environment variable to your OpenWeather API key.
- Linux/macOS: export OPENWEATHER_API_KEY=your_api_key
- Windows PowerShell: $env:OPENWEATHER_API_KEY="your_api_key"
2) Install dependencies:
pip install flask requests python-dotenv
3) Run:
python app.py
4) Open http://127.0.0.1:5000/ in your browser.
Notes:
- If OPENWEATHER_API_KEY is not provided or API fails, the app uses bundled sample data to demonstrate functionality.
- The code implements a tiny in-memory TTL cache to reduce API calls.
"""
from __future__ import annotations
import os
import time
import requests
from datetime import datetime, timedelta
from functools import wraps
from flask import Flask, request, redirect, url_for, render_template_string, flash
# Optional: load .env for local development (uncomment if you use a .env file)
# from dotenv import load_dotenv
# load_dotenv()
# ------------------------------
# Configuration
# ------------------------------
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "").strip()
OPENWEATHER_URL_WEATHER = "https://api.openweathermap.org/data/2.5/weather"
OPENWEATHER_URL_FORECAST = "https://api.openweathermap.org/data/2.5/forecast"
# Basic caching TTL for API responses (seconds)
CACHE_TTL_SECONDS = 300 # 5 minutes cache
# Default units: 'metric' or 'imperial'
DEFAULT_UNITS = "metric"
# ------------------------------
# Sample fallback data (small)
# ------------------------------
# Minimal sample for current weather (for demo without API key)
SAMPLE_CURRENT = {
"name": "Sample City",
"dt": int(time.time()),
"main": {"temp": 22.5, "feels_like": 21.0, "humidity": 55, "temp_min": 20.0, "temp_max": 24.0},
"weather": [{"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"}],
"wind": {"speed": 3.5},
"sys": {"country": "SC"},
}
# Minimal sample 5-day forecast (3-hour slots simulated)
SAMPLE_FORECAST = {
"city": {"name": "Sample City", "country": "SC", "timezone": 0},
"list": [
{"dt": int(time.time()) + i * 3 * 3600,
"main": {"temp": 20 + 2 * ((i % 4) / 3), "humidity": 50 + (i % 3) * 5},
"weather": [{"id": 500 + (i % 4), "main": "Rain" if i % 4 == 0 else "Clouds", "description": "light rain" if i % 4 == 0 else "scattered clouds", "icon": "10d" if i % 4 == 0 else "03d"}],
"wind": {"speed": 4.0 + 0.2 * i}
} for i in range(0, 40)
]
}
# ------------------------------
# Simple TTL cache
# ------------------------------
_cache = {}
def ttl_cache(ttl_seconds):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
key = (func.__name__, args, tuple(sorted(kwargs.items())))
entry = _cache.get(key)
now = time.time()
if entry and now - entry["ts"] < ttl_seconds:
return entry["value"]
value = func(*args, **kwargs)
_cache[key] = {"value": value, "ts": now}
return value
return wrapper
return decorator
# ------------------------------
# OpenWeather API helpers
# ------------------------------
def has_api_key() -> bool:
return bool(OPENWEATHER_API_KEY)
@ttl_cache(CACHE_TTL_SECONDS)
def fetch_current_weather(city: str, units: str = DEFAULT_UNITS):
"""Fetch current weather from OpenWeather or return SAMPLE_CURRENT on failure."""
if not has_api_key():
return {"ok": False, "error": "No API key", "data": SAMPLE_CURRENT}
params = {"q": city, "appid": OPENWEATHER_API_KEY, "units": units}
try:
r = requests.get(OPENWEATHER_URL_WEATHER, params=params, timeout=6.0)
r.raise_for_status()
data = r.json()
return {"ok": True, "data": data}
except requests.HTTPError as e:
return {"ok": False, "error": f"HTTP {r.status_code} - {r.text}", "data": SAMPLE_CURRENT}
except Exception as e:
return {"ok": False, "error": str(e), "data": SAMPLE_CURRENT}
@ttl_cache(CACHE_TTL_SECONDS)
def fetch_forecast(city: str, units: str = DEFAULT_UNITS):
"""Fetch 5-day / 3-hour forecast from OpenWeather or SAMPLE_FORECAST on failure."""
if not has_api_key():
return {"ok": False, "error": "No API key", "data": SAMPLE_FORECAST}
params = {"q": city, "appid": OPENWEATHER_API_KEY, "units": units}
try:
r = requests.get(OPENWEATHER_URL_FORECAST, params=params, timeout=6.0)
r.raise_for_status()
data = r.json()
return {"ok": True, "data": data}
except requests.HTTPError as e:
return {"ok": False, "error": f"HTTP {r.status_code} - {r.text}", "data": SAMPLE_FORECAST}
except Exception as e:
return {"ok": False, "error": str(e), "data": SAMPLE_FORECAST}
# ------------------------------
# Data utilities
# ------------------------------
def kelvin_to_celsius(k): # not used if units=metric, kept for reference
return k - 273.15
def ts_to_local(dt_ts, tz_offset_seconds=0):
"""Convert UTC timestamp to a naive local datetime using tz offset in seconds."""
return datetime.utcfromtimestamp(dt_ts + tz_offset_seconds)
def summarize_forecast(forecast_data):
"""
Convert forecast list into daily aggregates (date string -> dict).
This is a simple transform used for display.
"""
daily = {}
for item in forecast_data.get("list", []):
dt = item["dt"]
dt_local = ts_to_local(dt, forecast_data.get("city", {}).get("timezone", 0))
date_key = dt_local.date().isoformat()
entry = daily.setdefault(date_key, {"temps": [], "weathers": [], "counts": 0})
entry["temps"].append(item["main"]["temp"])
entry["weathers"].append(item["weather"][0]["description"])
entry["counts"] += 1
# Build summary list
summary = []
for date, v in daily.items():
summary.append({
"date": date,
"temp_min": round(min(v["temps"]), 1),
"temp_max": round(max(v["temps"]), 1),
"description": max(set(v["weathers"]), key=v["weathers"].count)
})
summary.sort(key=lambda x: x["date"])
return summary
# ------------------------------
# Flask app & templates (inline)
# ------------------------------
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "weather-demo-secret") # keep secret in production
BASE_HTML = """
...
"""
# ------------------------------
# Routes
# ------------------------------
@app.route("/", methods=["GET"])
def home():
city = request.args.get("city", "").strip()
units = request.args.get("units", DEFAULT_UNITS)
context = {"city": city, "units": units, "ttl": CACHE_TTL_SECONDS, "api_present": bool(OPENWEATHER_API_KEY)}
if not city:
return render_template_string(BASE_HTML, **context)
# fetch current and forecast (cached)
cur = fetch_current_weather(city, units=units)
fct = fetch_forecast(city, units=units)
# Decide data source string
data_source = "OpenWeather API" if cur["ok"] and fct["ok"] else "Sample data (fallback)"
# Handle API errors (display to user)
if not cur["ok"]:
flash(f"Warning: current weather API error: {cur.get('error')}")
if not fct["ok"]:
flash(f"Warning: forecast API error: {fct.get('error')}")
# Parse current
cur_d = cur["data"]
try:
weather0 = cur_d.get("weather", [])[0] if cur_d.get("weather") else {}
current = {
"city": cur_d.get("name", city),
"country": cur_d.get("sys", {}).get("country", ""),
"temp": round(cur_d["main"]["temp"], 1) if cur_d.get("main") else None,
"feels_like": round(cur_d["main"].get("feels_like", 0), 1) if cur_d.get("main") else None,
"temp_min": round(cur_d["main"].get("temp_min", 0), 1) if cur_d.get("main") else None,
"temp_max": round(cur_d["main"].get("temp_max", 0), 1) if cur_d.get("main") else None,
"humidity": cur_d["main"].get("humidity") if cur_d.get("main") else None,
"desc": weather0.get("description", "").title() if weather0 else "",
"icon": weather0.get("icon", "01d"),
"wind": cur_d.get("wind", {}).get("speed"),
"as_of": ts_to_readable(cur_d.get("dt", int(time.time())), cur_d.get("timezone", 0)),
"code": weather0.get("id", 0)
}
except Exception:
current = {
"city": city, "country": "", "temp": None, "feels_like": None,
"temp_min": None, "temp_max": None, "humidity": None,
"desc": "", "icon": "01d", "wind": None, "as_of": "", "code": 0
}
# Parse forecast - transform to daily summary and pick an icon per day
fct_d = fct["data"]
daily = summarize_forecast(fct_d)
for i, day in enumerate(daily):
icon = "03d"
target_date = day["date"]
for item in fct_d.get("list", []):
dt_local = ts_to_local(item["dt"], fct_d.get("city", {}).get("timezone", 0))
if dt_local.date().isoformat() == target_date:
icon = item["weather"][0].get("icon", icon)
break
day["icon"] = icon
# Advice
desc_lower = (current["desc"] or "").lower()
advice = "Have a nice day."
if "rain" in desc_lower or "shower" in desc_lower:
advice = "Carry an umbrella — rain expected."
elif "snow" in desc_lower:
advice = "Wear warm clothes — snow is expected."
elif current["temp"] is not None:
if units == "metric":
if current["temp"] >= 30:
advice = "It's hot — stay hydrated."
elif current["temp"] <= 5:
advice = "It's cold — wear a jacket."
else:
if current["temp"] >= 86:
advice = "It's hot — stay hydrated."
elif current["temp"] <= 41:
advice = "It's cold — wear a jacket."
context.update({"current": current, "forecast": daily[:8], "advice": advice, "data_source": data_source})
return render_template_string(BASE_HTML, **context)
# ------------------------------
# Small helpers for times
# ------------------------------
def ts_to_readable(dt_ts, tz_offset_seconds=0):
try:
dt = ts_to_local(dt_ts, tz_offset_seconds)
return dt.strftime("%Y-%m-%d %H:%M")
except Exception:
return ""
def ts_to_local(dt_ts, tz_offset_seconds=0):
from datetime import datetime, timezone, timedelta
tz = tz_offset_seconds if tz_offset_seconds is not None else 0
return datetime.utcfromtimestamp(dt_ts + tz)
# ------------------------------
# Start server
# ------------------------------
if __name__ == "__main__":
# Simple info at startup
print("Starting Weather Forecast App")
print("OPENWEATHER_API_KEY present:" , bool(OPENWEATHER_API_KEY))
app.run(debug=True, port=5000)
•
OPENWEATHER_API_KEY environment variable is used for real API calls. If it's missing, the app displays sample data so you can test the UI.• Lightweight in-memory TTL cache implemented via
ttl_cache decorator prevents repeated calls for the same city within the TTL.• Templates are embedded in
BASE_HTML and use Bootstrap via CDN.• Forecast summary (
summarize_forecast) groups 3-hour slots into date-level min/max/description.• UI supports unit switching (metric/imperial).
6. Sample Output or Results
After running python app.py, open http://127.0.0.1:5000/:
- Enter Karachi (or any city). With a valid API key, current weather and 5-day forecast appear.
- Without an API key, you see sample city “Sample City” with sample forecast — excellent for demos or offline testing.
- Flash warnings inform you if API calls failed and that sample data was used.
- The page shows: large current temperature, description, “Quick Advice”, and cards for upcoming days (date, icon, description, min/max).
Example advice strings:
- “Carry an umbrella — rain expected.”
- “It's hot — stay hydrated.”
7. Possible Enhancements
- Interactive charts: add Plotly or Chart.js time series (temperature over time).
- Geolocation: auto-detect user’s location (browser geolocation) and show local weather on load.
- Air quality and alerts: integrate OpenWeather Air Pollution API and alerts.
- Authentication & rate quotas: require API key registration for multi-user deployment and track usage.
- Persistent favorites: save favorite cities per user (use SQLite or Redis).
- Background updater: use Celery / APScheduler to prefetch data for popular cities.
- Dockerize & deploy: add a Dockerfile, and deploy to Render / Heroku / AWS ECS.