5 Real Shopify Scripts I Migrated to Functions (Code)

I migrated 5 Plus-store Scripts to Functions in the last 90 days. Every guide I read first showed a one-line “10 percent off everything” toy. None of them shipped on a real store. So I wrote out the 5 patterns I keep hitting, with the Ruby in, the JavaScript out, and the gotcha that cost me an hour each time.

TL;DR: Five production migrations: tiered free shipping (Delivery Customization), free gift at threshold (Cart Transform plus Discount), B2B tag pricing (Discount with metafield mirror), BXGY (Discount with tie-break), country plus cart-contents payment hiding (Payment Customization). Together they cover roughly 80 percent of the Script logic on Plus stores. Migrate before June 30 2026.

For the strategic 60-day migration plan and timeline context, see Shopify Scripts deprecation: June 2026 migration plan. For the wider Plus checkout migration that runs in parallel (checkout.liquid to Checkout Extensibility, Web Pixels, Shop Pay component), see Shopify Plus checkout optimization 2026.

Why this matters for your store

  • Shopify removes the Scripts runtime on June 30 2026. Stores that miss the date lose tiered shipping, B2B pricing, and gateway rules at checkout, with zero fallback.
  • The 5 patterns below cover roughly 80 percent of Plus-store Script logic I see in audits, so the migration list is shorter than you think.
  • Functions run in 11 milliseconds on a 200-line cart, which is faster than the Script sandbox on Black Friday traffic.

Why Functions are not Scripts with a new syntax

Scripts mutate the cart in place. Call change_line_price, the cart object changes. Functions never touch the cart. They take an immutable input, run for 11 ms, and return a list of operations Shopify chooses to apply. The mental model flips: stop thinking “edit the cart,” start thinking “describe the change.”

That flip breaks every direct port. A for loop calling change_line_price becomes a filter plus map that builds a targets array. Cart Transform exists for the one case where you do need to add or modify a line.

Input queries are the other trap. Functions only see fields you explicitly request in run.graphql. Read customer.tags in the JavaScript without listing it in the query and the field is null at runtime. The discount silently does nothing. Roughly 90 percent of “my Function returns zero discounts” tickets I see trace back to an input-query miss. I keep a saved input JSON for each Function and replay it locally with shopify app function run before every deploy.

Migration 1: Tiered free shipping with a Delivery Customization

The Script hid paid shipping methods when the cart subtotal crossed $150. The Function reads the same threshold from a shop metafield and returns one hide operation per paid method. Most common Script I see, and the easiest of the 5.

The original Ruby Script the apparel client was running:

# Shopify Scripts editor: shipping
FREE_SHIPPING_THRESHOLD = Money.new(cents: 150_00)

class FreeShippingCampaign
  def initialize(threshold)
    @threshold = threshold
  end

  def run(rates)
    return rates if Input.cart.subtotal_price < @threshold
    rates.delete_if do |rate|
      rate.code != "Standard" && rate.code != "Free Shipping"
    end
    rates
  end
end

Output.shipping_rates =
  FreeShippingCampaign.new(FREE_SHIPPING_THRESHOLD).run(Input.shipping_rates)

The Delivery Customization Function. Threshold reads from a shop metafield so the merchant can change it without a redeploy:

// extensions/free-shipping-tier/src/run.js
export function run(input) {
  const operations = [];
  const subtotal = parseFloat(input.cart.cost.subtotalAmount.amount);
  const threshold = parseFloat(input.shop.metafield?.value ?? "150.00");

  if (subtotal < threshold) return { operations };

  for (const group of input.cart.deliveryGroups) {
    for (const option of group.deliveryOptions) {
      const title = (option.title ?? "").toLowerCase();
      if (title.includes("express") || title.includes("priority")) {
        operations.push({ hide: { deliveryOptionHandle: option.handle } });
      }
    }
  }
  return { operations };
}

The matching input query:

