Skip to main content

May 12, 2026 by Alex Massaad · 8 min read

Shopify Functions vs Shopify Scripts: A Migration Walkthrough

Shopify Functions vs Shopify Scripts: A Migration Walkthrough

We covered the Scripts deprecation in an earlier post. The short version: Shopify Scripts stop running on June 30, 2026, and Shopify Functions are the replacement. As of April 15, 2026, you can no longer edit or publish new Scripts, which means Plus merchants who haven’t migrated yet are now in the danger zone.

This post is the next layer down. It’s the walkthrough. What the actual migration looks like in code, what breaks in translation, and the order we run the work in when a client hands us a Script Editor full of Ruby and asks us to make it Functions.

If you have a developer on your team, this gives them a sane starting point. If you don’t, it gives you a clear picture of what you’re hiring for.

The Mental Model Shift

Scripts and Functions solve the same problem in very different ways. The biggest mistake we see is people trying to port Scripts line-by-line. That doesn’t work, because the underlying contract is different.

Scripts: Ruby code that runs inside Shopify’s checkout, mutates a Cart object directly, and returns the mutated cart. You read properties, you write properties, you’re done.

Functions: A WebAssembly module that receives a GraphQL input, returns a list of operations (FunctionRunResult), and lets Shopify apply those operations. You don’t mutate state. You declare what should change.

If you internalize that one shift, the rest of the migration is mechanical.

The Migration Order We Run

We don’t write a single line of Function code until steps 1 through 3 are done. Skipping ahead is how migrations slip three weeks past the deadline.

  1. Audit the Scripts. Read every active Script in Script Editor. Write down what each one does in plain English, including edge cases. Half the time, a Script does something the merchant forgot was even live.
  2. Map to native features. Shopify’s native discount and checkout customization features have grown a lot since 2016. Several Scripts we audit each year don’t need to become Functions. They can become native Automatic Discounts or Discount Combinations.
  3. Group what’s left. The leftover Scripts get grouped by extension point. Discount logic goes to a Discount Function. Shipping logic goes to a Delivery Customization Function. Payment logic goes to a Payment Customization Function.
  4. Pick the toolchain. We build Functions on Gadget.dev because it cuts the boilerplate dramatically, but vanilla Shopify CLI plus a Rust or JS template works fine for a one-off.
  5. Translate, deploy, test in a development store, then promote. Never test Function changes against the live checkout. Use a dev store with the same product catalog shape.

A Real Translation: Tiered Discount

Here’s the canonical “buy more, save more” Script that almost every Plus merchant has some version of.

Before (Ruby Script):

class TieredDiscount
  def run(cart)
    quantity = cart.line_items.reduce(0) { |sum, item| sum + item.quantity }

    discount_percent = case quantity
      when 0..2 then 0
      when 3..5 then 10
      when 6..9 then 15
      else 20
    end

    return if discount_percent == 0

    cart.line_items.each do |line_item|
      line_item.change_line_price(
        line_item.line_price * (Decimal.new(100 - discount_percent) / 100),
        message: "Tier discount (#{discount_percent}% off)"
      )
    end
  end
end

TieredDiscount.new.run(Input.cart)
Output.cart = Input.cart

After (Shopify Function in JavaScript):

// src/run.js
export function run(input) {
  const totalQuantity = input.cart.lines.reduce(
    (sum, line) => sum + line.quantity,
    0
  );

  const discountPercent =
    totalQuantity >= 10 ? 20 :
    totalQuantity >= 6  ? 15 :
    totalQuantity >= 3  ? 10 : 0;

  if (discountPercent === 0) {
    return { discounts: [], discountApplicationStrategy: "FIRST" };
  }

  return {
    discounts: [
      {
        message: `Tier discount (${discountPercent}% off)`,
        targets: input.cart.lines.map((line) => ({
          productVariant: { id: line.merchandise.id, quantity: line.quantity }
        })),
        value: { percentage: { value: discountPercent.toString() } }
      }
    ],
    discountApplicationStrategy: "FIRST"
  };
}

A few things to notice in the diff:

  • No mutation. We return a discounts array, we don’t reach into the cart and rewrite line prices.
  • Targets are explicit. Scripts let you call change_line_price on each line. Functions ask you to declare which variants the discount applies to and how it stacks via discountApplicationStrategy.
  • The data shape is GraphQL. cart.line_items is now cart.lines, line_item.quantity is now line.quantity, and you’ll spend the first hour of every migration relearning these names.

The GraphQL Input File Is Where People Get Stuck

