Skip to main content

Web Development with Python

Building web applications using Python frameworks

Python web development covers 3 main frameworks: Flask for small services, Django for full-stack apps, FastAPI for async REST APIs. The student-level decision tree: pick Flask for a single endpoint or class project, Django for anything with a database and admin panel, FastAPI when type hints and async I/O matter.

Flask routing and Jinja2 templates in 20 lines

Flask is a microframework. The entire app fits on one screen: import `Flask`, instantiate an app object, decorate functions with `@app.route` to bind URL paths, run with `app.run(debug=True)`. The decorator pattern is the central abstraction. A function below `@app.route("/hello")` becomes the handler that runs when a browser requests `/hello`.

URL parameters arrive through angle-bracket syntax in the route and matching keyword arguments in the handler. `@app.route("/user/")` matches `/user/ana` and passes `username="ana"` to the function. Add type converters like `` to enforce that the segment parses as an integer, returning 404 otherwise. The HTTP method defaults to GET; pass `methods=["GET", "POST"]` to accept form submissions.

Jinja2 is Flask's templating engine. Templates live in a `templates/` directory next to the script. `render_template("page.html", name=username)` reads the file, replaces `{{ name }}` with the value, and returns HTML. Control flow uses `{% if %}` and `{% for %}` blocks. Template inheritance through `{% extends "base.html" %}` plus named `{% block content %}` regions keeps a header and footer in one place across every page. A CS50W problem set typically demands inheritance plus a Bootstrap base, so practicing the extends pattern matters.

Example

                      
                        # Requires: pip install flask
from flask import Flask, render_template_string, request

app = Flask(__name__)

# Inline template stands in for a templates/index.html file
HOME = """
<h1>Hello, {{ name }}</h1>
<form method="post" action="/echo">
  <input name="msg" placeholder="Say something">
  <button type="submit">Send</button>
</form>
"""

@app.route("/")
def home():
    return render_template_string(HOME, name="student")

@app.route("/user/<username>")
def show_user(username):
    return f"<h1>Profile: {username}</h1>"

@app.route("/echo", methods=["POST"])
def echo():
    msg = request.form.get("msg", "")
    return f"You said: {msg}"

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

Django models, views, and templates: the MTV trio

Django follows the Model-Template-View pattern. The Model defines database tables as Python classes. The View is a function or class that accepts an HTTP request and returns a response. The Template renders HTML from context data. Project layout is opinionated: `startproject` creates the configuration, `startapp` creates a feature module with its own `models.py`, `views.py`, `urls.py`, and `templates/` folder.

Models inherit from `django.db.models.Model`. Each field is a class attribute: `CharField` for short strings with a `max_length`, `TextField` for unbounded text, `IntegerField`, `DateTimeField` with `auto_now_add=True` to stamp creation time, `ForeignKey` to relate one model to another. After defining the model, run `python manage.py makemigrations` then `migrate` to write the schema to the database. The ORM exposes querysets: `Post.objects.filter(author=user).order_by("-created_at")` returns posts by that author, newest first.

The URL dispatch lives in `urls.py`. Each pattern maps a path to a view: `path("posts//", views.post_detail, name="post_detail")`. The `name` argument lets templates and other views reference the URL without hardcoding the path: `{% url "post_detail" pk=post.id %}`. CS50W and most intro Django courses grade strict adherence to this convention because hardcoded paths break under URL refactoring.

Example

                      
                        # Requires: pip install django
# File: blog/models.py
from django.db import models
from django.contrib.auth.models import User

class Post(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

# File: blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post

def post_list(request):
    posts = Post.objects.order_by("-created_at")[:10]
    return render(request, "blog/list.html", {"posts": posts})

def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk)
    return render(request, "blog/detail.html", {"post": post})

# File: blog/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.post_list, name="post_list"),
    path("<int:pk>/", views.post_detail, name="post_detail"),
]
                      
                    

REST API design: 4 HTTP verbs, 5 status codes worth memorizing

A REST API exposes resources at predictable URLs and uses HTTP verbs to describe the action. The 4 verbs that cover every CRUD operation: GET reads, POST creates, PUT or PATCH updates, DELETE removes. The 5 status codes worth memorizing: 200 OK for a successful read, 201 Created when POST adds a resource, 400 Bad Request when input fails validation, 401 Unauthorized when auth is missing or wrong, 404 Not Found when the resource ID does not exist.

URL design follows nouns, not verbs. `GET /api/posts/` lists posts. `GET /api/posts/42/` reads post 42. `POST /api/posts/` creates a post from the JSON body. `PUT /api/posts/42/` replaces post 42. `DELETE /api/posts/42/` removes it. Avoid `/api/getPosts` or `/api/createPost`: the verb is the HTTP method, not the path. Resources nest where the relationship is real: `GET /api/posts/42/comments/` for the comments on post 42.