# extensions/free-shipping-tier/src/run.graphql
query Input {
  cart {
    cost { subtotalAmount { amount } }
    deliveryGroups { deliveryOptions { handle title } }
  }
  shop {
    metafield(namespace: "shipping", key: "free_threshold") { value }
  }
}

Merchant config: Settings, Custom data, Shop, add metafield shipping.free_threshold, single line text, value 150.00. Then Settings, Shipping and delivery, Customizations, add the Function.

The gotcha: Delivery Customization cannot create rates. The Standard rate must already exist in your shipping profile. If a merchant deleted Standard during cleanup, the Function hides every paid rate and the customer sees nothing. Verify the keep-rate before you deploy.

Migration 2: Free gift at threshold with Cart Transform plus Discount

The Script auto-added a $0 gift line when the cart crossed $200. Discount Functions cannot mutate the cart, so this migration splits across 2 APIs: Cart Transform adds the line, Discount zeros it.

The original Script on a beauty client:

# Shopify Scripts editor: line items
GIFT_VARIANT_ID = 41234567890
THRESHOLD = Money.new(cents: 200_00)

if Input.cart.subtotal_price >= THRESHOLD
  gift = Input.cart.line_items.find { |li| li.variant.id == GIFT_VARIANT_ID }
  if gift.nil?
    Input.cart.line_items <<
      Input.cart.line_items.new(variant_id: GIFT_VARIANT_ID, quantity: 1)
  end
  Input.cart.line_items.each do |li|
    if li.variant.id == GIFT_VARIANT_ID
      li.change_line_price(Money.zero, message: "Free gift")
    end
  end
end
Output.cart = Input.cart

The Cart Transform Function adds the gift line:

// extensions/free-gift-add/src/run.js
const GIFT = "gid://shopify/ProductVariant/41234567890";
const THRESHOLD = 200.0;

export function run(input) {
  const operations = [];
  const subtotal = parseFloat(input.cart.cost.subtotalAmount.amount);
  if (subtotal < THRESHOLD) return { operations };

  const present = input.cart.lines.some((l) => l.merchandise.id === GIFT);
  if (present) return { operations };

  operations.push({
    expand: {
      cartLineId: input.cart.lines[0].id,
      title: "Free gift",
      image: null,
      price: { adjustment: { fixedPricePerUnit: { amount: "0.00" } } },
      expandedCartItems: [{ merchandiseId: GIFT, quantity: 1 }],
    },
  });
  return { operations };
}

The Discount Function zeros the gift line:

// extensions/free-gift-discount/src/run.js
const GIFT = "gid://shopify/ProductVariant/41234567890";

export function run(input) {
  const targets = input.cart.lines
    .filter((l) => l.merchandise.id === GIFT)
    .map((l) => ({ cartLine: { id: l.id, quantity: l.quantity } }));

  if (targets.length === 0) {
    return { discounts: [], discountApplicationStrategy: "FIRST" };
  }
  return {
    discounts: [{
      targets,
      value: { percentage: { value: "100.0" } },
      message: "Free gift over $200",
    }],
    discountApplicationStrategy: "FIRST",
  };
}

Merchant config: Discounts, Create discount, App, select the free-gift Discount. Cart Transform registers on shopify app deploy. Verify it under Settings, Apps and sales channels, App Embeds.

The gotcha: Shopify allows one Cart Transform per cart line at a time. If you also run Bold Bundles or Shopify Bundles, that app may already own the line. Coordinate, or the gift quietly fails to appear.

Migration 3: B2B customer-tag pricing with a Discount Function

The Script gave any customer with the wholesale tag 30 percent off every line. The Function reads a customer metafield, because the raw tags array is not exposed in the Function input. This trap catches half my B2B clients.

The original Script:

# Shopify Scripts editor: line items
WHOLESALE_TAG = "wholesale"
DISCOUNT = 0.30

customer = Input.cart.customer
if customer && customer.tags.include?(WHOLESALE_TAG)
  Input.cart.line_items.each do |line|
    next if line.line_price < Money.zero
    line.change_line_price(
      line.line_price * (1 - DISCOUNT),
      message: "Wholesale 30%"
    )
  end
