Skip to content

E-commerce Website (Basic)

Every online store boils down to three moves: show products, let people add them to a cart, and total it up. This project builds exactly that in Flask — a routes-and-templates web app with a product list, an add-to-cart action, and a cart page that sums the prices. It’s the perfect introduction to server-side web development: routing, URL parameters, template rendering, and redirects. Then you’ll fix the one bug that makes the starter version unusable as a real site — a global cart shared by every visitor — and add quantities, checkout, and a database.

You will leave understanding:

  • How Flask maps URLs to functions with @app.route.
  • The request → process → redirect pattern (POST/Redirect/GET).
  • How render_template passes data into Jinja HTML.
  • Why a module-level cart list is a serious bug, and how sessions fix it.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Flask: pip install flask.
  • Basic HTML.
  • Comfort with functions and lists/dicts.
  1. Create a folder named ecommerce.
  2. Inside it, create ecommerce_website.py and a templates/ folder.
  3. Install Flask: pip install flask.

Flask looks for HTML in templates/. You’ll need index.html and cart.html:

templates/index.html
<h1>Products</h1>
<ul>
{% for p in products %}
  <li>{{ p.name }} — ${{ p.price }}
      <a href="{{ url_for('add_to_cart', product_id=p.id) }}">Add to cart</a></li>
{% endfor %}
</ul>
<a href="{{ url_for('view_cart') }}">View cart</a>
templates/cart.html
<h1>Your Cart</h1>
<ul>{% for item in cart %}<li>{{ item.name }} — ${{ item.price }}</li>{% endfor %}</ul>
<p>Total: ${{ total_price }}</p>
<a href="{{ url_for('index') }}">Keep shopping</a>
ecommerce_website.py pch.viewSource
ecommerce_website.py
"""
E-commerce Website (Basic)

A Python application that simulates a basic e-commerce website.
Features include:
- Displaying a list of products.
- Adding products to a shopping cart.
- Calculating the total price.
"""

import os
import sys
from flask import Flask, render_template, request, redirect, url_for

app = Flask(__name__)

# Sample product data
products = [
    {"id": 1, "name": "Laptop", "price": 800},
    {"id": 2, "name": "Smartphone", "price": 500},
    {"id": 3, "name": "Headphones", "price": 100},
    {"id": 4, "name": "Keyboard", "price": 50},
]

# Shopping cart
cart = []

@app.route('/')
def index():
    """Display the list of products."""
    return render_template('index.html', products=products)

@app.route('/add_to_cart/<int:product_id>')
def add_to_cart(product_id):
    """Add a product to the shopping cart."""
    product = next((p for p in products if p['id'] == product_id), None)
    if product:
        cart.append(product)
    return redirect(url_for('view_cart'))

@app.route('/cart')
def view_cart():
    """Display the shopping cart and total price."""
    total_price = sum(item['price'] for item in cart)
    return render_template('cart.html', cart=cart, total_price=total_price)


# Flask looks for templates on disk, so a self-contained single-file project
# has to put them there before the first request. Keeping them as strings
# means the project stays one file; writing them at startup means Jinja can
# actually find them.
TEMPLATES = {
    "base.html": """<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>{% block title %}Shop{% endblock %}</title>
<style>
 body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
 table { border-collapse: collapse; width: 100%; }
 td, th { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #ddd; }
 a { color: #06c; }
</style></head>
<body>
<h1><a href="{{ url_for('index') }}">Shop</a></h1>
{% block body %}{% endblock %}
</body></html>""",

    "index.html": """{% extends "base.html" %}
{% block body %}
<p><a href="{{ url_for('view_cart') }}">View cart</a></p>
<table>
<tr><th>Product</th><th>Price</th><th></th></tr>
{% for product in products %}
<tr>
  <td>{{ product.name }}</td>
  <td>${{ product.price }}</td>
  <td><a href="{{ url_for('add_to_cart', product_id=product.id) }}">Add to cart</a></td>
</tr>
{% endfor %}
</table>
{% endblock %}""",

    "cart.html": """{% extends "base.html" %}
{% block title %}Cart{% endblock %}
{% block body %}
{% if cart %}
<table>
<tr><th>Product</th><th>Price</th></tr>
{% for item in cart %}
<tr><td>{{ item.name }}</td><td>${{ item.price }}</td></tr>
{% endfor %}
<tr><th>Total</th><th>${{ total_price }}</th></tr>
</table>
{% else %}
<p>The cart is empty.</p>
{% endif %}
<p><a href="{{ url_for('index') }}">Keep shopping</a></p>
{% endblock %}""",
}


