Guides

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.

Published by Addora
Updated March 18, 2026
22 min read
Editorial note: Discount Functions are not just the new place to put percentage-off logic. They are the discount policy layer in Shopify Functions. The hard part is not generating candidates. The hard part is deciding what should own pricing, what should own exclusions, and how to keep transformed carts from being discounted in ways the merchant did not mean.

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.run handles product and order discounts.

  • cart.delivery-options.discounts.generate.run handles 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.discountClasses to see which classes are enabled
  • discount.metafield for app-owned configuration
  • triggeringDiscountCode when the function is code-triggered
  • enteredDiscountCodes when 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:

  1. Cart Transform has already run before discount logic evaluates cart lines.
  2. Discount logic for cart lines runs before shipping discounts.
  3. 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.

  4. 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, and MAXIMUM.
  • Order discounts support FIRST and MAXIMUM.
  • 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.

MechanismWhat it controlsWhat it does not control
selectionStrategyCompetition among candidates in one operationGlobal stacking with other discounts
combinesWithWhether the discount node may combine by classWhich candidate your function should pick internally
excludedCartLineIdsWhich lines are excluded from order subtotal calculationsBundle 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.

  1. Cart Transform owns structure. It expands, merges, or updates lines and stamps machine-readable bundle markers.

  2. Discount Functions own discount policy. They read the markers and decide what to target, exclude, or reject.

  3. 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 STDOUT or STDERR for debugging

The debugging workflow worth standardizing on is:

  1. keep your input query minimal and version-aware
  2. regenerate schema when you change API version
  3. test function behavior locally with Shopify CLI
  4. use shopify app function replay to replay real executions
  5. 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:

  1. 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.

  2. Treat combination design as product behavior, not API plumbing. Merchants experience “does this stack”, not “did the operation use MAXIMUM”.

  3. Use order discounts carefully when product discounts already touched the same surface. excludedCartLineIds is not a minor feature. It is one of the cleanest ways to keep subtotal logic honest.

  4. 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.

  5. 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.

  6. 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.

  7. 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

Sources and further reading

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

Guides

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.

guidesShopify developerCheckout UI Extensions
Guides

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.

guidesShopify developerShopify Functions