DEV Community

Russell Yapp
Russell Yapp

Posted on

Comparing UK supermarket prices programmatically: Tesco, Sainsbury's, Asda and Aldi in one schema

The problem

Four supermarkets, four sites, four different schemas, and no shared product id between them. If you want to compare Tesco, Sainsbury's, Asda and Aldi prices programmatically, what you actually need is one row shape that fits all four, and a key you can join rows on. We built the UK Supermarket Price Scraper, an Apify Actor, to return exactly that: one schema across all four retailers, with the join keys built in.

The join keys

The join key we use is ean, the GTIN-13 barcode, validated by its check digit. Tesco carries an EAN on every row we have seen. Sainsbury's carries one on nearly every row (codes that fail the check digit are dropped). Asda carries one on about 97% of rows, recovered from Asda's own product image id; the rest have none. Aldi publishes no barcode anywhere in its listing API, product API or product pages, so Aldi rows carry an empty ean array.

Every row, from all four retailers, also carries matchKey: a normalised brand, name and pack size. It is the join key for Aldi, and the recommended way to match the same product across retailers generally, since every row carries it whether or not an EAN is present.

One honest caveat: pack sizes are written differently across retailers, for example Asda's "4 pint" against Tesco's "2.272L" for the same four-pint milk. A matchKey join on own-label milk needs a normalising step of its own to catch that. An ean join does not, because the barcode identifies the exact product regardless of how its pack size is written on the page.

A worked comparison

All prices below are national online shelf prices as published on each retailer's own site on 24 September 2026. They will have moved since; treat the table as an illustration of the method, not today's prices.

Product Tesco Sainsbury's Asda Aldi
Semi-skimmed milk, 4 pints (2.27 L) £1.65 £1.65 £1.65 £1.65
Own-label salted butter, 250 g £1.85 £1.85 £1.85 £1.85
Granulated sugar, 1 kg £1.09 £1.09 — £1.09
Cravendale filtered semi-skimmed milk, 1 L — £1.75 (Nectar £1.40) £1.64 —
Free-range large eggs, 6 — £2.10 £2.10 £1.69

Asda's granulated sugar comes in a 500 g pack at £0.89, not the 1 kg pack the other three sell, so it is left out of the row above rather than compared directly.

Two things stand out. First, on plain staples the big four price-match each other to the penny: milk, butter and sugar are identical, or near enough, across Tesco, Sainsbury's, Asda and Aldi. The interesting signal is not the staple; it is loyalty pricing and the branded lines, where retailers actually diverge, as the Cravendale and egg rows above show. Second, a comparison has to use the effective price, meaning the loyalty price when one is shown, or it misreads Sainsbury's and Tesco: Sainsbury's £1.75 shelf price for Cravendale becomes £1.40 with a Nectar card, and any Tesco row carrying a Clubcard price in loyaltyPrice is cheaper than its price field alone suggests.

Code: cheapest price per product, across all four retailers

One run across all four retailers in search mode, then group the rows by ean (falling back to matchKey where ean is empty) and keep the cheapest effective price per product:

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("yappman/uk-supermarket-price-scraper").call(
    run_input={
        "mode": "search",
        "retailers": ["tesco", "sainsburys", "asda", "aldi"],
        "queries": ["semi skimmed milk"],
        "maxItems": 200,
    }
)

def effective_price(row):
    loyalty = row.get("loyaltyPrice")
    price = row["price"]
    return loyalty if loyalty is not None and loyalty < price else price

cheapest = {}
for row in client.dataset(run.default_dataset_id).iterate_items():
    key = row["ean"][0] if row["ean"] else row["matchKey"]
    price = effective_price(row)
    if key not in cheapest or price < cheapest[key]["price"]:
        cheapest[key] = {"row": row, "price": price}

for entry in cheapest.values():
    row = entry["row"]
    print(row["retailer"], row["name"], entry["price"])
Enter fullscreen mode Exit fullscreen mode

effective_price treats a loyalty price as the real price whenever it is lower than the shelf price. Asda and Aldi rows always fall through to price, because those two retailers never set loyaltyPrice.

