How Do You Migrate a Tiered Discount Script to Shopify Functions?
You migrate it by rewriting the logic in JavaScript, deploying it as a Shopify Functions extension through a custom app, and enabling it as a discount in your Shopify admin. The business logic translates directly — the tiers, the conditions, the percentages. What doesn't translate directly is the execution model or the output format. You're not rewriting Ruby in JavaScript; you're expressing the same decision tree in a completely different shape.
If your store is still running tiered discount Scripts, you're against the clock. Shopify locked all Scripts on April 15, 2026. You can no longer edit or publish them. On June 30, 2026, they stop executing entirely. That's 33 days from today.
The walkthrough below covers a standard quantity-based tiered discount (buy 3 get 5% off, buy 5 get 10% off, buy 10 get 20% off). More complex cases, filtering by product tag, conditional by customer type, stacking with other Scripts — are at the end.
Why can't I just leave my tiered discount Script running?
Because it stops working on June 30. Not "stops being editable", stops running. Any tiered discounts built on Shopify Scripts will stop executing at checkout the moment deprecation hits. Shopify hasn't documented the exact checkout experience after that point, but the practical outcome is the same: the discount logic won't run.
This is an easy thing to miss, especially for stores where the Script was set up years ago by a developer who's no longer around. There's no visible discount record in Shopify Admin for a Script-based discount. It only exists in the Script Editor. If no one on your team is actively maintaining it, the June 30 cutoff can sneak up on you.
Before you start writing any code, go to Apps → Script Editor in your Shopify Admin and make a list of every Script that's currently published. Note whether each one is a line item Script, a shipping Script, or a payment Script — they each migrate differently. This post covers line item Scripts specifically, which is where the majority of tiered discount logic lives. Shopify also provides a Scripts customizations report accessible from the Replace Shopify Scripts banner inside the Script Editor — it shows which of your Scripts can be replaced by Functions vs. native Shopify features, broken out by payment gateways, shipping, and product discounts.
What's actually different between a Shopify Script and a Shopify Function?
The conceptual model is similar. Both get invoked at checkout with cart data. Both return instructions about what to do with that cart. The differences are in the runtime, the delivery model, and how you express the output.
Shopify Scripts run Ruby on Shopify's servers. You write them in the Script Editor and publish them directly, no app required. The Script receives cart data, modifies line items in place, and returns the updated cart. That's the model you're migrating away from.
Shopify Functions run WebAssembly at the edge. You write JavaScript (or Rust), and Shopify compiles it to Wasm. The Function lives inside a Shopify app. Either a custom app you create through the Partner Dashboard, or a public app. When checkout runs, your Function receives input defined by a GraphQL query you write, and returns a structured JSON object describing the discount operations to apply.
The key difference for migration purposes: Functions don't modify cart objects directly. They return a list of operations — candidates for discounts that Shopify applies. You're not reaching into a cart and changing a price. You're saying "here are the discounts that qualify; apply the best one."
This means you're not translating your Script line-for-line. You're extracting the decision tree, which quantity thresholds trigger which discounts, and expressing it in a completely different output format.
One other practical difference: Functions require a Shopify app. If you've never built one, the setup takes an hour the first time. After that, deploying additional Functions is fast.
Before you write any code, map your Script logic
Pull up the Script Editor and read through the Ruby. Most tiered discount Scripts for quantity-based pricing follow one of two patterns:
Pattern A — Per-line-item quantity tiers. The Script checks the quantity of each cart line and applies a percentage discount to that line if it hits a threshold.
# Typical quantity tier Script (Ruby / Shopify Scripts)
Input.cart.line_items.each do |line_item|
qty = line_item.quantity
pct = if qty >= 10 then 0.20
elsif qty >= 5 then 0.10
elsif qty >= 3 then 0.05
else 0
end
if pct > 0
line_item.change_line_price(
line_item.line_price * (1 - pct),
message: "#{(pct * 100).to_i}% volume discount"
)
end
end
Output.cart = Input.cart
Pattern B — Order subtotal tiers. The Script looks at the total cart value and applies a percentage to the whole order.
Pattern A maps to productDiscountsAdd in the Functions output, targeting individual cart lines. Pattern B maps to orderDiscountsAdd — targeting the order subtotal with a minimum spend condition. This walkthrough covers Pattern A. The structure for Pattern B is similar; the difference is in the output operation type and the tier check logic (comparing cart.cost.subtotalAmount rather than per-line quantity).
Also note whether your Script filters by product tag, collection, or variant ID. If it does, you'll add those fields to your GraphQL input query in the next step — I'll flag exactly where.
How do I create a Shopify Functions discount extension?
You need two things: a Shopify app and the Shopify CLI.
If you don't have an existing custom app, create one through the Shopify Partner Dashboard — Apps → Create app → Custom app. Clone it locally and follow the CLI setup steps in the Partner Dashboard.
Once your app is set up locally, scaffold the discount extension:
shopify app generate extension --template discount --flavor vanilla-js --name=tiered-volume-discount
This creates extensions/tiered-volume-discount/ containing three things:
-
shopify.extension.toml— declares the target and links to your query and logic files -
src/cart_lines_discounts_generate_run.js— where your Function logic lives -
src/cart_lines_discounts_generate_run.graphql— the GraphQL query that defines what input data your Function receives
The target you'll be working with is cart.lines.discounts.generate.run — this handles both per-line-item discounts and order subtotal discounts. It's the correct target for tiered quantity logic. The scaffolding sets this up automatically.
How do I write the tiered quantity discount logic?
Step 1: Write the GraphQL input query
Your Function receives exactly the data you ask for in this query — nothing more. For a quantity-based tier, you need each line's ID, quantity, and merchandise type. Open src/cart_lines_discounts_generate_run.graphql and replace the contents with:
query CartLinesDiscountsGenerateRunInput {
cart {
lines {
id
quantity
merchandise {
__typename
... on ProductVariant {
id
}
}
}
}
}
If your Script filters by product tag, extend the ProductVariant block:
... on ProductVariant {
id
product {
hasAnyTag(tags: ["volume-eligible"])
}
}
The hasAnyTag field is how Functions expose product tags — you can't access the tags array directly, so you pass the specific tags you want to check as arguments and get a boolean back.
Step 2: Write the Function logic
Open src/cart_lines_discounts_generate_run.js. Here's a direct translation of the Pattern A Ruby Script:
// Tier config — update these to match your Script's thresholds
const TIERS = [
{ minQuantity: 3, percentage: "5.0" },
{ minQuantity: 5, percentage: "10.0" },
{ minQuantity: 10, percentage: "20.0" },
];
/**
* @param {CartLinesDiscountsGenerateRunInput} input
* @returns {CartLinesDiscountsGenerateRunResult}
*/
export function cartLinesDiscountsGenerateRun(input) {
const operations = [];
for (const line of input.cart.lines) {
// Skip non-product lines (gift cards, custom line items, etc.)
if (line.merchandise.__typename !== "ProductVariant") continue;
const qty = line.quantity;
// Walk tiers from highest threshold down — first match wins
const tier = [...TIERS]
.reverse()
.find(t => qty >= t.minQuantity);
if (!tier) continue;
operations.push({
productDiscountsAdd: {
candidates: [
{
message: `${parseFloat(tier.percentage).toFixed(0)}% volume discount`,
targets: [
{ cartLine: { id: line.id } }
],
value: {
percentage: { value: tier.percentage }
}
}
],
selectionStrategy: ProductDiscountSelectionStrategy.First,
}
});
}
return { operations };
}
Three things are different from the Ruby version:
The Ruby Script calls line_item.change_line_price() to directly mutate the price. The Function doesn't touch prices directly — it pushes a productDiscountsAdd operation with a candidates array. Shopify reads that array and applies the discount. Same outcome, different mechanism.
The selectionStrategy: ProductDiscountSelectionStrategy.First tells Shopify to apply the first qualifying candidate per line. If you want Shopify to evaluate multiple candidates and apply the best one for the customer, use Maximum instead.
The message field is what shows at checkout next to the discounted price. Match whatever your Script was displaying.
If your Script filters by product tag, add this check before the tier lookup:
if (!line.merchandise.product?.hasAnyTag) continue;
How do I test and deploy the Function?
Test locally before deploying. Build first:
shopify app function build
Then create a test input file input.json that mirrors the structure your GraphQL query returns — an object with cart.lines, each line having id, quantity, and merchandise.__typename. Run the Function against it:
shopify app function run --input=input.json --export=cartLinesDiscountsGenerateRun
Check the output JSON. For a line with quantity 5, you should see a productDiscountsAdd operation with a 10% candidate. For quantity 2, you should see { "operations": [] }. If the output matches your expected tier behavior, you're ready to deploy.
shopify app deploy
After deploying, go to Shopify Admin → Discounts → Create discount. Your Function doesn't appear as a generic toggle — Shopify creates an automatic app discount tied to your Function's handle. Give it a name (this is the label that shows in your Discounts list), set the discount class, and leave it active indefinitely. It shows up in admin labeled with whatever title you set, not as "Function."
Your existing Script will keep running until June 30 while you test the Function in parallel — Shopify hasn't documented a specific conflict mechanism between the two systems, but running both with overlapping logic risks customers receiving double discounts. Test the Function thoroughly, then disable the Script in the Script Editor as soon as you're confident it's working correctly. Don't wait for June 30 to pull the Script.
For a full map of which Scripts migrate to Functions, which map to native features, and how to prioritize if you're running five or more Scripts, I've written a complete walkthrough in my Shopify Scripts migration guide.
What if my Script logic is more complex?
Three patterns come up regularly:
Filtering by product collection or type. Add inAnyCollection(ids: ["gid://shopify/Collection/123"]) to your ProductVariant block in the GraphQL query. You can also use product.productType as a string field directly — both are valid in the 2025-07 Discount Function input schema.
Multiple Scripts that need to coexist. If you have a volume discount Script and a loyalty discount Script, build them as two separate Functions rather than merging the logic into one. Shopify's Discount Function API runs all active Functions concurrently — they have no knowledge of each other at execution time. Combination behavior (whether both apply, or only one) is controlled by discount combination rules on each discount node, not by execution order. Shopify allows up to 25 discount Functions active on a single store.
Customer-tag-based tiers (B2B or VIP pricing). If your Script checked customer tags to apply different tier percentages to different customer types, use the buyerIdentity field in your GraphQL input query. Two options exist in the 2025-07 schema: hasAnyTag(tags: ["vip"]) returns a boolean (true if the customer has any of the listed tags — use this for simple VIP tier checks); hasTags(tags: ["vip"]) { hasTag tag } returns an array of {hasTag, tag} objects, one per tag passed in (use this when you need to know which specific tags matched). For most tiered pricing cases, hasAnyTag is all you need.
Scripts that set line item properties. Some Scripts write custom properties on line items to pass data to other parts of the checkout flow. Shopify Functions don't do this — that's a Cart Transform Function's job, not a Discount Function. If your Script mixes discounting with property mutation, you'll split the logic across two different Function types.
How long does this migration actually take?
For a single tiered discount Script with no product filtering: 2–4 hours. About 30 minutes to read the Script and map the tier logic, 1–2 hours to write and test the Function, 30 minutes to deploy and verify in a test checkout.
If you haven't used the Shopify CLI before, add an hour for initial setup. After that, each additional Function migration goes faster.
What catches stores out is volume — not complexity. One Script is a half-day. Ten Scripts, each with slightly different logic, is a full week. If you have more than three Scripts still running, don't wait for the week of June 30 to start.
If you're not sure what Scripts are running in your store, or which ones can be replaced by native Shopify features versus which ones actually need a Function, I'm happy to take a look.