end
Output.cart = Input.cart

The Function reads the mirrored metafield:

// extensions/wholesale-discount/src/run.js
const PERCENT = "30.0";

export function run(input) {
  const value = input.cart.buyerIdentity?.customer?.metafield?.value;
  if (!value) return { discounts: [], discountApplicationStrategy: "FIRST" };

  const tags = value.split(",").map((t) => t.trim().toLowerCase());
  if (!tags.includes("wholesale")) {
    return { discounts: [], discountApplicationStrategy: "FIRST" };
  }

  const targets = input.cart.lines.map((l) => ({
    cartLine: { id: l.id, quantity: l.quantity },
  }));
  return {
    discounts: [{
      targets,
      value: { percentage: { value: PERCENT } },
      message: "Wholesale 30%",
    }],
    discountApplicationStrategy: "FIRST",
  };
}

The input query has to request the customer metafield by name:

# extensions/wholesale-discount/src/run.graphql
query Input {
  cart {
    lines { id quantity }
    buyerIdentity {
      customer {
        id
        metafield(namespace: "b2b", key: "tags_mirror") { value }
      }
    }
  }
}

Merchant config: Settings, Custom data, Customers, add metafield b2b.tags_mirror, single line text. A Shopify Flow workflow listens for customer/updated and writes comma-joined tags into the metafield. Then Discounts, Create discount, App, select the wholesale Function.

The gotcha: Flow only runs on future updates. My first ship of this had the discount doing nothing for two days because Flow had not backfilled existing wholesale customers. Run a one-off Flow trigger across the segment the moment the metafield exists.

Migration 4: Buy 2 get 1 with a stable tie-break

The Script gave 50 percent off the cheapest “Y” SKU when at least 2 “X” SKUs sat in the cart. Clean migration with one trap: the cheapest-line tie-break. Without it, equally priced Y items shuffle on every cart refresh and the discount jumps line to line.

The original Script:

# Shopify Scripts editor: line items
TRIGGER_TAG = "bxgy-x"
REWARD_TAG = "bxgy-y"
TRIGGER_QTY = 2
REWARD_PERCENT = 0.50

trigger_count = Input.cart.line_items.sum do |li|
  li.variant.product.tags.include?(TRIGGER_TAG) ? li.quantity : 0
end

if trigger_count >= TRIGGER_QTY
  reward_lines = Input.cart.line_items.select do |li|
    li.variant.product.tags.include?(REWARD_TAG)
  end
  cheapest = reward_lines.min_by { |li| li.variant.price }
  if cheapest
    cheapest.change_line_price(
      cheapest.line_price * (1 - REWARD_PERCENT),
      message: "Buy 2 get 1 50% off"
    )
  end
end
Output.cart = Input.cart

The Function. Note the second sort key on id to stabilise ties:

// extensions/bxgy-discount/src/run.js
const TRIGGER_QTY = 2;
const PERCENT = "50.0";

export function run(input) {
  const lines = input.cart.lines;
  const triggerQty = lines
    .filter((l) => (l.merchandise.product?.tags ?? []).includes("bxgy-x"))
    .reduce((s, l) => s + l.quantity, 0);

  if (triggerQty < TRIGGER_QTY) {
    return { discounts: [], discountApplicationStrategy: "FIRST" };
  }

  const candidates = lines
    .filter((l) => (l.merchandise.product?.tags ?? []).includes("bxgy-y"))
    .sort((a, b) => {
      const pa = parseFloat(a.cost.amountPerQuantity.amount);
      const pb = parseFloat(b.cost.amountPerQuantity.amount);
      return pa - pb || a.id.localeCompare(b.id);
    });

  if (candidates.length === 0) {
    return { discounts: [], discountApplicationStrategy: "FIRST" };
  }
  return {
    discounts: [{
      targets: [{ cartLine: { id: candidates[0].id, quantity: 1 } }],
      value: { percentage: { value: PERCENT } },
      message: "Buy 2 get 1 50% off",
    }],
    discountApplicationStrategy: "FIRST",
  };
}

