You've been there: you check the price of something every day for two weeks, get bored, stop checking — and it goes on sale the day after you stop. Or you finally buy it, and the price drops 20% the next morning.

A price tracker fixes this with three small pieces of Python:

  1. Scrape the current price from a product page
  2. Store it in a history file
  3. Compare — and email you when the price drops

If you read our Send Emails with Python guide, you saw a teaser of this project — the price drop alert at the end used a fake get_price() function. Here's the real version, and it's less scary than it looks. Each piece is a script you can run and check on its own before combining them.

Piece 1: Getting the Price

We'll use requests to download the product page and BeautifulSoup to pull the price out of the HTML. Both are covered in depth in our web scraping with Python guide — here's the short version.

First, install:

pip install requests beautifulsoup4

Then the scraper:

import requests
from bs4 import BeautifulSoup

def get_price(url, selector):
    """Fetch a product page and return the price as a float."""
    # Some sites block the default Python user agent — pretend to be a browser
    headers = {
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
    }
    resp = requests.get(url, headers=headers, timeout=10)
    resp.raise_for_status()  # crash loudly if the page fails to load

    soup = BeautifulSoup(resp.text, "html.parser")
    price_text = soup.select_one(selector).text.strip()

    # "$1,299.99" → 1299.99
    return float(price_text.replace("$", "").replace(",", ""))

The one tricky part is the selector — the CSS path to the price on the page. You find it in 30 seconds:

  1. Open the product page in your browser
  2. Right-click the price → Inspect (or Inspect Element)
  3. The DevTools panel opens with the price highlighted
  4. Right-click the highlighted line → CopyCopy selector

That gives you something like span.current-price. Pass it to get_price() and you're done:

price = get_price("https://shop.example.com/coffee-maker", "span.current-price")
print(f"Current price: ${price:.2f}")
# Current price: $39.99

What if the page blocks me?

Three things happen in the real world, and they all have fixes:

  • The site shows no price to bots — some big stores (Amazon especially) work hard to block scrapers. Rather than fighting them, track a store that plays nicer, or check if the store has an official API.
  • The price loads with JavaScriptrequests only sees the HTML the server sends, not what JS renders afterward. If the price is missing, you need a real browser: our Playwright guide covers exactly this.
  • The page layout changes — your selector quietly stops matching. The script will crash with an error about NoneType; re-copy the selector and move on.

Also, be polite: one request per product per day is plenty. Don't hammer a store's server every 5 seconds — that's how scrapers get IP-banned.

Test your selector offline first

While you're developing, you don't need to request the page every time. Save the page once (right-click → Save As → "Web Page, HTML Only"), then work against the local copy:

from pathlib import Path

soup = BeautifulSoup(Path("product-page.html").read_text(), "html.parser")
price_text = soup.select_one(selector).text.strip()
print(price_text)

This is also how you debug when a selector breaks later — save the current page, open the file, and figure out what changed without making a single request.

Piece 2: Remembering Yesterday's Price

Scraping gives you today's price. A tracker needs yesterday's too. The simplest storage is a JSON file in your home folder:

import json
from pathlib import Path

HISTORY_FILE = Path.home() / ".price-history.json"

def load_history():
    """Return the saved history, or an empty dict on first run."""
    if HISTORY_FILE.exists():
        return json.loads(HISTORY_FILE.read_text())
    return {}

def save_history(history):
    """Write history back to disk, nicely formatted."""
    HISTORY_FILE.write_text(json.dumps(history, indent=2))

The file looks like this after a few runs:

{
  "coffee maker": {
    "last_price": 49.99,
    "last_check": "2026-08-14"
  },
  "bluetooth speaker": {
    "last_price": 24.99,
    "last_check": "2026-08-14"
  }
}

Keyed by product name — easy to read, easy to edit by hand, and json is in the standard library. (When your tracker grows past a dozen products, a real database like SQLite becomes worth it. For 2–10 products, a JSON file is the right amount of engineering.)

Piece 3: Sending the Alert

When today's price is lower than the stored price, email yourself. This is the exact smtplib pattern from the email guide — App Password, environment variables, the lot:

import os
import smtplib
from email.mime.text import MIMEText

def send_email(subject, body):
    """Send an email to yourself."""
    msg = MIMEText(body)
    msg["Subject"] = subject
    msg["From"] = os.environ["GMAIL_ADDRESS"]
    msg["To"] = os.environ["GMAIL_ADDRESS"]

    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
        server.send_message(msg)

    print(f"Alert sent: {subject}")

Putting It Together: The Full Tracker