def write_templates():
    """Put the templates where Jinja will look for them."""
    os.makedirs("templates", exist_ok=True)
    for name, body in TEMPLATES.items():
        with open(os.path.join("templates", name), "w",
                  encoding="utf-8") as handle:
            handle.write(body)


def smoke_test():
    """Exercise every GET route once, without starting a server.

    `app.test_client()` dispatches a real request through the real application
    object -- no socket, no port, no waiting. A web project that cannot be
    driven this way cannot be tested either.

    It returns the number of routes that did **not** answer 2xx, and the
    caller turns that into an exit code. The earlier version printed the
    status and exited 0 regardless, so this file reported success while every
    content route returned 500.
    """
    print("smoke test: dispatching one request per route\n")
    broken = 0
    with app.test_client() as client:
        rules = sorted(app.url_map.iter_rules(), key=lambda rule: str(rule))
        checked = 0
        for rule in rules:
            if "GET" not in rule.methods or rule.arguments:
                continue
            response = client.get(str(rule))
            body = " ".join(response.get_data(as_text=True).split())[:60]
            if response.status_code >= 400:
                broken += 1
            print(f"  GET {str(rule):26} {response.status_code}  {body}")
            checked += 1
    print(f"\n{checked} route(s) answered, {broken} failing. "
          f"Pass --serve to start the real server instead.")
    return broken


if __name__ == "__main__":
    # Templates first: they are what the routes render, and rendering them
    # before they exist is what made every content route 500.
    write_templates()
    # Serving is opt-in, because a run that never returns cannot be tested or
    # captured. With no arguments the file answers every route once and exits.
    if "--serve" in sys.argv:
        app.run(debug=True)
    else:
        raise SystemExit(1 if smoke_test() else 0)
command
C:\Users\Your Name\ecommerce> python ecommerce_website.py
# Visit http://127.0.0.1:5000 in your browser.

Running the file exactly as it ships takes 0.5 s and prints:

python ecommerce_website.py
smoke test: dispatching one request per route
 
  GET /                          200  <!DOCTYPE html> <html lang="en"> <head><meta charset="utf-8"
  GET /cart                      200  <!DOCTYPE html> <html lang="en"> <head><meta charset="utf-8"
 
2 route(s) answered, 0 failing. Pass --serve to start the real server instead.
ecommerce_website.py
app = Flask(__name__)
products = [
    {"id": 1, "name": "Laptop", "price": 800},
    {"id": 2, "name": "Smartphone", "price": 500},
    ...
]
cart = []

Flask(__name__) creates the app. Products are a hard-coded list of dicts (a database stand-in). cart is — for now — a single global list. (Hold that thought.)

ecommerce_website.py
@app.route('/')
def index():
    return render_template('index.html', products=products)

The decorator binds the URL / to index(). render_template loads index.html and injects products so Jinja can loop over them.

ecommerce_website.py
@app.route('/add_to_cart/<int:product_id>')
def add_to_cart(product_id):
    product = next((p for p in products if p['id'] == product_id), None)
    if product:
        cart.append(product)
    return redirect(url_for('view_cart'))

<int:product_id> captures a number from the URL and passes it as an argument. After adding, the route redirects to the cart — the Post/Redirect/Get pattern, which stops a page refresh from re-adding the item.