Responses always carry JSON in both directions. Set `Content-Type: application/json` on the request and parse with `request.get_json()` in Flask or `request.data` in Django REST framework. Return JSON with `jsonify(data)` and an explicit status code. Validation belongs in one place: reject bad inputs at the edge of the API with 400 and a clear error message, so the handler logic can assume clean data.

Example

                      
                        # Requires: pip install flask
from flask import Flask, jsonify, request, abort

app = Flask(__name__)
posts = {1: {"id": 1, "title": "Hello", "body": "First post"}}
next_id = 2

@app.get("/api/posts/")
def list_posts():
    return jsonify(list(posts.values())), 200

@app.get("/api/posts/<int:pk>/")
def read_post(pk):
    if pk not in posts:
        abort(404, description="Post not found")
    return jsonify(posts[pk]), 200

@app.post("/api/posts/")
def create_post():
    global next_id
    data = request.get_json(silent=True) or {}
    if "title" not in data or "body" not in data:
        return jsonify({"error": "title and body required"}), 400
    post = {"id": next_id, "title": data["title"], "body": data["body"]}
    posts[next_id] = post
    next_id += 1
    return jsonify(post), 201

@app.delete("/api/posts/<int:pk>/")
def delete_post(pk):
    if pk not in posts:
        abort(404)
    del posts[pk]
    return "", 204
                      
                    

FastAPI: type hints, async, and auto-generated docs

FastAPI builds on Starlette and pydantic. It uses Python type hints as the source of truth for request and response validation, auto-generates an OpenAPI schema, and serves interactive docs at `/docs` without extra code. The performance is in the same range as Node.js because the framework runs on top of ASGI and `async def` handlers.

Pydantic models replace the manual validation logic that Flask handlers usually carry. Define a `BaseModel` subclass with typed fields, and FastAPI parses the request body, validates each field, and rejects with a 422 response listing every failure if anything is wrong. The same model serializes the response, so the input and output schemas are visible in the docs page without docstring duplication.

Async handlers matter when the endpoint waits on I/O: database queries, external HTTP calls, file reads. Inside an `async def` view you await async clients (`httpx.AsyncClient`, async ORMs like SQLAlchemy 2.0 in async mode, Motor for MongoDB). For CPU-bound work, FastAPI runs sync handlers in a thread pool, so a regular `def` view still works without blocking the event loop.

Example

                      
                        # Requires: pip install fastapi uvicorn pydantic
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title="Posts API")

