Retail, Demand and Supply Chain
Promotions, cannibalisation and halo
A promotion's headline sales spike almost always overstates its real benefit, because some of it is stolen from other products and from next week.
- 9 min read
- 3 reading levels
- Published
Read these first
On this page 5
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
A promotion's sales spike almost always overstates how much it actually helped, because some of that spike was stolen from somewhere else.
Think about a "buy one, get one free" offer on your favourite biscuit brand during a festival week. Sales of that biscuit shoot up. It looks like a huge win. But look closer: some of those buyers usually bought a rival biscuit brand that week, and switched only for the offer. Some of them bought two months' worth in one trip and will not buy again for weeks.
The headline number — biscuits sold during the offer — hides both of these effects completely.
Why it exists
A promotion changes buying behaviour in more than one direction at once. Only one of those directions shows up as a satisfying spike on a sales chart.
Cannibalisation means a promoted product steals sales from a close substitute, rather than creating new demand. Cannibalise here means "eat into" — the way one product eats into a rival's usual sales. If your biscuit promo pulls buyers away from a competing biscuit, the category as a whole did not grow as much as the spike suggests.
Pull-forward means shoppers who would have bought later buy now instead, because the price is good. The following week's sales dip below normal to pay for it — the same demand, only moved in time.
Halo is the friendlier cousin of both: a promotion on one product sometimes lifts sales of a related product too. A cooking-oil discount can quietly boost sales of the snacks people fry in it.
Judging a promotion only by its own sales during the promo week is a trap. It counts the cannibalised sales and the pulled-forward sales as if they were free extra revenue.
How it works
Normal week, brand A: 100 units
Promo week, brand A: 180 units <- looks like +80, a huge win
But also, in that same promo week:
Rival brand B: 90 -> 68 units (-22, some buyers switched to a rival brand)
And the week right after the promo ends:
Brand A: 100 -> 70 units (-30, buyers had already stocked up)
Naive "lift" claimed: +80 units
True incremental lift to the business: 80 - 22 - 30 = +28 unitsThe promotion still helped — 28 real extra units is not nothing. It is a fraction of the 80 the headline number suggested.
Where you have already seen it
- A phone brand's festive sale, where sales of last year's model spike while sales of a rival brand's similar phone quietly dip that same week.
- A "stock up" offer on cooking oil or rice before a long weekend, followed by unusually quiet sales the week after.
- A supermarket combo deal, like chips with a discounted cold drink, where the drink's own sales rise even though it was not the item on discount.
Remember this
- The sales spike during a promotion is not the same thing as the value the promotion created.
- Some of a promotion's apparent lift is borrowed from a rival product, or from next week's sales of the same product.
- A promotion can still be worth running — the honest question is how much it was really worth, not how big the spike looked.
What to learn next
- Price elasticity and dynamic pricing — the pricing logic promotions are built on top of.
- Forecasting a product with no history — another case where the normal forecasting assumptions break.
- Why a great model answers the wrong question — the deeper idea behind why "sales during the promo" is the wrong number to trust.
Developer — Code and libraries.
Setup
pip install numpy pandasMinimal runnable code
Two competing soap brands share the same shoppers. Brand A runs a promotion in week 4. We measure the naive lift, then correct it for cannibalisation of brand B and pull-forward the week after.
import numpy as np
import pandas as pd
rng = np.random.default_rng(9)
n_weeks = 6
promo_week = 3 # 0-indexed: the 4th week has the promotion
# Two competing soap brands share the same shoppers.
base_A, base_B = 100, 90
weeks = []
for w in range(n_weeks):
if w == promo_week:
sales_A = base_A * 1.8 # the promo lifts brand A hard
sales_B = base_B * 0.75 # some of brand B's usual buyers switch
elif w == promo_week + 1:
sales_A = base_A * 0.7 # shoppers stocked up last week, buy less now
sales_B = base_B * 1.0
else:
sales_A = base_A
sales_B = base_B
weeks.append({"week": w, "sales_A": round(sales_A), "sales_B": round(sales_B)})
df = pd.DataFrame(weeks)
df["category_total"] = df["sales_A"] + df["sales_B"]
print(df)
print()
baseline_A = df.loc[~df["week"].isin([promo_week, promo_week + 1]), "sales_A"].mean()
naive_lift = df.loc[df["week"] == promo_week, "sales_A"].values[0] - baseline_A
print(f"brand A baseline (normal week): {baseline_A:.0f} units")
print(f"naive promo lift (promo week only): {naive_lift:.0f} units")
print()
baseline_B = df.loc[~df["week"].isin([promo_week, promo_week + 1]), "sales_B"].mean()
b_drop = baseline_B - df.loc[df["week"] == promo_week, "sales_B"].values[0]
print(f"brand B baseline: {baseline_B:.0f} units, dropped by {b_drop:.0f} units during A's promo")
post_promo_dip = baseline_A - df.loc[df["week"] == promo_week + 1, "sales_A"].values[0]
print(f"brand A sales dipped by {post_promo_dip:.0f} units the week after the promo (pull-forward)")
print()
true_incremental_lift = naive_lift - b_drop - post_promo_dip
print(f"naive lift claimed: {naive_lift:.0f} units")
print(f"true incremental lift to the category: {true_incremental_lift:.0f} units")week sales_A sales_B category_total 0 0 100 90 190 1 1 100 90 190 2 2 100 90 190 3 3 180 68 248 4 4 70 90 160 5 5 100 90 190 brand A baseline (normal week): 100 units naive promo lift (promo week only): 80 units brand B baseline: 90 units, dropped by 22 units during A's promo brand A sales dipped by 30 units the week after the promo (pull-forward) naive lift claimed: 80 units true incremental lift to the category: 28 units
What actually happened
Looking only at row 3 of the table, brand A's promotion looks like it added 80 units. That is the naive lift — the number a rushed report would show.
The two later calculations show where a large chunk of that 80 went. 22 units came from brand B's usual buyers switching over for one week — that is b_drop, the cannibalisation. 30 more units came from brand A's own future sales, pulled into the promo week early — that is post_promo_dip.
true_incremental_lift subtracts both. What is left, 28 units, is a defensible estimate of demand the promotion actually created rather than borrowed. It is worth noticing this is barely a third of the naive number.
Common mistakes
Measuring lift over the promo week alone. Always widen the measurement window to include at least one week after the promotion ends, to catch pull-forward.
Ignoring the competing product entirely. If you only track the promoted SKU, cannibalisation is invisible by construction — you need the rival product's sales in the same analysis.
Assuming halo and cannibalisation cancel out automatically. They do not, and the balance is specific to the category. A promotion on a staple often cannibalises hard. A promotion on a discretionary treat item, bought on impulse, often creates more genuinely new demand.
Confusing correlation with the promotion's actual causal effect. This simple before/after comparison is a reasonable start, but a rigorous answer needs the tools in Why a great model answers the wrong question — other things could have changed in week 4 besides the promotion.
Try it yourself
Change sales_B during the promo week so it barely drops, from base_B * 0.75 to base_B * 0.95. Re-run, and watch the true incremental lift climb much closer to the naive number — this is what a genuinely low-cannibalisation promotion looks like.
What to learn next
Researcher — Mathematics and papers.
A structural decomposition of observed promotional lift
Observed lift for the promoted product A in the promo period decomposes as:
Lift_observed = Lift_new_demand + Lift_cannibalised(A substitutes) - Pull_forward(A future) - Halo_loss(complements, if negative)Each term needs its own identification strategy, because none of them is directly observed — only the aggregate Lift_observed is.
Estimating true incrementality: synthetic control and matched-market designs
The industry-standard rigorous approach is a matched-market or synthetic control design (Abadie, Diamond and Hainmueller, 2010, Synthetic Control Methods for Comparative Case Studies, JASA): hold the promotion out in a subset of stores or regions, construct a weighted combination of non-promoted "donor" stores that closely tracked the promoted store's sales pre-period, and attribute the gap during the promo period to the promotion itself. This directly nets out category-wide trends, seasonality, and macro shocks that a simple before/after comparison — as used in the developer example — cannot separate from the promotion's true effect.
Where randomised holdouts are feasible (a subset of stores deliberately excluded from the promotion), a straightforward difference-in-differences estimator applies directly; see Difference-in-differences.
Modelling cannibalisation directly
A common production approach models promoted and competing SKU sales jointly with cross-price and cross-promotion elasticity terms:
log(q_i) = alpha_i + beta_i * log(p_i) + SUM_{j != i} gamma_{ij} * promo_j + epsilonq_i— quantity sold of productip_i— price of productipromo_j— an indicator or intensity measure for productj's promotiongamma_{ij}— the cross-promotional elasticity of productiwith respect to productj's promotion; negative for substitutes (cannibalisation), positive for complements (halo)
Estimating a full gamma matrix across a large assortment is a high-dimensional regression problem; retailers typically restrict it to known substitute and complement groups (a product hierarchy, or a market-basket-derived affinity graph) rather than estimating cross-effects for every SKU pair.
Pull-forward as inventory borrowed from the future
Pull-forward is formally a violation of the independence assumption behind simple period-over-period lift measurement: post-promotion demand D_{t+1} is negatively correlated with promotion-period demand D_t because both draw from the same underlying stock of near-term household need. Cohen, Leung and Perakis (2017), in retail revenue-management literature, model this with an explicit household inventory state, forecasting depletion of stockpiled goods rather than treating each period as independent — the same conceptual move as adding an explicit "days since last purchase" feature to a promotional-response model.
Key references
- Abadie, A., Diamond, A. & Hainmueller, J. (2010). Synthetic Control Methods for Comparative Case Studies: Estimating the Effect of California's Tobacco Control Program. Journal of the American Statistical Association 105(490).
- Blattberg, R. & Neslin, S. (1990). Sales Promotion: Concepts, Methods, and Strategies. Prentice Hall — the standard applied reference for decomposing promotional lift.
- van Heerde, H., Leeflang, P. & Wittink, D. (2004). Decomposing the Sales Promotion Bump with Store Data. Marketing Science 23(3).