Developer guide
Shopify Discount Function deep dive: product, order, shipping, combination rules, code rejection, and how it intersects with Cart Transform
A technical Shopify developer guide to Discount Functions covering the unified API, product, order, and shipping discounts, selection strategies, combination rules, code rejection, admin configuration, and Cart Transform-safe architecture.
What Discount Functions actually own
Discount Functions are Shopify’s discount policy layer. They decide when a discount should apply, what it should target, how much value it should generate, and which cart or delivery surfaces should be excluded. If the requirement is fundamentally “should this cart line, subtotal, or shipping option receive a discount”, this is the right layer.
That scope is narrower than many teams think. Discount Functions are not a generic cart mutation API. They do not own bundling, line topology, title rewriting, or visual grouping. They own discount candidates and discount policy.
“A single function processes one discount, either code-based or automatic, but can apply savings across three discount classes: product, order, and shipping.”
This matters because developers still blur three different responsibilities:
- Cart Transform owns cart structure and presentation.
- Discount Functions own discount policy and discount targeting.
- Validation owns checkout blocking and rule enforcement.
The clean mental model
Discount Functions answer “what discount should apply”. Cart Transform answers “what does the cart look like”. Validation answers “should checkout be allowed”. Systems get brittle when one layer tries to impersonate the others.
If you are already working at the seam between bundles and pricing, the
Cart Transform Function deep dive
is the companion piece. If you are untangling legacy checkout logic at the same time, the
Shopify Scripts to Shopify Functions migration guide
is the broader migration map.
The unified model for product order and shipping discounts
Shopify’s discount model is now unified. The older Product Discount, Order Discount, and Shipping Discount Function APIs have been folded into the Discount Function API. That does not mean the runtime became a single undifferentiated blob. It means one discount feature can now work across multiple discount classes with a shared mental model, shared configuration shape, and shared discount object.
The most important practical consequence is this:
cart.lines.discounts.generate.runhandles product and order discounts.cart.delivery-options.discounts.generate.runhandles shipping discounts.
Shopify’s own build tutorial now teaches the unified pattern as one discount that can reduce a cart line, discount the order subtotal, and provide free shipping. That is the correct way to think about the platform now, but it still helps to remember that line and delivery logic execute on separate targets.
“With Shopify Functions, you can create a new type of discount that applies to cart lines, order subtotal, and shipping rates.”
The discount object itself becomes the contract your function reads. In modern input queries you will usually inspect:
discount.discountClassesto see which classes are enableddiscount.metafieldfor app-owned configurationtriggeringDiscountCodewhen the function is code-triggeredenteredDiscountCodeswhen code interaction matters
That is a much cleaner model than treating product, order, and shipping discounts as three unrelated extension types. If this work sits inside a larger legacy migration, pair it with the
Shopify Scripts to Shopify Functions migration guide
. If the same project also changes bundle topology or synthetic line structure, keep the
Cart Transform Function deep dive
open next to it.
Function targets execution order and why it matters
Execution order shapes what your function can know. Shopify’s current function order is explicit: cart structure and presentation run first, discount calculations run after that, and validations run later.
“First, functions that change the pricing and presentation of items in a cart run. Then, functions that calculate discounts execute. Finally, functions that validate the cart contents run.”
For Discount Functions, that has four immediate implications:
- Cart Transform has already run before discount logic evaluates cart lines.
- Discount logic for cart lines runs before shipping discounts.
Functions that execute after discounts, such as validation surfaces later in the flow, can reason about completed discount state more reliably than Cart Transform can.
You should not design Cart Transform logic that depends on the final discounted line price, because the discount pass has not happened yet.
Shopify reinforced this further in the 2026-04 changelog by exposing richer discount detail inside the Functions cart only for functions that execute after discounts. That gives you a cleaner post-discount inspection model later in the flow, but it does not magically make pre-discount surfaces aware of final discount outcomes.
The working rule
If a decision depends on final discount state, do not try to make Cart Transform own it. Put that logic in a later function surface or redesign the contract so the discount layer remains the owner.
Building one discount across product order and shipping
The strongest implementation pattern is to treat the discount as one feature with two run targets. The cart lines target can return product and order discount operations. The delivery options target can return shipping discount operations. Both targets can read the same discount configuration, usually from an app-scoped discount metafield.
A typical cart-lines input query looks like this:
query RunInput {
cart {
lines {
id
quantity
cost {
subtotalAmount {
amount
}
}
merchandise {
__typename
... on ProductVariant {
id
product {
metafield(namespace: "$app:volume" key: "eligible") {
value
}
}
}
}
}
}
discount {
discountClasses
metafield(namespace: "$app:discounts" key: "function-configuration") {
jsonValue
}
}
triggeringDiscountCode
}Then your cart-lines run target can branch by class and output both product and order operations:
/**
* Illustrative example for cart.lines.discounts.generate.run
*/
export function cartLinesDiscountsGenerateRun(input) {
const classes = new Set(input.discount.discountClasses || []);
const config = input.discount.metafield?.jsonValue || {};
const operations = [];
const eligibleLines = input.cart.lines.filter((line) => {
const merchandise = line.merchandise;
return (
merchandise?.__typename === "ProductVariant" &&
merchandise.product?.metafield?.value === "true"
);
});
if (classes.has("PRODUCT") && config.productPercentage > 0) {
operations.push({
productDiscountsAdd: {
selectionStrategy: "ALL",
candidates: eligibleLines.map((line) => ({
message: `${config.productPercentage}% off eligible items`,
targets: [{ cartLine: { id: line.id } }],
value: {
percentage: {
value: config.productPercentage,
},
},
})),
},
});
}
if (classes.has("ORDER") && config.orderPercentage > 0) {
operations.push({
orderDiscountsAdd: {
selectionStrategy: "MAXIMUM",
candidates: [
{
message: `${config.orderPercentage}% off order subtotal`,
targets: [
{
orderSubtotal: {
excludedCartLineIds: eligibleLines.map((line) => line.id),
},
},
],
value: {
percentage: {
value: config.orderPercentage,
},
},
},
],
},
});
}
return { operations };
}The delivery target then handles shipping separately:
query DeliveryInput {
cart {
deliveryGroups {
id
}
}
discount {
discountClasses
metafield(namespace: "$app:discounts" key: "function-configuration") {
jsonValue
}
}
triggeringDiscountCode
}/**
* Illustrative example for cart.delivery-options.discounts.generate.run
*/
export function cartDeliveryOptionsDiscountsGenerateRun(input) {
const classes = new Set(input.discount.discountClasses || []);
const config = input.discount.metafield?.jsonValue || {};
if (!classes.has("SHIPPING") || config.shippingPercentage <= 0) {
return { operations: [] };
}
const firstGroup = input.cart.deliveryGroups[0];
if (!firstGroup) return { operations: [] };
return {
operations: [
{
deliveryDiscountsAdd: {
selectionStrategy: "ALL",
candidates: [
{
message: `${config.shippingPercentage}% off delivery`,
targets: [{ deliveryGroup: { id: firstGroup.id } }],
value: {
percentage: {
value: config.shippingPercentage,
},
},
},
],
},
},
],
};
}The important idea is not the percentage values. It is the shape. One discount feature, shared config, explicit class checks, target-specific outputs.
Combination rules selection strategies and the real stacking model
This is where many production bugs start. Shopify exposes two distinct layers of discount interaction, and developers routinely confuse them.
The first layer is selection strategy inside your function output. This decides how candidates inside one operation compete with each other.
The second layer is combination behavior on the discount node. This decides whether separate discounts are allowed to combine across product, order, and shipping classes.
Shopify’s docs are direct about the concurrency model:
“All discount functions run concurrently, and have no knowledge of each other.”
Inside the function itself, the selection strategies are not uniform across classes:
- Product discounts support
ALL,FIRST, andMAXIMUM. - Order discounts support
FIRSTandMAXIMUM. - Delivery discounts currently use
ALL.
That means a statement like “set MAXIMUM so it does not stack” is usually
incomplete. MAXIMUM only resolves competition among candidates inside that
operation. It does not stop another separate discount node from combining if the node’s
combination settings allow it.
| Mechanism | What it controls | What it does not control |
|---|---|---|
selectionStrategy | Competition among candidates in one operation | Global stacking with other discounts |
combinesWith | Whether the discount node may combine by class | Which candidate your function should pick internally |
excludedCartLineIds | Which lines are excluded from order subtotal calculations | Bundle structure or line transformation |
The safest pattern is to make the economic surface explicit. If your product discount is already discounting eligible bundle components, exclude those same lines from any order subtotal discount candidate. Otherwise the subtotal discount can accidentally apply on top of already-discounted value.
Creating code and automatic discounts from the Admin API
Discount Functions do not activate themselves. You still create discount instances through the Admin GraphQL API.
Use discountCodeAppCreate for code discounts and
discountAutomaticAppCreate for automatic discounts. Both mutations require
write_discounts, and both create a discount node that points at your app’s
function. If your app backend is Rails, the
Shopify Admin GraphQL patterns in Rails
guide is the closest companion for structuring these mutations cleanly.
mutation discountCodeAppCreate($codeAppDiscount: DiscountCodeAppInput!) {
discountCodeAppCreate(codeAppDiscount: $codeAppDiscount) {
codeAppDiscount {
discountId
title
combinesWith {
orderDiscounts
productDiscounts
shippingDiscounts
}
}
userErrors {
field
message
}
}
}{
"codeAppDiscount": {
"title": "VIP launch code",
"code": "VIP20",
"functionId": "YOUR_FUNCTION_ID",
"startsAt": "2026-03-18T00:00:00Z",
"combinesWith": {
"orderDiscounts": false,
"productDiscounts": true,
"shippingDiscounts": false
},
"metafields": [
{
"namespace": "$app:discounts",
"key": "function-configuration",
"type": "json",
"value": "{\"productPercentage\":20,\"orderPercentage\":0,\"shippingPercentage\":0}"
}
]
}
}mutation discountAutomaticAppCreate($automaticAppDiscount: DiscountAutomaticAppInput!) {
discountAutomaticAppCreate(automaticAppDiscount: $automaticAppDiscount) {
automaticAppDiscount {
discountId
title
combinesWith {
orderDiscounts
productDiscounts
shippingDiscounts
}
}
userErrors {
field
message
}
}
}The key architectural point is that combinesWith lives on the discount
instance. Your function logic and the discount node configuration must agree with each
other. A function that assumes non-combinability but is attached to a highly combinable
discount node is a production bug waiting to happen.
Do not hide discount behavior in code alone
Selection inside the function and combination on the node should be designed together. Merchants experience the combined outcome, not the elegance of your internal separation.
Rejecting discount codes with custom messages
Discount code rejection is one of the most important additions to the API. It gives you a first-class way to reject entered codes with a custom checkout message instead of relying on awkward side effects or vague merchant documentation.
Shopify now supports querying enteredDiscountCodes, inspecting which codes
are rejectable, and returning one or more
enteredDiscountCodesReject operations.
This is useful when you need to:
- prevent double-discounting on sale items
- allow only one code from a special campaign family
- disqualify certain products or bundle lines from a code
- replace a generic failure with a precise checkout message
query RunInput {
enteredDiscountCodes {
code
rejectable
}
cart {
lines {
id
bundleRole: attribute(key: "_bundle_role") {
value
}
}
}
}export function cartLinesDiscountsGenerateRun(input) {
const rejectableCodes = input.enteredDiscountCodes.filter((code) => code.rejectable);
const hasBundleParent = input.cart.lines.some(
(line) => line.bundleRole?.value === "parent",
);
if (!hasBundleParent) {
return { operations: [] };
}
const codesToReject = rejectableCodes
.filter((code) => code.code.startsWith("BUNDLE10"))
.map((code) => ({ code: code.code }));
if (codesToReject.length === 0) {
return { operations: [] };
}
return {
operations: [
{
enteredDiscountCodesReject: {
codes: codesToReject,
message: "This code cannot be used on transformed bundle lines.",
},
},
],
};
}Two details matter here. First, rejection works only for codes that are actually in the
enteredDiscountCodes input and are marked rejectable in that
execution context. Second, the rejection message is a buyer-facing checkout message, so
it should be localized and merchant-safe.
Configuring Discount Functions with Admin UI extensions
A real discount feature usually needs merchant configuration. Shopify’s current pattern is to pair the function with a Discount Function Settings admin UI extension and persist settings in app-scoped metafields on the discount.
The baseline pattern is straightforward:
- render merchant settings inside the Shopify admin discount detail page
- save those settings via the Discount Function Settings API
- read them in the function through
discount.metafield
Shopify strengthened this setup in the 2026-01 API version. Discount settings extensions can now access the selected discount method and manage discount classes from the UI itself.
That solves a real operational problem. Older setups tended to enable all three classes by default, even when the discount only needed one. That created needless conflicts, confusing UI, and accidental combination behavior.
The configuration rule I would standardize on is simple:
- store durable function config in one app-owned JSON metafield
- enable only the discount classes the discount actually needs
- render method-specific UI when code and automatic behavior differ
- avoid giant kitchen-sink schemas when two or three narrow knobs would do
Original insight
Discount settings should describe merchant intent, not expose your entire internal implementation. When the settings schema mirrors your code structure too closely, the admin UI becomes a debugging panel instead of a product surface.
When Discount Functions should own pricing instead of Cart Transform
This is the decision that causes the most confusion.
Cart Transform can change prices and presentation. Discount Functions can apply product, order, and shipping discounts. Both can affect what the buyer pays. That does not mean both are equally good homes for the same economic rule.
Discount Functions should usually own pricing when the merchant’s intent is any of the following:
- a discount campaign
- code-driven eligibility
- stacking or non-stacking behavior
- subtotal exclusions
- shipping incentives
- buyer-segment logic
Cart Transform should usually own the change when the merchant’s intent is structural or presentational:
- expand a parent bundle into components
- merge sibling lines into one synthetic bundle line
- change the visible title or image of a line
- encode a bundle price as part of the cart structure itself
The anti-pattern is encoding campaign economics in Cart Transform because the price is technically editable there. That often works in a demo and becomes painful in production, especially once merchants want code logic, exclusions, or predictable stacking.
If the business question sounds like “is this a discount”, start from Discount Functions. If it sounds like “what is this line or bundle supposed to look like”, start from Cart Transform. The
Cart Transform Function deep dive
covers that structural side in more detail, and the
Shopify Scripts to Shopify Functions migration guide
helps when the ambiguity comes from legacy Script behavior that previously mixed both concerns.
A discount safe architecture with Cart Transform
When both APIs are present, the safest design is to make the contract explicit.
Cart Transform owns structure. It expands, merges, or updates lines and stamps machine-readable bundle markers.
Discount Functions own discount policy. They read the markers and decide what to target, exclude, or reject.
Validation owns blocking. If a cart should not proceed, fail it as validation, not as hidden pricing behavior.
A practical pattern is to mark transformed lines clearly:
attributes: [
{ key: "_bundle_role", value: "parent" },
{ key: "_bundle_type", value: "gift-box" },
{ key: "_bundle_source", value: "cart-transform" },
]Then your Discount Function can respect those markers:
export function cartLinesDiscountsGenerateRun(input) {
const eligibleTargets = input.cart.lines
.filter((line) => line.bundleRole?.value !== "parent")
.map((line) => ({ cartLine: { id: line.id } }));
if (eligibleTargets.length === 0) {
return { operations: [] };
}
return {
operations: [
{
productDiscountsAdd: {
selectionStrategy: "ALL",
candidates: [
{
message: "Spring promotion",
targets: eligibleTargets,
value: {
percentage: { value: 10.0 },
},
},
],
},
},
],
};
}That is not the only pattern, but it is the one I would trust most in production. Synthetic structure gets explicit markers. Discount logic reads those markers. Nobody relies on wishful assumptions about which lines still count as “the same thing” after a transform.
The practical rule
If Cart Transform changes line identity or topology, make that visible to downstream discount logic. Hidden structure changes are a major source of wrong-target and double-discount bugs.
Limits debugging and production failure modes
Discount Functions live inside Shopify Functions resource limits, so the general limits still matter. For carts with up to 200 line items, Shopify documents an 11 million instruction limit, 128 kB function input, and 20 kB function output. Input queries also have their own constraints, including a 3000-byte maximum query size excluding comments.
Those numbers matter more than they look. They change how you should model discount logic:
- prefer compact metafield configuration over bloated input queries
- avoid whole-cart scoring logic when targeted logic would do
- do not over-fetch product data just because GraphQL makes it easy
- prefer Rust when the logic will be complex or high-volume
Shopify’s current Functions docs also call out three non-obvious realities:
- apps can reference only their own functions in Admin GraphQL mutations
- functions cannot use nondeterministic logic like clocks or randomness
- you cannot rely on printing to
STDOUTorSTDERRfor debugging
The debugging workflow worth standardizing on is:
- keep your input query minimal and version-aware
- regenerate schema when you change API version
- test function behavior locally with Shopify CLI
- use
shopify app function replayto replay real executions - log durable business inputs in your app layer, not just inside the function
The highest-value app-side logs are usually:
- discount configuration at execution time
- the discount node ID or merchant-facing discount identifier
- the cart lines or delivery groups relevant to the decision
- which exclusions or bundle markers were present
- which codes were entered and which were rejected
Function code is small, but discount bugs are rarely small. They usually live in the contract between merchant settings, transformed cart state, and combination behavior.
What Discount Functions are not for
Discount Functions are not the answer to every checkout customization problem. They are the wrong layer for:
- bundle expansion and merge topology
- cart line title and image presentation changes
- payment method control
- delivery method reordering or hiding
- hard checkout blocking with field-targeted errors
- general-purpose cart mutation
Those problems map more naturally to Cart Transform, Delivery Customization, Payment Customization, or Cart and Checkout Validation.
The fastest way to create a brittle pricing system is to use Discount Functions as a substitute for cart structure, and then to use Cart Transform as a substitute for discount policy.
Original insights that save real engineering time
These are the patterns I would optimize for early:
Pick one economic owner for each savings story. If a bundle includes an embedded price benefit, do not leave it ambiguous whether Cart Transform or Discount Functions really own that benefit.
Treat combination design as product behavior, not API plumbing. Merchants experience “does this stack”, not “did the operation use MAXIMUM”.
Use order discounts carefully when product discounts already touched the same surface.
excludedCartLineIdsis not a minor feature. It is one of the cleanest ways to keep subtotal logic honest.Use code rejection to explain policy, not just enforce it. A clear rejection message is better than silently letting a discount fail for reasons the buyer cannot infer.
Enable only the discount classes you truly need. A discount that is structurally capable of product, order, and shipping savings should not necessarily have all three classes enabled in merchant configuration.
Assume transformed carts need explicit discount contracts. If Cart Transform is in play, discount exclusions and target selection should read explicit markers, not guess from product identity alone.
Design for version drift. The discount surface is moving. Schema generation, replay tooling, and version-specific assumptions should be part of your normal workflow, not cleanup work after a breakage.
The deepest lesson
Discount Functions are easiest to use when you stop thinking of them as “percentage off code” and start thinking of them as a controlled discount policy engine that sits inside a larger checkout contract.
Best internal links
Cart Transform Function deep dive
for the structural side of bundles, line expansion, merge logic, and the precise places pricing collides with transformed cart topology.
Shopify Scripts to Shopify Functions migration guide
for mapping older line item, shipping, and payment Script behavior into the modern Functions surface area.
Shopify Checkout UI Extensions migration guide
for understanding where discount logic ends and where checkout UI, thank you page, and extension migration work begins.
Shopify Admin GraphQL patterns in Rails
for structuring the code and automatic discount creation mutations cleanly in a Rails app.
Checkout UI Extensions with a custom backend architecture
for cases where discount configuration, buyer messaging, and extension-owned backend flows need to line up with the same app contract.
Sources and further reading
Shopify Dev, Discount Function API
Shopify Dev, Build a Discount Function
Shopify Dev, Migrate from deprecated discount APIs to the Discount API
Shopify Dev, discountCodeAppCreate
Shopify Dev, discountAutomaticAppCreate
Shopify Dev, Build a Discount Function that rejects invalid codes
Shopify Dev, Build a discounts UI with admin UI extensions
Shopify Dev, Discount Function Settings API
Shopify Developer Changelog, Discount Function support for rejecting discount codes
Shopify Developer Changelog, Enhanced Discount Function configuration with Admin UI extensions
Shopify Developer Changelog, Enhanced discounts support in the Shopify Functions Cart
Shopify Dev, Function APIs
Shopify Dev, Cart Transform Function API
Shopify Dev, Cart and Checkout Validation Function API
FAQ
Can one Discount Function apply product, order, and shipping savings at once?
Yes, one Discount Function can support all three discount classes, but product and order discounts run on the cart lines target and shipping discounts run on the delivery options target. In practice, one discount feature can span all three classes, but the implementation is still target-specific.
Do selection strategies control stacking across separate discounts?
No. Selection strategies decide how candidates from one operation compete inside your function output. Cross-discount stacking is governed by the discount node's combination settings and Shopify's discount engine, not by your selectionStrategy alone.
Can a Discount Function reject a code with a custom message?
Yes. Shopify now supports returning enteredDiscountCodesReject operations with a custom checkout message, as long as the entered code is rejectable in the current run context.
Should bundle savings live in Cart Transform or Discount Functions?
Usually pick one economic owner. Let Cart Transform own structure and presentation, and let Discount Functions own discount policy. If Cart Transform also embeds the savings, downstream discount exclusions need to be much more deliberate or you will double-discount transformed carts.
Related resources
Keep exploring the playbook
Cart Transform Function deep dive: bundles, line expansion, merge logic, and when it conflicts with discounts
A technical Shopify developer guide to Cart Transform Functions covering lineExpand, linesMerge, lineUpdate, bundle pricing, metafield-driven configuration, discount conflicts, limits, and production design patterns.
Shopify Checkout UI Extensions migration guide
A practical Shopify developer guide to moving from checkout.liquid, Scripts, additional scripts, and script tags to Checkout UI Extensions, Functions, web pixels, and blocks.
Shopify Scripts to Shopify Functions migration guide
A technical Shopify developer guide to migrating line item, shipping, and payment Scripts to Shopify Functions, unified Discount API, delivery and payment customizations, validation, and modern deployment workflows.