History and trend

For a longer view than one run gives you, the Actor also publishes a free daily dataset: a fixed basket of 212 staple products, up to three products per retailer for each of 20 staples (milk, bread, eggs, butter, cheddar, chicken breast, bananas, apples, potatoes, onions, tomatoes, pasta, rice, baked beans, cereal, coffee, tea bags, sugar, orange juice, olive oil), across Tesco, Sainsbury's, Asda and Aldi. It is fetched every morning and appended to one dataset, on the same schema as the Actor's own output, so the same products build a price history from 24 September 2026. Filter or group on scrapedAt for a given day's snapshot.

  • JSON: https://api.apify.com/v2/datasets/ynAT9NPps2EdjMOJa/items?format=json
  • CSV: https://api.apify.com/v2/datasets/ynAT9NPps2EdjMOJa/items?format=csv

Neither URL needs an account or token. The same basket is also on Kaggle as a CSV under CC BY 4.0, refreshed monthly: UK Supermarket Prices: Daily 212-Item Basket.

For a market-level view rather than a basket, there is also the UK Online Shelf-Price Index: a weekly index of posted online prices at the same four retailers, with the week of 27 September 2026 (2026-W39) as 100. It is published as aggregates only (a headline, each retailer and each product class, with the number of matched products behind every figure). Each week is appended and never revised, and the four-week change appears from W43.

  • JSON: https://api.apify.com/v2/datasets/AvRCWqFinCiNjx3sP/items?format=json
  • CSV: https://api.apify.com/v2/datasets/AvRCWqFinCiNjx3sP/items?format=csv

It measures posted online shelf prices at these four retailers, not what shoppers pay, and it is not the ONS CPI. The method is written up at https://data.yappman.com/shelf-price-index. One row per index value:

import pandas as pd
import requests

INDEX = "https://api.apify.com/v2/datasets/AvRCWqFinCiNjx3sP/items"
rows = requests.get(INDEX, params={"format": "json"}, timeout=60).json()
retailers = pd.DataFrame([r for r in rows if r["recordType"] == "retailer"])
print(retailers.pivot(index="vintage", columns="retailer", values="level"))
Enter fullscreen mode Exit fullscreen mode

A week-on-week change per product, once the rows are in a dataframe:

import pandas as pd
import requests

DATASET = "https://api.apify.com/v2/datasets/ynAT9NPps2EdjMOJa/items"
rows = requests.get(DATASET, params={"format": "json"}, timeout=60).json()
df = pd.DataFrame(rows)
df["scrapedAt"] = pd.to_datetime(df["scrapedAt"])
# Keep the last seven days: from the start of the day a week before the newest run.
start = df["scrapedAt"].max().normalize() - pd.Timedelta(days=7)
df = df[df["scrapedAt"] >= start].sort_values("scrapedAt")
weekly = (
    df.groupby(["retailer", "retailerProductId"])
    .agg(first_price=("price", "first"), last_price=("price", "last"))
)
weekly["change"] = weekly["last_price"] - weekly["first_price"]
Enter fullscreen mode Exit fullscreen mode

Cost and limits

Pricing is pay-per-event: $0.005 once per run, plus $2.50 per 1,000 products returned ($0.0025 each) on the Free plan, with discounts on higher Apify plans. Cap spend with Apify's maximum-charge setting on the run, and with the maxItems input. The data itself is public and logged-out: no basket, account or delivery-slot information. Asda's stock flag is for a single store, set by asdaStoreId, though Asda's prices themselves stay national. Aldi publishes neither stock nor a barcode for any product, which is why its rows join on matchKey instead of ean.

Where to find it

The UK Supermarket Price Scraper is on the Apify Store: https://apify.com/yappman/uk-supermarket-price-scraper. This Actor is not affiliated with, endorsed by, or partnered with Aldi, Sainsbury's, Asda or Tesco; retailer names are used only to describe the public product data it returns.

Top comments (1)

Collapse
 
yappman profile image
Russell Yapp •

Happy to answer questions on the schema or on matching products across retailers.