ecommerce_website.py
@app.route('/cart')
def view_cart():
    total_price = sum(item['price'] for item in cart)
    return render_template('cart.html', cart=cart, total_price=total_price)

A generator expression sums the prices — clean and lazy.

The Critical Bug: One Cart for the Whole Internet

Section titled “The Critical Bug: One Cart for the Whole Internet”

cart = [] lives at module level, so every visitor shares the same cart. Two users shopping at once would see each other’s items. The fix is Flask sessions — per-user storage backed by a signed cookie:

sessions.py
from flask import session
app.secret_key = "change-me"     # required to sign the session cookie
 
@app.route('/add_to_cart/<int:product_id>')
def add_to_cart(product_id):
    cart = session.get("cart", [])
    cart.append(product_id)          # store IDs, not whole objects
    session["cart"] = cart
    return redirect(url_for('view_cart'))

Now each browser gets its own cart. This is the single most important upgrade in the project.

Storing duplicate IDs is clumsy. Use a {product_id: quantity} map:

quantities.py
cart = session.get("cart", {})
cart[str(pid)] = cart.get(str(pid), 0) + 1     # JSON keys must be strings
session["cart"] = cart

On the cart page, multiply price × quantity per line.

A form that captures the order and clears the cart:

checkout.py
@app.route('/checkout', methods=['POST'])
def checkout():
    order = session.pop("cart", {})
    # save the order to a database here...
    return render_template('thanks.html', order=order)

Hard-coded products don’t scale. SQLite is the natural next step:

db.py
import sqlite3
def get_products():
    conn = sqlite3.connect("shop.db")
    rows = conn.execute("SELECT id, name, price FROM products").fetchall()
    conn.close()
    return [{"id": r[0], "name": r[1], "price": r[2]} for r in rows]
ProblemCauseFix
Everyone shares one cartGlobal cart listUse session (per-user)
RuntimeError: secret keySessions without a keySet app.secret_key
TemplateNotFoundHTML not in templates/Put templates in the templates/ folder
Refresh re-adds the itemAction returned HTML directlyRedirect after the POST (PRG)
Prices wrong with duplicatesCounting items, not quantitiesStore {id: qty} and multiply
Data lost on restartEverything in memoryPersist products/orders to a DB
  1. Sessions cart — the must-do fix above.
  2. Quantities & remove — increment, decrement, delete lines.
  3. Product detail pages/product/<id> with description and image.
  4. Search & categories — filter the catalog.
  5. User accounts — login, order history (see REST API with Authentication).
  6. Payment stub — integrate a sandbox Stripe checkout.
  7. Admin panel — add/edit products through a form.
  • Online stores — the skeleton of any Shopify-style shop.
  • Booking systems — carts generalize to reservations and tickets.
  • SaaS billing — plan selection and checkout flows.
  • Learning Flask — the canonical “real app” beyond hello-world.
  • Web routing — URLs to handlers, URL parameters.
  • Templating — Jinja loops and url_for.
  • State & sessions — the difference between global and per-user state.
  • App architecture — the path from in-memory demo to DB-backed app.
  • Replace the global cart with sessions.
  • Add quantities, remove, and a checkout.
  • Move products and orders into a database.
  • Add accounts and a payment integration.

Here’s how a request flows through the Flask app, from the browser to the database and back as rendered HTML.

diagram E-commerce Flask architecture mermaid
A browser request travels through Flask routes and business logic to the database, then comes back as a rendered Jinja2 template.

You built a working e-commerce site — products, cart, totals — and learned the routing/templating/redirect loop at the heart of every Flask app. More importantly, you found and fixed the bug that separates a demo from a real store: shared global state versus per-user sessions. With quantities, checkout, and a database, this scales into a genuine shop. Full source on GitHub. Explore more web projects on Python Central Hub.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading