🇧🇪 Colruyt Example solution JavaScript /products API

Colruyt vs Delhaize: I settled the Belgian grocery debate with one shopping list

Every Belgian has an opinion on this and most of them are about Colruyt. This settles it the boring way: write one 30-item basket, ask both chains what they would charge for it, and read the scorecard. The answer was closer than expected, and the reason why is more useful than the answer.

Run this yourself

$ PEPESTO_API_KEY=your_key node colruyt-vs-delhaize-belgium-comparison.js

Full script: colruyt-vs-delhaize-belgium-comparison.js. You'll need an API key to run it — get one here.

Getting started

Get your API key first:

js
const res = await fetch('https://s.pepesto.com/api/link', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'your@email.com' }),
});
const { api_key } = await res.json();
// export PEPESTO_API_KEY=your_key

One basket, priced at both chains

Both chains are Belgian and both price in EUR, so at least there is no currency conversion to argue about. The real problem is that Colruyt's own-label mince and Delhaize's own-label mince are different products with different names, and there is nothing to join them on.

So let Pepesto decide what counts as the same thing. Write the basket as plain generic names, send it to /parse once, and keep the kg_token that comes back — that token is Pepesto's reading of the list. Send the same token to /products for each chain and both answer with the same items, each resolved to whatever that chain stocks. Pair on item_name.

js
const BASKET = [
  'ground pork', 'beef', 'chicken', 'sausage', 'bacon',
  'milk', 'butter', 'cheese', 'eggs', 'yoghurt', 'mozzarella cheese',
  'tomatoes', 'carrots', 'onions', 'potatoes', 'mushrooms', 'apples',
  'canned tomatoes', 'chickpeas',
  'beer', 'orange juice', 'sparkling water', 'coffee',
  'spaghetti', 'rice', 'flour', 'sugar', 'olive oil', 'salt', 'bread',
];

/**
 * Turns the shopping list into a kg_token. Sending the same token to both
 * chains is what lets the two baskets line up item by item.
 */
async function parseBasket() {
  console.log(`Parsing a ${BASKET.length}-item basket...`);
  const response = await fetch(`${API_BASE}/parse`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ recipe_text: BASKET.join('\n') }),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`Pepesto API error — ${response.status}: ${errorText}`);
  }

  const { kg_token } = await response.json();
  return kg_token;
}

/**
 * Prices the basket at one chain.
 */
async function priceBasketAt(kgToken, domain) {
  console.log(`Pricing the basket at ${domain}...`);
  const response = await fetch(`${API_BASE}/products`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ recipe_kg_tokens: [kgToken], supermarket_domain: domain }),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`Pepesto API error for ${domain} — ${response.status}: ${errorText}`);
  }

  return response.json();
}

Thirty items is the ceiling — that is what /parse accepts in one shopping list. Colruyt priced up 25 of them, Delhaize 27, and 25 matched on both sides.

Here is what Colruyt returns for one item — several candidates, cheapest shown:

json
{
  "currency": "EUR",
  "items": [
    {
      "item_name": "Beef",
      "products": [
        {
          "product": {
            "product_name": "lean beef stew meat",
            "category": "Beef",
            "quantity": { "grams": 1000 },
            "price": { "price": 1332, "promotion": {} }
          },
          "num_units_to_buy": 1
        }
      ]
    }
  ]
}

And the same item at Delhaize:

json
{
  "currency": "EUR",
  "items": [
    {
      "item_name": "Beef",
      "products": [
        {
          "product": {
            "product_name": "Delhaize Prepared Beef Tartare Filet Americain 160g",
            "category": "Beef",
            "quantity": { "grams": 160 },
            "price": { "price": 219, "promotion": {} }
          },
          "num_units_to_buy": 1
        }
      ]
    }
  ]
}

€13.32 against €2.19. Hold that thought.

The matching and comparison logic

Both sides come back keyed by item_name, so the index is straightforward: for each item, keep the cheapest candidate.