The Function manifest:

# extensions/bxgy-discount/shopify.function.toml
api_version = "2026-01"

[[extensions]]
name = "bxgy-discount"
handle = "bxgy-discount"
type = "function"

  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/run.graphql"
  export = "run"

Merchant config: Tag X products with bxgy-x and Y products with bxgy-y. Discounts, Create discount, App, select the BXGY Function, set dates, combine with shipping on.

The gotcha: I missed the tie-break on the first deploy. The merchant got 3 “the discount keeps moving” tickets in 48 hours. Sort by price, then by id.

Migration 5: Hide a payment method by country plus cart contents

The Script hid Cash on Delivery for international orders and any cart with a fragile SKU. Payment Customization Functions are Plus-only. This migration earns the Plus tier.

The original Script:

# Shopify Scripts editor: payment gateways
COD_GATEWAY = "Cash on Delivery (COD)"
DOMESTIC = "US"
FRAGILE_TAG = "fragile"

country = Input.cart.shipping_address&.country_code
has_fragile = Input.cart.line_items.any? do |li|
  li.variant.product.tags.include?(FRAGILE_TAG)
end

if country != DOMESTIC || has_fragile
  Input.payment_gateways.delete_if { |gw| gw.name == COD_GATEWAY }
end
Output.payment_gateways = Input.payment_gateways

The Payment Customization Function:

// extensions/hide-cod/src/run.js
const COD = "Cash on Delivery (COD)";
const DOMESTIC = "US";

export function run(input) {
  const operations = [];
  const country = input.cart.deliveryGroups[0]?.deliveryAddress?.countryCode;
  const isDomestic = country == null || country === DOMESTIC;
  const hasFragile = input.cart.lines.some((l) =>
    (l.merchandise.product?.tags ?? []).includes("fragile")
  );

  if (isDomestic && !hasFragile) return { operations };

  const cod = input.paymentMethods.find((m) => m.name === COD);
  if (cod) operations.push({ hide: { paymentMethodId: cod.id } });
  return { operations };
}

The input query:

# extensions/hide-cod/src/run.graphql
query Input {
  cart {
    lines {
      merchandise {
        ... on ProductVariant { product { tags } }
      }
    }
    deliveryGroups { deliveryAddress { countryCode } }
  }
  paymentMethods { id name }
}

Merchant config: Settings, Payments, Customizations, Add customization, select the hide-COD Function. Tag fragile products with fragile. Verify by switching the cart shipping address between US and Canada.

The gotcha: Country code is null until the customer enters a shipping address. On the first render of the payment step the Function sees country = null. A strict country !== DOMESTIC check hides COD before anyone has typed an address. Treat null as domestic. The Function re-runs the moment the address lands.

5 production realities the docs skip

  1. Function logs are 24 hours, not real-time. Save 5 input JSONs covering your edge cases and replay with shopify app function run on every change. The admin runs report lags by up to 60 seconds.
  2. Discount combination defaults to nothing. A new Discount Function silently blocks existing automatic discounts. Set combination explicitly and write it into your runbook.
  3. The 11 ms execution budget is real. A for loop over a 200-line B2B cart with a nested find per line will trip it on a slow shard. One of mine timed out on the first Black Friday cart because carts had grown 3x since dev-store testing.
  4. Function deploys are not atomic with the storefront. shopify app deploy takes 1 to 3 minutes to roll. Customization config does not change during the rollout. Deploy in a low-traffic window.
  5. There is no Scripts-to-Functions audit tool. Shopify ships no report. Dig through Settings, Checkout, Scripts manually. On a 10-Script store I export everything to a Notion page, tag each one with the target Function API, and check them off as I go.

The migration order I ship: Delivery, Discount, Payment, Cart Transform

Delivery Customization first. Worst case the customer sees too many or too few shipping options, and rollback is one click.

Discount Functions second. A misfire applies the wrong discount or none, but checkout still completes and the runs report flags it the same day.