Now the main script. It reads a config of products, checks each one, alerts on drops, and updates history. It's completely free — copy it, run it, and you're done. No paid services, no subscriptions, just standard Python libraries:

"""price-tracker.py — track product prices and email on drops."""
import os
import json
from pathlib import Path
import requests
from bs4 import BeautifulSoup
import smtplib
from email.mime.text import MIMEText

# ── Config ────────────────────────────────────────
PRODUCTS = {
    "coffee maker": {
        "url": "https://shop.example.com/coffee-maker",
        "selector": "span.current-price",
        "target": 45.00,   # optional: also alert if it goes below this
    },
    "bluetooth speaker": {
        "url": "https://shop.example.com/speaker",
        "selector": "span.current-price",
        "target": 25.00,
    },
}
HISTORY_FILE = Path.home() / ".price-history.json"
HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"}

# ── Helpers (same as above) ───────────────────────
def get_price(url, selector):
    resp = requests.get(url, headers=HEADERS, timeout=10)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")
    price_text = soup.select_one(selector).text.strip()
    return float(price_text.replace("$", "").replace(",", ""))

def load_history():
    if HISTORY_FILE.exists():
        return json.loads(HISTORY_FILE.read_text())
    return {}

def save_history(history):
    HISTORY_FILE.write_text(json.dumps(history, indent=2))

# ── Main loop ─────────────────────────────────────
history = load_history()

for product, cfg in PRODUCTS.items():
    try:
        current = get_price(cfg["url"], cfg["selector"])
    except Exception as e:
        print(f"⚠ {product}: check failed ({e}) — skipping")
        continue  # don't let one broken selector kill the whole run

    previous = history.get(product, {}).get("last_price")
    print(f"{product}: ${current:.2f}")

    if previous is not None and current < previous:
        drop = previous - current
        pct = drop / previous * 100
        send_email(
            f"💰 {product}: ${previous:.2f} → ${current:.2f} ({pct:.0f}% off)",
            f"{product} dropped from ${previous:.2f} to ${current:.2f} "
            f"({pct:.0f}% off).\n\nSent by your Python price tracker.",
        )
    elif cfg["target"] and current <= cfg["target"]:
        send_email(
            f"🎯 {product} hit your target price: ${current:.2f}",
            f"{product} is now ${current:.2f}, at or below your target "
            f"of ${cfg['target']:.2f}.\n\nSent by your Python price tracker.",
        )

    history[product] = {"last_price": current, "last_check": "today"}

save_history(history)
print("Done.")

Run it:

GMAIL_ADDRESS="you@gmail.com" \
GMAIL_APP_PASSWORD="your-app-password" \
python price-tracker.py
coffee maker: $39.99
Alert sent: 💰 coffee maker: $49.99 → $39.99 (20% off)
bluetooth speaker: $24.99
Done.

First run: it saves prices without alerting — there's no history to compare against yet. From the second run on, any drop lands in your inbox.

Note the try/except around get_price(). Without it, one store changing its HTML would crash the whole script and none of your other products would get checked. With it, the broken one prints a warning and life goes on.

Schedule It and Forget It

One run per day is the sweet spot — enough to catch sales, gentle on the store's server. The Cron Jobs guide covers the details:

GMAIL_ADDRESS=you@gmail.com
GMAIL_APP_PASSWORD=xxxx
0 8 * * * /usr/bin/python3 /home/user/price-tracker.py >> /var/log/price-tracker.log 2>&1

Morning is a good time: you'll see the alert over breakfast, before you'd have checked prices yourself anyway.

Common Problems (And Their Fixes)

Symptom Cause Fix
AttributeError: 'NoneType' ... text Selector no longer matches the page Re-copy the selector from DevTools
Price is missing but page loads Price rendered by JavaScript Use Playwright (see our guide)
403 or CAPTCHA page Store is blocking bots Track a different store or use its official API
Alert fires constantly Price flapping up and down daily Only alert on drops over 5%: add if pct >= 5:
History file grows forever One entry per product per run It doesn't — each run overwrites the previous entry

What's Next?

Your tracker works, but it's the beginning, not the end:

  • Track more than price — the same scraper can watch for "out of stock → back in stock", or a product listing going live
  • Chart the history — instead of overwriting last_price, append every check to a list and plot it with matplotlib
  • Get alerts on your phone — email works, but a message ping beats an inbox ding when you need to act fast

Ready for more automation? These pair well with a price tracker:

Set it up tonight and let it run for a week. The first time you get a price drop email you didn't check for, the whole thing suddenly feels worth it.

All code in this article was tested and runs successfully on Python 3.8.10 (Ubuntu 20.04) with requests 2.32.4, beautifulsoup4 4.8.2 — verified August 2026.