The word "migration" undersells this. Shopify Scripts were Ruby, executed server-side inside the checkout request, with fairly free rein over the cart. Shopify Functions are compiled WebAssembly modules that run on Shopify's infrastructure inside a sandbox with hard constraints. You are not porting code. You are re-implementing a specification on a different execution model.
Understanding the constraints first makes the rebuild much faster, so start there.
Four constraints that change your design
1. No network calls. This is the big one. A Function cannot call your API, your ERP, or anything else. Every piece of data it needs must already be present in its input, which in practice means on the cart, the customer, or a metafield. If your Script fetched a price from an external service, that architecture is gone.
The usual answer is to sync the data into metafields ahead of time and have the Function read only those. It means accepting some staleness in exchange for the Function being fast and deterministic, which is a reasonable trade, and often better than the alternative, because a checkout that depends on a live third-party call fails when that service does.
2. One extension point per Function. Scripts could do several things in one file. Functions are scoped: a discount Function discounts, a delivery customization Function touches delivery options, a payment customization Function touches payment methods. A single Script frequently becomes two or three Functions.
3. You declare your input up front. Functions use a GraphQL input query to declare exactly what data they receive. This is a genuine improvement: it makes the data dependency explicit and reviewable, but it means "just read whatever you need off the cart" no longer applies.
4. There is an instruction limit. Functions run inside a bounded execution budget. Shopify documents that the limit exists and that JavaScript reaches it sooner than a language compiling directly to WebAssembly, which is why Rust is the recommended language. JavaScript is supported through Javy, Shopify's JavaScript-to-WebAssembly toolchain, which embeds a JS engine alongside your code in the module: convenient, but heavier.
Use JavaScript to prototype if that's what your team knows. If the logic loops over a large cart or does meaningful computation, plan for Rust.
The shape of a Function
Every Function has two parts: an input query declaring the data it needs, and a run function returning a typed result.
The input query is GraphQL, and it's where you pull in the metafields your logic depends on:
# src/run.graphql
query Input {
cart {
lines {
id
quantity
cost { amountPerQuantity { amount } }
merchandise {
... on ProductVariant {
id
product { id }
}
}
}
buyerIdentity {
customer {
# Tier lives on the customer record, synced ahead of time,
# because the Function cannot call out to fetch it.
tier: metafield(namespace: "pricing", key: "tier") { value }
}
}
}
}
The run function then receives exactly that shape and returns a discount result:
// src/run.js
export function run(input) {
const tier = input.cart.buyerIdentity?.customer?.tier?.value;
// No tier means no trade discount. Return the empty result rather
// than throwing — a Function that errors is a checkout that misbehaves.
if (!tier) {
return { discountApplicationStrategy: "FIRST", discounts: [] };
}
const percentage = { bronze: 5, silver: 10, gold: 15 }[tier];
if (!percentage) {
return { discountApplicationStrategy: "FIRST", discounts: [] };
}
const targets = input.cart.lines
.filter((line) => line.merchandise.id)
.map((line) => ({ cartLine: { id: line.id } }));
if (targets.length === 0) {
return { discountApplicationStrategy: "FIRST", discounts: [] };
}
return {
discountApplicationStrategy: "FIRST",
discounts: [
{
targets,
value: { percentage: { value: percentage } },
message: `${tier} trade pricing`,
},
],
};
}
Two things in that snippet are worth calling out because they're where ported Scripts go wrong.
The first is returning an empty discount result instead of throwing. In a Script, an exception was survivable in ways it isn't here. A Function that fails is a Function whose behaviour at checkout you no longer control; always return the valid empty shape.
The second is that the tier comes from a metafield rather than a lookup. That single decision is usually the whole architecture of a Scripts migration: figure out what external data the Script depended on, and get it onto the customer or product record before the Function ever runs.
Delivery and payment customizations
These follow the same pattern with different result types. A delivery customization receives the available delivery options and returns operations against them: hide, rename, or move. A payment customization does the same for payment methods.
// Hide express shipping when the cart contains
// anything flagged as oversized.
export function run(input) {
const oversized = input.cart.lines.some(
(line) => line.merchandise?.oversized?.value === "true"
);
if (!oversized) {
return { operations: [] };
}
const express = input.cart.deliveryGroups
.flatMap((group) => group.deliveryOptions)
.find((option) => option.title === "Express");
return express
? { operations: [{ hide: { deliveryOptionHandle: express.handle } }] }
: { operations: [] };
}
Note the same defensive shape: find the thing, and if it isn't there, return no operations rather than assuming.
What to do about the logic you can't move
Some Scripts don't have a clean Functions equivalent. The common ones:
- Logic depending on live external state. Sync it to metafields, and accept the staleness, or move the decision out of checkout entirely.
- Very large conditional tables. If a Script encoded hundreds of rules, putting them in the Function means redeploying to change a rule. Metafield-driven configuration is almost always the better shape.
- Cross-surface logic. A Script that discounted and changed shipping becomes two Functions that both read the same metafield, rather than one Function doing both.
A note on testing
Functions run in checkout, which is the worst place to discover a bug. Shopify's CLI supports running a Function locally against a JSON input file, and that's where the bulk of your testing should happen: feed it the edge cases directly (empty cart, missing metafield, unknown tier, single line, fifty lines) rather than trying to reproduce them by hand in a test checkout.
Then test on a development store with a real checkout before anything reaches production, because the input query is the part most likely to differ from what you assumed.
Verified against Shopify's developer documentation on 30 July 2026. The Function APIs are versioned and the discount entry points have changed across releases: check the docs for the API version you're targeting, and compile the samples above before relying on them.