Functions don’t receive the entire cart. They receive whatever you ask for in an input.graphql query. This is the file that surprises every developer the first time.

# src/input.graphql
query Input {
  cart {
    lines {
      quantity
      merchandise {
        ... on ProductVariant {
          id
          product {
            id
            tags
          }
        }
      }
    }
  }
}

If you forget to query tags here, you cannot read tags inside run.js, no matter how many console.log calls you add. The first hour of debugging a misbehaving Function is almost always “the input query is missing a field.”

Shipping and Payment Customization

Scripts had a single execution point: checkout. Functions split that into separate APIs:

  • Delivery Customization Function for shipping. Hide options, rename them, reorder them.
  • Payment Customization Function for payment methods. Same operations.
  • Cart Transform Function if you need to expand a single line into multiple lines (bundles, components).

A Script that hid Express Shipping for heavy carts becomes a Delivery Customization Function that returns a hide operation:

export function run(input) {
  const totalGrams = input.cart.lines.reduce(
    (sum, line) => sum + (line.merchandise.weight ?? 0) * line.quantity,
    0
  );

  if (totalGrams < 20000) {
    return { operations: [] };
  }

  const expressOption = input.cart.deliveryGroups
    .flatMap((group) => group.deliveryOptions)
    .find((option) => option.title === "Express");

  if (!expressOption) {
    return { operations: [] };
  }

  return {
    operations: [
      { hide: { deliveryOptionHandle: expressOption.handle } }
    ]
  };
}

The pattern is consistent across all three customization APIs: query what you need, decide what should change, return operations.

Gotchas We Hit Often

These are the ones that have actually cost us hours, not the ones we read about.

The 5ms execution budget is real. Functions enforce a strict performance limit. Loops over the entire catalog, regex on every line item description, deeply nested .find() chains will hit the budget on a busy cart. Profile early.

undefined vs missing fields. GraphQL only returns what you queried. If a field isn’t in input.graphql, it’s not null in your input object, it’s not present at all. Defensive code matters.

No external HTTP calls. Scripts didn’t allow them either, but people sometimes assume Functions do because Functions are deployed as part of an app. They don’t. If you need data from outside Shopify, fetch it ahead of time and pass it through metafields.

Discount stacking. Scripts let you stack discounts implicitly. Functions force you to declare a discountApplicationStrategy of FIRST or MAXIMUM. Pick the wrong one and your customers see different totals than they did under Scripts.

Localization and currency. Scripts gave you the cart’s currency on the cart object. In Functions, you query it via cart.cost.totalAmount.currencyCode (or similar paths depending on the API). Hardcoding CAD because the dev store is in CAD is the kind of bug that ships to a US store and breaks Black Friday.

Testing tools are different. Scripts had a console in the admin. Functions are tested with shopify app function run against fixture inputs. Build out a small set of fixture carts that represent your real edge cases, and run them on every change.

How Long Does a Migration Take?

For a single straightforward Script (one rule, one extension point), expect a day of work end to end. Audit, translate, test, deploy.

For a Plus merchant with eight to twelve active Scripts spread across discounts, shipping, and payment, expect two to three weeks. Most of that time is in steps 1, 2, and 5. The actual coding is the smallest part.

If your store has Scripts you’ve forgotten about, has Scripts written by someone who left the company, or has Scripts that interact with each other in ways nobody documented, double the estimate. We’ve seen migrations that took six weeks because the audit kept turning up new edge cases.

How We Run Scripts to Functions Migrations

When clients bring us this work, our process is:

  1. Read-only audit week. We get Script Editor access, document everything, and produce a migration plan with native-feature swaps called out.
  2. Build week(s). Functions written, deployed to a dev store, tested against fixture carts that match the merchant’s real traffic.
  3. Cutover. Functions enabled in production, Scripts disabled the same day. We watch checkout closely for 48 hours.
  4. Post-cutover review. We document what changed and hand the codebase off, since Functions live in a real Git repo, not in an admin panel.

If you’re sitting on Scripts and the June 30 deadline is starting to feel close, get in touch. We can run the audit week and tell you exactly how big the project is before you commit to a build.

The Bottom Line

The migration isn’t conceptually hard. It’s a translation job between two systems that solve the same problem. The discipline is in the audit and the testing, not the coding. Start with what you have, map what’s native, and write Functions only for what’s actually left.

The merchants who started this work in 2025 are done. The ones who started in early 2026 are mid-project. The ones who haven’t started yet still have time, but the runway is roughly eight weeks and shrinking.

Call to action background