All articles
E-commerceAugust 13, 2026 7 min read

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:

  1. Product Discount / Order Discount — replaces most of what Script Editor's discount rules did
  2. Delivery Customization — reorder, rename, or hide shipping methods
  3. Payment Customization — hide or reorder payment methods based on cart shape
  4. 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.
  • hasAnyTag is a server-side helper. Doing tag matching in your Function code by iterating product.tags works 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 FIRST vs MAXIMUM strategy 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 run CLI command for local replays
  • Whatever console.log output 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:

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

#shopify#checkout#engineering#cro

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project