js
function buildEntityIndex(productsData) {
  const index = {};

  for (const item of productsData.items ?? []) {
    const cheapest = (item.products ?? [])
      .filter(p => typeof p.product?.price?.price === 'number')
      .sort((a, b) => a.product.price.price - b.product.price.price)[0];
    if (!cheapest) continue;

    index[item.item_name] = {
      name: cheapest.product.product_name || item.item_name,
      price: cheapest.product.price.price,
      currency: productsData.currency || 'EUR',
      promo: cheapest.product.price.promotion?.promo || false,
      url: cheapest.product.product_id || '',
    };
  }

  return index;
}

Then compare the two indexes on the keys they share, biggest gap first:

js
function compareStores(colruytIndex, delhaizeIndex) {
  const matches = [];

  for (const entity of Object.keys(colruytIndex)) {
    if (!delhaizeIndex[entity]) continue;

    const colruyt = colruytIndex[entity];
    const delhaize = delhaizeIndex[entity];
    const diffCents = colruyt.price - delhaize.price;
    const diffPct = ((colruyt.price - delhaize.price) / delhaize.price) * 100;

    matches.push({
      entity,
      category: categorise(entity),
      colruyt: { name: colruyt.name, price: colruyt.price, promo: colruyt.promo },
      delhaize: { name: delhaize.name, price: delhaize.price, promo: delhaize.promo },
      diffCents,
      diffPct: diffPct.toFixed(1),
      winner: diffCents < 0 ? 'Colruyt' : diffCents > 0 ? 'Delhaize' : 'tie',
    });
  }

  matches.sort((a, b) => Math.abs(b.diffCents) - Math.abs(a.diffCents));
  return matches;
}

What the data showed

Across the 25 matched items, Delhaize took 53% and Colruyt 47%. For a debate this heated, that is not a result. Colruyt held dairy and did well on pantry staples; Delhaize took meat, fruit and vegetables and the single canned item. Run it again next week and promotions would probably flip it back.

The interesting part is the three biggest gaps, because all three are wrong.

Beef. Colruyt €13.32, Delhaize €2.19, a 508% gap and the largest in the table. Colruyt's cheapest beef is a kilo of lean stewing meat; Delhaize's is a 160g tub of filet américain. Per kilo that is €13.32 against €13.69. The two chains are charging the same and the table says one is six times dearer.

Sparkling water. Colruyt €0.22, Delhaize €1.29. Colruyt's is a 50cl bottle of Everyday own-label. Delhaize's cheapest match is 33cl of Charlie's organic passionfruit sparkling water, which is a soft drink wearing the words "sparkling water".

Milk. Colruyt €0.65 for 500ml of Boni semi-skimmed, Delhaize €1.85 for a litre of organic fresh. Per litre it is €1.30 against €1.85, so Colruyt is genuinely cheaper — but a third of the headline gap is pack size and the rest is that one of them is organic.

The result

Once you divide by quantity.grams or quantity.milliliters, the two chains are much closer than the Belgian internet believes, and most of what looks like a price difference is a difference in what each one puts at the bottom of the shelf. Colruyt sells the kilo pack, Delhaize sells the tub. That is a real difference, but it is a difference in format, not in price.

If you want a fair number, filter the candidate list before picking. Each item returns several products, so keeping only those within a sensible size range of each other — or excluding organic when the other side has no organic option — gets you a comparison that survives contact with a Belgian.

What else you could do?

Normalise everything to price per kilo or per litre using the quantity object before comparing, which removes most of the distortion described above. Run it weekly and track which chain wins over time — promotions shift the result week to week, and Delhaize runs aggressive promo cycles. Add Lidl Belgium as a third data point, since the real Belgian budget question is whether Lidl undercuts both.

Links

Ready to build?

Start comparing Belgian supermarket prices

Price one basket at Colruyt and Delhaize in parallel and settle it with numbers.

27supermarkets 13countries 1schema Instant access