- Inflation
- Analysis
- Price data
- Apify
How to Track Grocery Inflation with a Fixed Basket of Staples
Grocery inflation shows up on your receipt well before it shows up in a statistic. The official food number is monthly, national, and published after the month it describes. Pricing the same basket yourself gets you a weekly read on your own city and your own stores.
Savvi7 min read
Start here
Ten staples, three chains, one location. Paste this into the Canadian Grocery Price Comparison API and hit Start.
{
"queries": ["eggs", "milk", "bread", "butter", "chicken breast",
"ground beef", "rice", "bananas", "onions", "canned tomatoes"],
"location": "Vancouver, BC",
"retailers": ["loblaws", "saveonfoods", "tnt"],
"includeSummaries": true,
"minMatchConfidence": "high"
}includeSummaries adds a basket_summary record carrying totals_by_retailer and cheapest_retailer, so the basket total per chain is computed for you. minMatchConfidence: "high" matters more here than in a one-off comparison, for reasons I get to below.
Then schedule it. In the Apify console: the Actor’s Schedules tab, weekly.
The basket
Every item earns its place three ways. It’s a staple people actually buy, it’s stocked at every chain you’re pricing, and it has a package size the matcher can normalize into a unit price.
| Item | Why it’s in |
|---|---|
| Eggs, milk, butter | Dairy and eggs move fast and early. They tend to lead a food price cycle rather than follow it. |
| Bread, rice | Grain staples. Slow movers, which makes them the stable floor of the index. |
| Chicken breast, ground beef | Proteins are the most volatile line in the basket. Two of them, so neither one alone swings the total. |
| Bananas, onions | Produce priced by weight year-round, so the unit price stays meaningful across seasons. |
| Canned tomatoes | A packaged good with a fixed size. The cleanest shrinkflation detector in the basket. |
Turning runs into history
The Actor returns current prices and stores nothing between runs, so your history starts the day you schedule it. There’s no backfill. Each run also writes its own dataset, which means building a series is a matter of listing past runs and reading each one:
import pandas as pd
from apify_client import ApifyClient
client = ApifyClient("YOUR_API_TOKEN")
ACTOR = "sunny_eternity/canada-grocery-price-comparison"
rows = []
for run in client.actor(ACTOR).runs().list(limit=52, desc=True).items:
if run["status"] != "SUCCEEDED":
continue
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
if item.get("_type") != "comparison":
continue
rows.append({
"date": run["startedAt"].date(),
"query": item["query"],
"retailer": item["retailer"],
"price": item["price"],
"unit_price": item["comparable_unit_price"],
"product_id": item["product_id"],
})
df = pd.DataFrame(rows)Skipping runs that didn’t reach SUCCEEDED keeps a half-finished scrape out of the series. Pin schema_version too if you’re storing this long term.
Building the index
Two numbers worth having. What the basket costs, in dollars, and that cost indexed to your first week.
# Cheapest match per item per date, at one retailer.
wide = (df[df.retailer == "loblaws"]
.pivot_table(index="date", columns="query", values="price", aggfunc="min")
.dropna() # a date counts only if every item matched
.sort_index())
cost = wide.sum(axis=1)
index = 100 * cost / cost.iloc[0]
print(pd.DataFrame({"basket_cost": cost.round(2), "index": index.round(1)}))That dropna() is doing real work. If one item fails to match in a given week, the basket total for that week is a smaller basket, and it’ll read as deflation you didn’t have. Dropping the incomplete week is the honest fix.
Catching shrinkflation
Here’s the part most home-built trackers miss. When a 1 L carton becomes 900 ml at the same sticker, price reports no change at all, while you’re paying about 11% more for what’s inside. comparable_unit_price is what catches it, since it normalizes to the basis named in comparable_unit_basis.
Run the same index on both fields and the gap between them is your signal:
def relatives(col):
"""Average of per-item price relatives, base 100.
Summed totals work for price but not for unit price, since that would add
$/100g to $/100ml. Per-item changes averaged together avoids that.
"""
w = (df[df.retailer == "loblaws"]
.pivot_table(index="date", columns="query", values=col, aggfunc="min")
.dropna()
.sort_index())
return 100 * (w / w.iloc[0]).mean(axis=1)
gap = pd.DataFrame({"sticker": relatives("price"), "unit": relatives("unit_price")})
gap["shrinkflation"] = (gap["unit"] - gap["sticker"]).round(2)
print(gap.round(1))A shrinkflation column that drifts upward means packages are getting smaller faster than prices are rising. Canned tomatoes and butter are usually where it shows first.
Matching drift
The other thing that quietly corrupts one of these series. The Actor compares retailer search results, so the product that wins a query can change between runs. A store brand outranks a national brand one week, and your series records a 30% price drop that is really a different product.
# Items where the matched product changed at least once. drift = df.groupby(["retailer", "query"])["product_id"].nunique() print(drift[drift > 1].sort_values(ascending=False))
Anything in that list needs looking at before you trust its trend. Two defences: keep minMatchConfidence at high, and make your queries specific enough to have one obvious answer. "butter" is a loose query, "salted butter 454g" is a tight one.
Against the official number
Statistics Canada publishes a Consumer Price Index monthly, with a food component, and it’s the number the news reports. Yours will disagree with it, and that’s expected rather than a bug.
- Different scope. StatCan builds a national statistical sample with weighting and adjustments behind it. Yours is ten items at three named chains in one city.
- Different timing. Theirs is monthly and lands after the month it covers. Yours is weekly and current.
- Different purpose. Use yours to see movement early and at the store level. It isn’t evidence about the official series, and I wouldn’t present it as such.
Where a small basket genuinely beats the national number is specificity. It can tell you that butter at one chain in your city moved 8% this month, which no national aggregate will. To widen it, the comparison API guide covers the other thirteen chains, and the Loblaws guide covers scraping whole departments instead of a fixed list.
Frequently asked questions
How often should I run the basket?
Weekly, on the same weekday. Grocery sale cycles are weekly, so running on a Tuesday one week and a Saturday the next mixes sale and regular pricing into what looks like a trend. Daily runs mostly buy you noise unless you are specifically studying promotions.
Why does my index disagree with the Statistics Canada food CPI?
Because they measure different things. StatCan builds a national statistical sample with weighting, seasonal adjustment and quality adjustment behind it. A ten-item basket priced at three named chains in one city is a live shelf reading. Use yours to see movement early and at the store level, not to dispute the official series.
How do I stop shrinkflation from hiding a price increase?
Track comparable_unit_price rather than price. When a 1 L carton becomes 900 ml at the same sticker, price reports zero change while the unit price rises about 11 percent. Chart both and the gap between them is your shrinkflation signal.
What if the matcher picks a different product between runs?
That turns a product change into a fake price change, and it is the most common way one of these series goes wrong. Set minMatchConfidence to high, then check that product_id stayed the same before you believe any large move.
How far back can I build history?
Only as far back as your own runs. The Actor returns current prices and stores nothing between runs, so history starts the day you schedule it. There is no backfill.
How many items should the basket have?
Ten to twenty. Fewer than ten and one volatile item, usually a meat or a vegetable, swings the whole index. Many more and you spend your time reconciling matches instead of reading the trend.
Should I track one retailer or several?
Several. A single chain tells you what that chain did, and chains move independently. Three gives you a spread to sanity-check against, and basket_summary already totals each one for you.
Why a fixed basket instead of tracking everything?
A fixed basket is what makes two dates comparable. If the item list changes between runs, you are measuring your own edits as much as the market, which is the substitution bias every price index is built to avoid.
Try it on your own data
The Canadian Grocery Price Comparison API runs on Apify. Paste an input, hit Start, and see what comes back before you write any code against it.
Get the Canadian Grocery Price Comparison APIOther retailers
- Loblaws, No Frills & Superstore API12 Loblaw banners with PC Optimum offers and multi-buy deals.
- Save-On-Foods & PriceSmart APIFour Pattison Food Group chains across BC and the prairies.
- T&T Supermarket APICanada’s largest Asian grocery chain, in BC, AB, ON and QC.
- Costco Scraper APIWarehouse prices, deals and item numbers from costco.ca and costco.com.
They all share the same field names. See them side by side on the grocery data API page, with a sample record for each.
Keep reading
- How to Compare Grocery Prices Across 16 Canadian Chains in One CallThis one isn’t a scraper, it’s a matcher. How the confidence scoring works, how to filter on it, and how to get basket totals per store.
- How to Scrape Loblaws, Superstore and No Frills PricesTwelve banners share one input and one output shape. Which banner covers your region, the promo fields only this chain exposes, and how to join across banners.
- How to Scrape Costco Prices from costco.ca and costco.comPaste the input, get rows back. Then the three things that cost me time, and where the unit prices come from, since Costco publishes none.