Payment Customization third. A bug here can hide every payment method and block checkout outright. Test on a development store with a real card before you ship.

Cart Transform last. It is the only API that mutates the cart, so the customer sees bugs immediately as a missing line, a price flip, or a duplicate gift. Soak the other 3 for 24 hours each before you touch it.

2 weeks per API gives you a buffer for the inevitable “wait, the Function does not see customer tags” surprise before June 30 2026.

How to verify your migration in 5 minutes

  1. Run shopify app function run against a saved input JSON for each edge case. The output should match what the Script produced for the same cart.
  2. Place a real test order on the development store with the customization wired up. Watch the Function runs report for the run record.
  3. On the production cutover, deploy the Function, leave the Script enabled for 24 hours, then disable the Script. If the Function runs report shows 0 errors across that window, the migration holds.

The takeaway

  • Audit every Script in Settings, Checkout, Scripts before you write any Function code.
  • Map each Script to one of the 5 patterns above and pick the matching Function API.
  • Mirror customer tags into a metafield via Flow before any B2B Discount Function ships.
  • Add a stable tie-break to every cheapest-line discount so the value stops shuffling.
  • Ship Delivery, Discount, Payment, then Cart Transform, with a 24-hour soak after each.

Need a Scripts to Functions migration done before June 30? Book a free 30-minute audit.

Frequently Asked Questions

Can I copy a Shopify Script directly into a Shopify Function?

No. Scripts are Ruby running inside a Shopify-hosted sandbox with mutable cart objects. Functions are deterministic WebAssembly modules that take an immutable input and return a list of operations. The mental model is different. Scripts mutate, Functions describe. Every migration in this post involves rewriting the logic, not translating it line for line. Plan an hour per Script for the rewrite plus another hour for testing on a development store.

Do Shopify Functions support reading customer tags for B2B logic?

Yes. Discount Functions, Payment Customization Functions, and Delivery Customization Functions can all query the buyerIdentity object on the cart input. That object exposes customer.id, customer.email, and customer.metafields. To read tags you need to expose them via a customer metafield because the raw tags array is not in the Function input by default. The merchant adds the metafield, the Function reads it, the discount applies. I show the exact GraphQL input query in Migration 3.

How long does a Scripts to Functions migration take per store?

On a Plus store with three to five Scripts, plan two to three days of focused work. Day one is auditing every Script, mapping each to the right Function API, and scaffolding the Functions with Shopify CLI. Day two is rewriting the logic and testing each Function on a development store with realistic cart data. Day three is the merchant configuration, the deploy, and a 24-hour soak window before retiring the Scripts. Stores with ten or more Scripts can take a full sprint.

What is a Delivery Customization Function and when do I use it?

A Delivery Customization Function runs at checkout, takes the available shipping methods as input, and returns operations that hide, rename, or reorder them. It replaces every shipping Script you have. Use it for tiered free shipping (hide paid methods over a threshold), country-specific carrier rules, or hiding express shipping for back-ordered items. It does not create new shipping rates. Rates still come from your shipping settings or a carrier app. The Function only filters and renames.

Why does my Discount Function return zero discounts even though the logic looks right?

The most common cause is missing GraphQL input fields. Functions only receive what you explicitly request in the input.graphql file. If you read cart.lines[0].merchandise.product.tags in the JavaScript but did not request it in the query, the field is null at runtime and the discount silently fails. Run shopify app function run with a sample input JSON to inspect what the Function actually sees. Ninety percent of zero-discount bugs are an input-query miss.

Can I run Shopify Scripts and Shopify Functions at the same time during migration?

Yes, briefly. Until April 15 2026 you can edit both, and until June 30 2026 you can run both. During cutover I deploy the Function on a Friday afternoon, watch it for 24 hours, then disable the matching Script on Saturday. Running both for one weekend gives you a clean rollback if the Function misfires. After June 30 2026 Shopify removes the Scripts editor and the runtime, so there is no longer a fallback. Migrate before that date.

Book Strategy Call