The hard part of grocery price intelligence is not comparing $3.99 with $4.09. It is knowing whether those prices refer to the same product, in the same location, at the same time, under the same promotion rules. If you get any of those wrong, your pricing report looks precise but tells the business the wrong thing.
Grocery makes this worse than many other categories. Products expire. Prices vary by ZIP code. Delivery apps can change prices several times a day. A competitor may not discount the exact SKU you track, but they may discount a substitute that steals the same basket.
Here is the shape of a system I would build before trying to run any pricing model on top of it.
Store price observations, not just products
A common mistake is to model one row per product and keep updating the price. That loses the history you need for freshness, forecasting, and debugging.
Treat each scrape or feed record as an observation:
CREATE TABLE price_observations (
id BIGSERIAL PRIMARY KEY,
retailer TEXT NOT NULL,
channel TEXT NOT NULL, -- store, website, delivery_app
location_id TEXT NOT NULL, -- store id, ZIP, delivery zone, or geo hash
observed_at TIMESTAMPTZ NOT NULL,
external_sku TEXT,
product_name TEXT NOT NULL,
brand TEXT,
size_text TEXT, -- 16 oz, 2 lb, 12 count
category_path TEXT[],
shelf_price NUMERIC(10, 2),
promo_text TEXT, -- 2 for $5, buy one get one, member price
currency CHAR(3) DEFAULT 'USD',
raw_payload JSONB NOT NULL
);
CREATE INDEX ON price_observations (retailer, location_id, observed_at DESC);
CREATE INDEX ON price_observations (external_sku, observed_at DESC);
Keep raw_payload. You will need it when a parser bug turns 12 count eggs into 12 oz eggs, or when a site changes promo wording and your downstream numbers jump for no business reason.
Normalize units before comparing prices
Unit normalization sounds boring until someone compares a 32 oz yogurt tub with a 5.3 oz single cup and reports a huge price gap. Store the shelf price, but compare on a normalized unit price where possible.
Here is a small version of the pattern:
from decimal import Decimal
import re
UNIT_TO_BASE = {
'oz': ('oz', Decimal('1')),
'lb': ('oz', Decimal('16')),
'g': ('oz', Decimal('0.035274')),
'kg': ('oz', Decimal('35.274')),
'count': ('count', Decimal('1')),
}
SIZE_RE = re.compile(r'(?P<qty>\d+(?:\.\d+)?)\s*(?P<unit>oz|lb|g|kg|count|ct)', re.I)
PROMO_RE = re.compile(r'(?P<n>\d+)\s*for\s*\$(?P<price>\d+(?:\.\d+)?)', re.I)
def normalize_unit(unit: str) -> str:
unit = unit.lower()
return 'count' if unit == 'ct' else unit
def effective_price(shelf_price: Decimal, promo_text: str | None) -> Decimal:
if not promo_text:
return shelf_price
match = PROMO_RE.search(promo_text)
if match:
return Decimal(match.group('price')) / Decimal(match.group('n'))
return shelf_price
def unit_price(shelf_price: str, size_text: str, promo_text: str | None = None):
match = SIZE_RE.search(size_text or '')
if not match:
return None
qty = Decimal(match.group('qty'))
unit = normalize_unit(match.group('unit'))
base_unit, multiplier = UNIT_TO_BASE[unit]
total_base_qty = qty * multiplier
price = effective_price(Decimal(shelf_price), promo_text)
return {
'unit_price': price / total_base_qty,
'base_unit': base_unit,
'effective_price': price,
}
print(unit_price('3.99', '32 oz'))
print(unit_price('5.00', '16 oz', '2 for $5'))
This will still fail on real grocery data. Bananas may be priced per pound online but sold per each in a delivery app. Meat may have estimated weights. Multipacks may say 6 x 4 oz. The important part is to make failures explicit. Return None, store a parse error, and exclude that row from automated comparisons until you fix the parser.
Location and freshness are part of the key
Two stores ten miles apart can have different competitors, customer behavior, and delivery fees. If your data model only has retailer and sku, you cannot answer whether a price is relevant to a specific customer.
Add freshness rules per channel. A weekly store scrape might be acceptable for shelf labels. For quick commerce, a six-hour-old price can already be stale.
SELECT *
FROM price_observations
WHERE retailer = 'example_market'
AND location_id = '94107'
AND observed_at > now() - interval '6 hours'
ORDER BY observed_at DESC;
When the extractor layer cannot consistently capture location-specific prices, every later stage inherits that uncertainty. In this context, Wire fits as the extraction layer: it returns grocery product, price, promotion, and location data that downstream pricing code can treat as inputs.
You should still track extraction quality yourself. At minimum, log coverage by category, freshness by location, parse error rate, and the share of rows with usable unit prices.
Match substitutes, not only exact SKUs
Exact SKU matching is useful, but grocery shoppers often compare needs rather than labels. If basmati rice gets expensive, jasmine rice may become the alternative. If a private-label cereal sits too close to the national brand price, customers may stop seeing it as a value option.
A practical approach is to split matching into tiers:
Tier 1: same UPC or retailer SKU
Tier 2: same brand, product type, size range
Tier 3: same product type, similar size, different brand
Tier 4: same need state, such as premium rice or kids cereal
Do not let an embedding model freely decide all matches. Use it to propose candidates, then apply hard filters like category, unit type, dietary attributes, and pack size range. Otherwise, you will eventually compare almond milk with almond butter because the names share tokens.
For pricing decisions, keep the match tier with the comparison. A 5 percent gap on exact UPCs means something different from a 5 percent gap between substitutes.
Measure the pipeline before trusting recommendations
Before you build forecasting or automated pricing rules, measure the data pipeline like a production system:
- Freshness: percent of observations inside the allowed time window per channel
- Coverage: tracked SKUs found per retailer, category, and location
- Promo parse rate: percent of promo rows converted into effective prices
- Unit-price availability: percent of rows with comparable base units
- Match precision: sampled accuracy by match tier
- Volatility: price changes per SKU per day, split by retailer and channel
A simple pricing model can tolerate missing data if it knows what is missing. It cannot tolerate silent bad comparisons.
Start with one category, one city, and three competitors. Build the observation table, normalize unit prices, tag stale records, and manually review substitute matches. Once those numbers look sane, add forecasting or automated recommendations on top.
Top comments (0)