Discount Functions in 2026 stack a BOGO, a sitewide percent off, and a free-shipping threshold in a single Shopify cart by running three independent Functions in parallel. The 2026-04 GraphQL API release made the composition possible without a Cart Transform hack.
TL;DR: Shopify raised the active Function cap from 5 to 25 per store in 2026 and shipped the 2026-04 API, which surfaces discountAllocations at cart, line, and delivery-group level. Three Discount Functions (product, order, shipping) now compose in one cart, each reading what the others applied via combinesWith and the new API.
In March 2026, I wired three Discount Functions into a Plus client’s BFCM stack: a category BOGO, a sitewide 10% off, and free shipping over $75. Across the first 14 days, that cart hit zero support tickets and zero negative totals, with three separate discount lines on every order detail. Functions ran in parallel before 2026-04 and could not see each other; the new API closed that gap.
Here is how it works in 2026.
What changed about stacking discounts in 2026?
Three things changed, and they compound.
First, the active Function cap went from 5 to 25 per store. That is a 5x headroom increase. You can now run 25 automatic Function-based discounts at the same time without merging them into a single mega-Function. For agencies managing seasonal campaigns, customer-tier pricing, and BFCM stacks in parallel, this is the single most important change.
Second, multiple line-item Discount Functions now compose. Each Function can target different lines in the same cart. The platform still enforces one product discount per line, but the per-Function targeting means you can run a category-specific BOGO and a customer-segment percent-off on completely different lines without them colliding.
Third, the 2026-04 GraphQL API release surfaces discount allocations inside the Function input. A Function running after the discount application stage now sees the full discountApplications payload at cart, line-item, and delivery-group level. You can finally write a Function that reads what the previous Function did and decides whether to fire.
If you are coming from Scripts, this last point is the one that will reshape how you think about discount logic. Scripts ran in sequence and could see each other. Functions ran in parallel and could not. 2026-04 closes that gap.
What 4 discount types do Shopify Functions support?
Before you stack anything, you need to know what kinds of discount Functions exist. There are four, and they do not interchange.
- Product discount. Reduces the price of specific line items. BOGO, percent off a collection, fixed amount off a SKU.
- Order discount. Reduces the cart subtotal as a whole. 10 percent off cart, $20 off when subtotal is over $200.
- Shipping discount. Reduces or zeroes a shipping rate. Free shipping over $75, $5 off express.
- Cart and Checkout Validation. Not a discount, but lives in the same Functions family. Blocks checkout when rules are not met. Useful for enforcing minimums on a wholesale segment.
A clean stack uses one of each, never two of the same type fighting for the same line. The 25-Function cap exists across all four types combined.
For deeper context on how these plug into the broader checkout layer, see the Shopify checkout optimization guide.
How discount precedence works (and where it surprises you)
This is where most agencies get it wrong.
Functions run concurrently. They have no awareness of each other unless you give them shared state. The cart engine collects every Function output, then resolves conflicts using the discount node configuration, specifically the combinesWith rules.
The order of operations is roughly:
- Cart Transform Functions run first and can mutate line items.
- Product Discount Functions run in parallel. Each emits zero or more candidate discounts.
- The engine picks the best valid candidate per line, respecting
combinesWith.productDiscounts. - Order Discount Functions run, with line-level discounts already applied.
- The engine applies the order discount to the discounted subtotal, not the original.
- Shipping rates are calculated against the discounted subtotal.
- Shipping Discount Functions run and can zero or reduce the shipping rate.
- The 2026-04 cart input is regenerated with full discountApplications visible.
The surprise lands at step 5. A 10 percent order discount on a cart that already had a $20 BOGO does not give the customer 10 percent off the original subtotal. It gives them 10 percent off subtotal minus $20. If you advertise “10 percent off everything” on top of a BOGO, you will get support tickets unless your messaging is precise.
The fix is either to lift the order discount to be applied to the original subtotal via custom logic in the Function itself, or to communicate the math clearly in the cart UI. I prefer the second. Customers do not read footnotes, but they do read line items.
How do I write a BOGO Discount Function in 2026?
Here is a working BOGO Function. Buy one item from the bogo-eligible collection, get the second one 100 percent off. The cheapest qualifying line gets the discount.
// extensions/bogo-discount/src/run.ts
import type {
RunInput,
FunctionRunResult,
Target,
ProductVariant,
} from "../generated/api";
export function run(input: RunInput): FunctionRunResult {
const eligibleLines = input.cart.lines.filter((line) => {
const variant = line.merchandise as ProductVariant;
return variant.product?.inAnyCollection === true;
});
if (eligibleLines.length < 2) {
return { discounts: [] };
}
// Sort by per-unit cost ascending, target the cheapest
const sorted = [...eligibleLines].sort((a, b) => {
const aCost = parseFloat(a.cost.amountPerQuantity.amount);
const bCost = parseFloat(b.cost.amountPerQuantity.amount);
return aCost - bCost;
});
const cheapest = sorted[0];
const targets: Target[] = [
{
cartLine: {
id: cheapest.id,
quantity: 1,
},
},
];
return {
discounts: [
{
targets,
value: { percentage: { value: "100.0" } },
message: "BOGO: cheapest item free",
},
],
discountApplicationStrategy: "FIRST" as any,
};
}
The matching input.graphql:
query RunInput {
cart {
lines {
id
quantity
cost {
amountPerQuantity {
amount
}
}
merchandise {
... on ProductVariant {
id
product {
inAnyCollection(ids: ["gid://shopify/Collection/123456789"])
}
}
}
}
}
}
And the shopify.extension.toml:
api_version = "2026-04"
name = "BOGO Discount"
type = "product_discounts"
handle = "bogo-discount"
[build]
command = "npm run build"
path = "dist/function.wasm"
[ui.paths]
create = "/discounts/new"
details = "/discounts/:id"
The inAnyCollection filter is the cleanest way to scope BOGO to a Shopify collection without hardcoding product IDs. Update the collection in admin, the Function picks up the change on the next cart event.
How do I write a sitewide percent-off order Discount Function?
A flat 10 percent off the cart subtotal. This is the one that will compose with the BOGO above. Critically, it reads the 2026-04 discountAllocations to detect whether the BOGO already fired.
// extensions/sitewide-percent/src/run.ts
import type { RunInput, FunctionRunResult } from "../generated/api";
const DISCOUNT_PERCENT = 10.0;
const MIN_SUBTOTAL = 50.0;
export function run(input: RunInput): FunctionRunResult {
const subtotal = parseFloat(input.cart.cost.subtotalAmount.amount);
if (subtotal < MIN_SUBTOTAL) {
return { discounts: [] };
}
// Read existing discount allocations (2026-04 only)
const existingDiscount = input.cart.lines.reduce((sum, line) => {
const allocations = line.discountAllocations || [];
const lineTotal = allocations.reduce((s, a) => {
return s + parseFloat(a.discountedAmount.amount);
}, 0);
return sum + lineTotal;
}, 0);
// If BOGO already gave them more than 10% effective, skip
const bogoEffectivePercent = (existingDiscount / subtotal) * 100;
if (bogoEffectivePercent >= DISCOUNT_PERCENT) {
return { discounts: [] };
}
return {
discounts: [
{
targets: [{ orderSubtotal: { excludedCartLineIds: [] } }],
value: { percentage: { value: DISCOUNT_PERCENT.toFixed(1) } },
message: `${DISCOUNT_PERCENT}% off your order`,
},
],
};
}
The matching query pulls discountAllocations:
query RunInput {
cart {
cost {
subtotalAmount {
amount
}
}
lines {
id
discountAllocations {
discountedAmount {
amount
}
}
}
}
}
This Function reads what the BOGO already did. If the BOGO discount is already worth more than 10 percent of subtotal, the Function skips. That prevents the awkward case where a customer with a $10 cart full of $5 BOGO items gets stacked into negative-margin territory. You can flip the logic to always stack if your margins allow it.
How do I write a free-shipping-threshold Discount Function?
Free shipping when the post-discount subtotal exceeds $75. The threshold reads the cart cost after product and order discounts have applied, which means the customer has to actually qualify after all the other promos.
// extensions/free-shipping-threshold/src/run.ts
import type { RunInput, FunctionRunResult } from "../generated/api";
const SHIPPING_THRESHOLD = 75.0;
export function run(input: RunInput): FunctionRunResult {
const subtotal = parseFloat(
input.cart.cost.totalAmount.amount
);
if (subtotal < SHIPPING_THRESHOLD) {
return { discounts: [] };
}
const targets = input.cart.deliveryGroups.map((group) => ({
deliveryGroup: { id: group.id },
}));
if (targets.length === 0) {
return { discounts: [] };
}
return {
discounts: [
{
targets,
value: { percentage: { value: "100.0" } },
message: "Free shipping",
},
],
};
}
Use cost.totalAmount not subtotalAmount so the threshold respects the discounts that already fired. A customer with a $80 cart and a $10 sitewide discount has a totalAmount of $70 and does not qualify, which is the correct behavior unless your merchant explicitly wants pre-discount thresholds.
The matching query:
query RunInput {
cart {
cost {
totalAmount {
amount
}
}
deliveryGroups {
id
}
}
}
How the 3 Functions stack at checkout (and what the customer sees)
Worked example. Customer has:
- 2x sneakers at {{ 60 | money }} each = {{ 120 | money }}
- 1x t-shirt at {{ 20 | money }}
Subtotal: {{ 140 | money }}.
Sneakers are in the bogo-eligible collection. T-shirt is not.
Step 1: BOGO Function fires. Cheapest of the two sneakers gets 100 percent off. {{ 60 | money }} discount applied to one sneaker line.
Cart state: {{ 140 | money }} subtotal, {{ 60 | money }} BOGO discount, {{ 80 | money }} effective subtotal.
Step 2: Sitewide 10 percent Function fires. Reads discountAllocations. BOGO is worth $60 / $140 = 42.8 percent effective, which is above 10 percent threshold, so the Function skips.
Cart state unchanged. {{ 80 | money }} effective subtotal.
Step 3: Free shipping Function fires. Reads totalAmount of $80. Threshold is $75. Qualifies. Shipping rate set to zero.
Final cart:
- Subtotal: {{ 140 | money }}
- BOGO discount: -{{ 60 | money }}
- Shipping: {{ 0 | money }}
- Total: {{ 80 | money }}
The customer sees one product discount line, no order discount line (correctly), and free shipping. The math is honest, the math is documented in the cart, and no Function is fighting another Function.
Swap the BOGO for a $5 customer-tag discount on the t-shirt and the sitewide 10 percent fires too, because $5 / $140 is well under 10 percent. The Functions adjust automatically. That is the value of reading discountAllocations.
How do I stay under the 25-Function limit on Shopify?
You get 25 active automatic Function-based discounts per store. Across all four types. That is generous, but real Plus stores fill it up faster than you would expect:
- 1 sitewide percent discount
- 1 BOGO per active campaign (often 3-4 running)
- 1 free shipping threshold, plus 1 per shipping zone for international
- 1 customer-tag wholesale discount, plus tiers (Bronze, Silver, Gold)
- 1 flash sale discount, sometimes 2 if you run them in parallel
- 1 first-order discount
- 1 returning-customer discount
- 1 abandoned cart recovery discount
That is 14 to 18 before you start the seasonal stack. BFCM weekend on a mature Plus store can easily push past 20.
How to stay under 25:
- Consolidate by parameterization. One Function with a metafield-driven config beats five Functions hardcoded to different segments. Read the Shopify Function metafield for thresholds, percentages, and target collections. The tiered bundle Discount Function post walks through a working example where one Function drives three price tiers off a single config.
- Archive after campaigns. Deactivate Discount Functions immediately after a campaign ends. Active Functions count toward the cap whether they fire or not.
- Use combinesWith rules instead of separate Functions. If two Functions only differ by their target collection, fold them into one.
- Audit quarterly. Walk the active Functions list and ask whether each one still has a job. Most stores carry 3 to 4 zombie Functions.
If you are pushing 25 and need more, you have crossed into the territory where you should be using a single Cart Transform Function that emits discount lines based on a rule engine you control. That is a different architecture and a different post.
What does the 2026-04 cart-level discount API expose?
The 2026-04 GraphQL API release added three discount visibility points that did not exist before:
cart.discountAllocations: every discount applied at the cart level, with type and value.cart.lines[].discountAllocations: per-line discount detail, with the originating discount’s class and code.cart.deliveryGroups[].discountAllocations: per-delivery-group shipping discounts.
For Functions running after the discount application stage, this payload is fully populated. For Functions running before, it is empty. That is by design, because earlier Functions cannot react to discounts that have not happened yet.
What this unlocks:
query RunInput {
cart {
lines {
id
discountAllocations {
discountedAmount {
amount
currencyCode
}
targetType
}
}
}
}
Combined with metafields on the Discount node, you can now write Functions that:
- Skip if a higher-priority discount already fired
- Stack only when the existing discount is below a threshold
- Apply a flat amount on top of a percent, only if the percent is below a target effective percent
- Refuse to fire on lines tagged as already-marked-down
This pattern shows up on every B2B-leaning Plus build where a wholesale customer-tag discount should never stack with a public flash sale but should always stack with free shipping. Pre-2026-04 it took a Cart Transform plus shared metafields. Post-2026-04 it is a single conditional in the wholesale Function.
For the broader Liquid foundation that pairs with these Functions, see the Shopify Liquid development guide.
How to verify your discount stack in 5 minutes
Three checks.
- Function preview, then real cart. The admin preview tool catches input parsing bugs but cannot show stacking interactions. After preview passes, push to a development store and inspect
cart.discount_applicationsin a real cart template. - Four-state sweep. Repeat the test in four states: logged-in customer, logged-out, with a sitewide code layered, and in a non-default currency. Most stacking bugs hide in one of those four states.
- Bogus Gateway test order. Place a $1 order through Shopify’s Bogus Gateway. Confirm the admin order detail shows every discount application in the order timeline and that totals match.
If any step fails, the bug is almost always combinesWith set on one side only, a Function reading subtotalAmount instead of totalAmount, or a stale CDN cart preview. The Function code itself rarely breaks.
What breaks Discount Function stacks in production?
A list of things that will bite you. In rough order of how often they have bitten me.
-
combinesWithis not symmetric. If discount A says it combines with B, but B does not say it combines with A, they will not stack. Set both sides explicitly in the discount nodecombinesWithinput. -
Function execution order is not guaranteed within a class. Two product Functions running in parallel may fire in any order. Do not write Function 2 to depend on Function 1 having already run unless one is in a different class (product vs order vs shipping).
-
Shipping discounts read post-discount totals. A free-shipping-over-$75 Function will not fire if a sitewide percent dropped the total to $74. This is correct behavior most of the time, but merchants frequently expect the opposite. Confirm the threshold logic in writing before you ship.
-
Currency rounding bites at the third decimal. A 10 percent discount on $19.99 is $1.999, which rounds to $2.00 in some currencies and $1.99 in others. The Function input gives you
currencyCode. Use it to round consistently with checkout. -
inAnyCollectionreturns null for products that are unpublished or out of stock in some configurations. Always null-check before treating afalseas “not in collection”. A null is not a no. -
Caching at the CDN edge can serve a stale cart preview to logged-in customers. This is rare but real. If a customer reports the wrong discount in the cart drawer but the right one at checkout, suspect cache before suspecting the Function.
-
Theme cart drawers do not always render
cart.discount_applicationscorrectly. Some Dawn forks render only one discount line even when multiple are applied. Audit the cart template before launching a multi-discount campaign. -
The 25-Function limit is per store, not per app. If you install three apps that each ship two Discount Functions, that is six against your cap. Audit installed apps quarterly.
I have shipped these stacks across multiple Plus clients in furniture, electronics, and apparel, all running tiered free-shipping ladders alongside customer-tag wholesale discounts and seasonal flash sales without any Function fighting another. The pattern works. The 2026-04 API made it cleaner. The 25-Function cap is enough headroom for any honest catalog.
For the Scripts-to-Functions migration path that gets you here, see the Shopify Scripts deprecation migration post and the real migration examples post. For a worked CRO case study where stacked discounts were part of the lift, see the WD Electronics case study.
The takeaway
- Audit active automatic Function discounts every quarter. Most mature stores carry 3 to 4 zombie Functions silently counting toward the 25-cap.
- Set
combinesWithon both sides of every discount node. The matrix is not symmetric; one side missing kills the stack with no error. - Read discountAllocations from the 2026-04 cart input before firing. A skip is sometimes the right output.
- Use
cost.totalAmount, notsubtotalAmount, for shipping thresholds. Free shipping should respect product discounts that already fired. - Test logged-in, logged-out, with codes layered, and across three currencies. Most stacking bugs hide in one of those four states.
Need a discount stack architected for your Plus store? Book a free 30-minute call.