class PostIn(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    body: str

class PostOut(PostIn):
    id: int

posts: dict[int, PostOut] = {}
next_id = 1

@app.get("/posts/", response_model=list[PostOut])
async def list_posts():
    return list(posts.values())

@app.post("/posts/", response_model=PostOut, status_code=201)
async def create_post(payload: PostIn):
    global next_id
    post = PostOut(id=next_id, **payload.model_dump())
    posts[next_id] = post
    next_id += 1
    return post

@app.get("/posts/{pk}", response_model=PostOut)
async def read_post(pk: int):
    if pk not in posts:
        raise HTTPException(status_code=404, detail="Post not found")
    return posts[pk]
# Run with: uvicorn main:app --reload
                      
                    

Web scraping with requests and BeautifulSoup

Web scraping pulls structured data from HTML pages. The 2-library stack: `requests` fetches the page over HTTP, `BeautifulSoup` parses the HTML and exposes a search API over the DOM. The pattern is consistent: `response = requests.get(url)`, check `response.status_code == 200`, parse with `BeautifulSoup(response.text, "html.parser")`, then call `soup.find` or `soup.select` to extract elements.

CSS selectors are the most ergonomic search API. `soup.select("article h2.title a")` returns every anchor tag inside an h2 with class `title` inside an article. `soup.find_all("span", class_="price")` filters by tag and attribute. Extract text with `.get_text(strip=True)` and attributes with `element["href"]`. The selectors mirror what you would write in JavaScript with `document.querySelectorAll`, so browser devtools double as a selector debugger.

Politeness rules apply. Read `robots.txt` at the site root before scraping anything. Send a real `User-Agent` header that identifies your script. Throttle requests with `time.sleep(1)` between calls, or use `requests-cache` to avoid re-fetching the same page during development. Many sites block scraping in their terms of service, and assignments that involve scraping production sites usually require fetching only a small allowlist of sample pages. For dynamic JavaScript-rendered pages, `requests` alone misses content loaded by client-side scripts: Playwright or Selenium handles those, at the cost of a real browser engine.

Example

                      
                        # Requires: pip install requests beautifulsoup4
import requests
from bs4 import BeautifulSoup
import time

URL = "https://example.com"
HEADERS = {"User-Agent": "CS50W-student-scraper/0.1"}

response = requests.get(URL, headers=HEADERS, timeout=10)
response.raise_for_status()  # raises if status is 4xx or 5xx

soup = BeautifulSoup(response.text, "html.parser")

# Page title
title = soup.find("title").get_text(strip=True)
print(f"Title: {title}")

# All paragraph text on the page
for i, p in enumerate(soup.find_all("p"), start=1):
    text = p.get_text(strip=True)
    if text:
        print(f"  P{i}: {text[:80]}")

# All outbound links
for link in soup.select("a[href]"):
    href = link["href"]
    if href.startswith("http"):
        print(f"  Link: {href}")

time.sleep(1)  # courtesy delay between requests
                      
                    

Authentication: session, token, or JWT

Authentication proves who the user is, authorization decides what they can do. The 3 common patterns: session cookies, opaque API tokens, JSON Web Tokens (JWT). Session cookies fit traditional server-rendered apps like Django: the server stores session state in a database or Redis, sends a cookie with an opaque session ID, looks up the session on each request. Django ships this out of the box through `django.contrib.auth` and `@login_required`.

API tokens fit single-purpose REST clients. The server issues a long random string at login, the client sends it in the `Authorization: Bearer ` header on every call, the server looks it up in a tokens table. Revocation is easy because deleting the row immediately invalidates the token. Django REST Framework's TokenAuthentication and Flask's `flask-httpauth` both implement this pattern in a few lines.

JWT carries the user identity inside a signed token. The server signs a JSON payload with a secret key. The client stores the token and sends it on each request. The server verifies the signature without a database lookup, which scales horizontally because no shared state is needed. The trade: revocation is hard because the token stays valid until it expires. Short expiry (15 minutes) plus a refresh token mechanism is the standard workaround. Never put passwords or sensitive PII inside a JWT payload: it is signed, not encrypted, and any client can decode it.

Example

                      
                        # Requires: pip install pyjwt
import jwt
import datetime

SECRET = "change-me-in-production"

def issue_token(user_id: int, hours: int = 1) -> str:
    payload = {
        "sub": user_id,
        "iat": datetime.datetime.utcnow(),
        "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=hours),
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

def verify_token(token: str) -> dict:
    try:
        return jwt.decode(token, SECRET, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise PermissionError("Token expired")
    except jwt.InvalidTokenError:
        raise PermissionError("Invalid token")

# Demo
token = issue_token(user_id=42, hours=1)
print(f"Issued token: {token[:40]}...")
claims = verify_token(token)
print(f"User: {claims['sub']}, expires: {claims['exp']}")
                      
                    

Common pitfalls

Flask routes return a 404 even though the function looks right, because the URL is missing a trailing slash.

Flask treats `/posts` and `/posts/` as different routes by default. Define routes with the trailing slash (`@app.route("/posts/")`) and Flask redirects the non-slash form. Be consistent across the app.

Django `makemigrations` produces no migration after editing a model.

Confirm the app is listed in `INSTALLED_APPS` in `settings.py` and that the model file is named `models.py` inside the app directory. Custom file names need explicit imports in `models/__init__.py`.

POST request to a Flask API returns 400 because `request.get_json()` is None.

Set the `Content-Type: application/json` header on the client request. Without that header Flask refuses to parse the body as JSON. Pass `silent=True` to `get_json` and validate the dict before reading keys.

BeautifulSoup `find_all` returns an empty list even though the element is visible in the browser.

The page renders content with JavaScript after load. `requests` sees only the initial HTML. Switch to Playwright or Selenium, or check for an underlying JSON API that the page calls and hit that endpoint directly.

JWT verification fails with "Invalid signature" after deploying to a second server.

Both servers must share the same secret key. Read the secret from an environment variable in production rather than a constant in the source file, and check that the env var is set on every host.

Django form submission throws CSRF verification failed on POST.

Add `{% csrf_token %}` inside every `<form method="post">` template. For API endpoints that legitimately accept cross-origin POST, exempt the view with `@csrf_exempt` and apply token authentication instead.

When to use web development with python

Choose Flask for a single-file class project or microservice, Django for any app with users, an admin interface, and a relational database, FastAPI when the deliverable is a typed REST API or async I/O dominates the workload.

Need Help?

Having trouble with this topic on an assignment? Our Python developers ship working code plus a walkthrough that helps you explain the code in class.