Shopify Functions vs Scripts: What Actually Runs at Checkout in 2026
Scripts are gone, Functions are the new contract. Here's what we learned porting discount logic, delivery customizations, and payment gating to Shopify Functions — and where the model still hurts.
Shopify Scripts officially stopped running for most merchants in 2024, and by 2026 the ecosystem has settled — not gently — around Shopify Functions. If you inherited a Plus store with a Ruby Script Editor tab still bookmarked, this is the piece we wish someone had handed us before the first migration.
What follows is the mental model we now use when a client says "we just need a small tweak at checkout." Spoiler: there is no small tweak at checkout anymore.
The old contract vs the new contract
Shopify Scripts were a single-tenant, Ruby-in-a-sandbox model. You wrote imperative code, mutated the cart, and Shopify ran it inline. It was fast to prototype and terrifying to audit. One bad regex could brick checkout for every shopper.
Shopify Functions flipped the model. You now ship a WebAssembly module — typically compiled from Rust, JavaScript, or TypeScript via the Shopify CLI — that receives a typed GraphQL input and returns a typed output. Shopify executes it inside their own runtime with hard limits: roughly 5ms of CPU and a few megabytes of memory per invocation, no network calls, no filesystem, no ambient state.
That last part is the one senior engineers keep tripping over. A Function cannot call your API. It cannot read a feature flag from LaunchDarkly. It cannot look up a customer segment from your CDP. Everything it needs must be baked into the input via metafields, cart attributes, or the Cart Transform payload.
Why Shopify did this
The old Scripts model didn't scale to checkout extensibility. Once checkout became a set of composable UI extensions running across web, Shop app, and one-page checkout, the platform needed logic that was:
- Deterministic — same input, same output, every time
- Sandboxed — no merchant script can degrade another merchant's checkout
- Portable — runs identically on desktop web, mobile web, and the Shop app
Wasm plus a typed schema gets you all three. It also means Shopify can cache, replay, and reason about your logic in ways they never could with arbitrary Ruby.
The four Function types you'll actually touch
There are more than four, but in practice most engagements come down to these:
- Product Discount / Order Discount — replaces most of what Script Editor's discount rules did
- Delivery Customization — reorder, rename, or hide shipping methods
- Payment Customization — hide or reorder payment methods based on cart shape
- Cart Transform — expand a single line item into bundle components, or merge components into a bundle
Everything else (Fulfillment Constraints, Order Routing, Validation) is real but niche. If you're doing B2B or multi-warehouse work, you'll meet them.
A concrete port: tiered volume discount
Here's a discount rule that was maybe 15 lines of Ruby in Scripts: 10% off if the cart contains 5+ units of any product tagged wholesale-eligible, 15% off at 10+ units.
In Functions, you write the logic like this (TypeScript, .js output after build):
import type { RunInput, FunctionRunResult } from "../generated/api";
export function run(input: RunInput): FunctionRunResult {
const eligibleQty = input.cart.lines
.filter((line) => {
const product = line.merchandise.__typename === "ProductVariant"
? line.merchandise.product
: null;
return product?.hasAnyTag ?? false;
})
.reduce((sum, line) => sum + line.quantity, 0);
let percentage = 0;
if (eligibleQty >= 10) percentage = 15;
else if (eligibleQty >= 5) percentage = 10;
if (percentage === 0) {
return { discounts: [] };
}
return {
discounts: [
{
message: `Volume discount: ${percentage}% off`,
targets: [{ orderSubtotal: { excludedVariantIds: [] } }],
value: { percentage: { value: percentage.toString() } },
},
],
discountApplicationStrategy: "FIRST",
};
}
And the paired GraphQL input query, which is where a lot of the real work lives:
query RunInput {
cart {
lines {
quantity
merchandise {
__typename
... on ProductVariant {
product {
hasAnyTag(tags: ["wholesale-eligible"])
}
}
}
}
}
}
A few things worth noting:
- The GraphQL query is not a runtime call. Shopify uses it at deploy time to know what data to hydrate into your Function's input. Overfetch and you'll blow the input size limit.
hasAnyTagis a server-side helper. Doing tag matching in your Function code by iteratingproduct.tagsworks but costs you Wasm CPU budget you don't have.- Discount messages are surfaced verbatim in the checkout UI. Localise them via metafields if you sell across regions.
What ports cleanly, what doesn't
After a dozen or so migrations, here's the honest breakdown.
Ports cleanly
- Tiered volume discounts based on quantity, tag, or collection
- BOGO-style promotions where the rule can be expressed in cart state alone
- Shipping method hiding based on cart weight, country, or product type
- Payment method gating — e.g., hide COD above a certain order value
Ports painfully
- Customer-segment discounts where the segment lives in your CRM. You have to sync the segment down to a customer metafield first, which means eventual consistency and a nightly job. If the shopper qualifies at 11:59pm but the sync runs at 2am, they don't get the discount.
- Stacked promotions with complex precedence. Functions run in a defined order, but the
FIRSTvsMAXIMUMstrategy is coarse. Anything involving "apply discount A unless discount B is larger, but never both" gets ugly. - Time-sensitive flash pricing where the price should change mid-session. Metafield updates propagate, but not instantly, and the Function has no clock beyond what's in the input.
Doesn't port at all
- Real-time fraud checks that need to call an external API
- Dynamic pricing from an ERP — you'll need to push prices into Shopify via price lists or B2B catalogs instead
- Anything that needs to write — Functions are pure. If you want to record a decision, do it in a checkout UI extension or a webhook downstream
The observability problem nobody warns you about
With Scripts, if something broke you saw it in the Script Editor's test tab. With Functions, you get:
- Partner dashboard logs, which are truncated and delayed
- The
shopify app function runCLI command for local replays - Whatever
console.logoutput you can smuggle out via the log field
In production, when a merchant Slacks you "the discount isn't applying for this one customer," your only real move is to reconstruct their cart, replay it locally, and diff the output. Build that replay harness on day one. We keep a small Node script per client that fetches an order's line items via the Admin API, reshapes them into a Function input fixture, and runs the Wasm module against it.
shopify app function run --input fixtures/order-1234.json
This has saved us more incident time than any dashboard.
Architecture patterns that hold up
After enough of these, a few patterns keep earning their keep.
Keep Functions dumb, keep metafields smart
Your Function should be a pure decision engine. All the "why" — which customer tier, which promotion window, which product bundle — lives in metafields and cart attributes. This means your merchandising team can change behaviour without a redeploy, and your Function stays small enough to fit in the CPU budget.
One Function per concern
Resist the urge to build one mega-Function that handles all discounts. Shopify lets you install multiple discount Functions and controls their interaction. Splitting by concern (volume, loyalty, promo code) makes each one testable in isolation and lets non-engineers toggle them from the admin.
Version your input schema
When you change the GraphQL input query, you're implicitly changing the contract with your Function code. Treat the generated types as a first-class artifact: check them in, review them in PRs, and never edit the Function without regenerating.
When Functions are the wrong answer
Sometimes the right answer is not a Function at all. If your logic needs external data, needs to persist state, or needs to react to events, you want a checkout UI extension backed by an app proxy, or a post-purchase webhook, or in some cases a full move to a headless storefront with your own middleware.
We've had clients try to force a Function to do work that belonged in a Cloudflare Worker sitting between their storefront and Shopify. It never ends well. The 5ms budget and the no-network rule exist for a reason.
Where we'd start
If you're staring down a Scripts migration in 2026, or greenfielding checkout logic on a new Plus build, do these three things this week:
- Inventory every piece of Script logic and classify it against the ports-cleanly / painfully / not-at-all list above. Some of it should become a Function. Some of it should become a checkout UI extension. Some of it should be deleted because nobody remembers why it exists.
- Set up the local replay harness before you write a line of Function code. If you can't reproduce a production cart on your laptop in under five minutes, you will regret it during your first incident.
- Pick one Function, ship it end-to-end — Rust or TypeScript, doesn't matter — including CI, versioning, and a rollback plan. The second Function is ten times easier than the first.
If you want a hand scoping the migration or auditing an existing Functions setup, our e-commerce engineering team does this work every week, and we're happy to sanity-check an architecture before you commit.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Cart Abandonment Recovery on Shopify: What Actually Moves the Needle in 2026
Most cart abandonment recovery advice is stuck in 2019. Here's what we've seen actually recover revenue on Shopify stores in 2026 — and what's a waste of engineering time.
Shopify Collection Pages at 10,000 SKUs: Faceted Filtering Without Killing TTFB
Faceted filtering on large Shopify catalogs quietly destroys collection page performance. Here's how we architect it to keep TTFB under 400ms without a full replatform.
Shopify Metaobjects in Production: When They Replace an App, and When They Become the Problem
Metaobjects promise a schema-first alternative to bolting on another Shopify app. Here's where they actually hold up in production, and where they quietly become your next migration.
