- Price intelligence
- Competitor monitoring
- Price data
- Apify
How to Monitor Competitor Grocery Prices in Canada
Sixteen Canadian grocery banners, eleven of them owned by Loblaw and four by Pattison, priced independently in the same city on the same day. That spread is the thing worth monitoring, and one output mode returns it already summarized.
Savvi7 min read
Start here
Paste this into the Canadian Grocery Price Comparison API and hit Start. It watches one category and one product line across five banners spanning two parent companies.
{
"queries": ["oat milk", "greek yogurt", "ground coffee"],
"categories": ["dairy-eggs"],
"location": "Toronto, ON",
"retailers": ["loblaws", "nofrills", "superstore", "saveonfoods", "pricesmart"],
"outputMode": "intel"
}curl -X POST \
"https://api.apify.com/v2/acts/sunny_eternity~canada-grocery-price-comparison/run-sync-get-dataset-items?token=YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"queries":["oat milk"],"categories":["dairy-eggs"],"location":"Toronto, ON","outputMode":"intel"}'intel takes queries, categories, or both. Queries answer what a named product costs at each banner. Categories answer what a whole aisle looks like, which is the half most monitoring setups skip.
What intel mode adds
A default run returns comparison rows. intel returns those plus four record types, each tagged with _type, so branch on that field before you do anything else with a dataset.
| Record | What it carries |
|---|---|
query_summary | One per search term: total_matches, retailers_with_match, cheapest, cheapest_per_100g_or_ml, price_min, price_max, price_median and on_sale_count. |
intel_category_stats | One per retailer per category: product_count, price_min, price_max, price_median and on_sale_count. This is your promotional intensity measure. |
intel_top_deals | The twenty deepest discounts in the run, ranked, each with price, was_price, discount_pct and source_url. |
intel_run_summary | retailers_attempted, retailers_succeeded, retailers_failed with reasons, and the product, query and category totals. |
Read intel_run_summary first, every time. A category gap and a retailer failure look identical in the data and completely different in that record.
Price position by banner
The question a monitoring setup exists to answer: where does each banner sit against the others on the products you care about. query_summary gives you the cheapest per query; the comparison rows underneath give you the whole distribution.
import pandas as pd
from apify_client import ApifyClient
client = ApifyClient("YOUR_API_TOKEN")
run = client.actor("sunny_eternity/canada-grocery-price-comparison").call(run_input={
"queries": ["oat milk", "greek yogurt", "ground coffee"],
"location": "Toronto, ON",
"retailers": ["loblaws", "nofrills", "superstore", "saveonfoods", "pricesmart"],
"outputMode": "intel",
})
rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
df = pd.DataFrame([r for r in rows if r["_type"] == "comparison"])
# Index each banner against the cheapest offer for that query. 100 = the floor.
df["floor"] = df.groupby("query")["price"].transform("min")
df["index"] = (100 * df["price"] / df["floor"]).round(1)
print(df.pivot_table(index="banner", columns="query", values="index"))That index is the number to chart over time. A banner drifting from 104 to 112 has repositioned, whether or not anyone announced it, and you’ll see it weeks before it shows up in a trade story.
Category benchmarks
Seventeen unified category slugs, resolved to each retailer’s own taxonomy at runtime: produce, dairy-eggs, meat, seafood, bakery, frozen, pantry, snacks, beverages, international, deli, prepared-meals, plant-based, health-beauty, baby-care, pet-care and household.
Coverage isn’t uniform, and the shape of it matters when you design a benchmark. Twelve resolve across all six default retailers. international skips T&T. The last five resolve only for Save-On-Foods and PriceSmart.
stats = pd.DataFrame([r for r in rows if r["_type"] == "intel_category_stats"])
stats["sale_share"] = (stats["on_sale_count"] / stats["product_count"]).round(3)
print(stats[["banner", "category", "product_count",
"price_median", "sale_share"]].sort_values("sale_share", ascending=False))sale_share is the one I watch. A banner running 8% of a category on promotion one week and 22% the next is buying traffic, and price_median tells you whether the everyday shelf moved with it or stayed put.
Running it weekly
Canadian grocery promotions turn over weekly, so schedule weekly on a fixed weekday. Tuesday one week and Saturday the next mixes promotional and regular pricing into what will look like a trend.
- Schedule it in the Apify console, on the Actor’s Schedules tab. Each run writes its own dataset.
- Keep every dataset. The Actor holds no history between runs, so your archive is the only history there is.
- Store
run_idandschema_versionwith the rows. Both make a two-year-old table explainable. - Reconcile against
intel_run_summarybefore loading. A run where one retailer failed is a smaller sample, not a price movement.
The mechanics of turning scheduled runs into a series, including the two things that corrupt one, are in the fixed-basket guide. For pulling many runs into one table, the Python guide covers the client side.
What bites
- A skipped category looks like a zero. Ask for
pet-careat Loblaws and that retailer is skipped, because no mapping exists. Nothing in the category rows says so;retailers_succeededdoes. - Sticker price across banners measures packaging. Benchmark on
comparable_unit_pricewithcomparable_unit_basis, and drop rows whereis_comparable_unit_priceis false. - Matches are search results, not identities. Filter with
minMatchConfidenceatmediumor better before you report a number to anyone, and spot-checksource_urlon the outliers. - Naming no retailers runs six. The other ten are opt-in, so a monitoring brief covering Quebec needs
maxiandprovigonamed explicitly. - Region changes the answer. Eight metros are supported, and a banner’s price position in Toronto tells you little about Halifax. Run per region rather than averaging across them.
Frequently asked questions
What does outputMode "intel" add over the default?
Four record types the default run never emits. A query_summary per search term, intel_category_stats per retailer per category, intel_top_deals ranking the twenty deepest discounts, and a closing intel_run_summary. The comparison rows still come through underneath them.
Can I track brands across banners owned by the same parent?
Yes, and that is where the 16 keys earn their place. Eleven of them are Loblaw banners and four are Pattison, so grouping rows by parent shows you how one company prices the same item at its discount and full-service formats in the same week.
How often should a monitoring run be scheduled?
Weekly on a fixed weekday for price position, since Canadian grocery promotions turn over weekly. Daily only if you are tracking promotion starts, and then expect most of what you collect to be unchanged rows.
Which categories can I benchmark?
Seventeen unified slugs, from produce and dairy-eggs through to pet-care and household. Twelve of them resolve across all six default retailers; international skips T&T, and plant-based, health-beauty, baby-care, pet-care and household resolve only for Save-On-Foods and PriceSmart.
Why did one of my retailers return nothing in category mode?
Because the category you asked for has no mapping for that banner, so it is skipped rather than guessed at. Check intel_run_summary.retailers_succeeded against retailers_attempted before you read anything into a gap.
Can this replace a paid price-monitoring platform?
For Canadian grocery it covers the collection half: scheduled runs, normalized fields and a stable schema. Alerting, dashboards and workflow are yours to build, which is the trade you are making against a per-seat SaaS licence.
How do I compare products that come in different pack sizes?
Read comparable_unit_price with comparable_unit_basis, and drop rows where is_comparable_unit_price is false. Benchmarking on sticker price across banners mostly measures packaging decisions.
Is competitor price monitoring legal in Canada?
Collecting publicly posted prices is generally lawful, and price transparency work has a long commercial history. Each retailer’s terms and your own jurisdiction still apply, so keep volume modest and take legal advice before building a business process on it.
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 Canadian Grocery Prices with PythonFive Actors, one DataFrame. The field names already line up, so the union is about fifteen lines once you know which ones to keep.
- How to Track Grocery Inflation with a Fixed Basket of StaplesTen staples, priced weekly, turned into an index. Including the two things that quietly corrupt a grocery price series if you don’t watch for them.