You opened the Script Editor this morning. It’s read-only. The freeze hit on April 15, 2026 — no more edits, no more new Scripts. On June 30, every Script in your store stops executing. Shopify’s migration page makes it sound mechanical: rewrite the Ruby in Rust, point the merchant at a new app, done.
It isn’t. Functions is a different runtime with different rules, and several Script behaviors don’t have a clean port. This is a build log of the things that actually got in the way moving a real B2B store off Scripts — what we lost, what we kept, and the workarounds that survived contact with the runtime.
1. The runtime is not the same shape
Scripts and Functions are both “code that runs at checkout,” and that’s where the similarity ends.
| Shopify Scripts | Shopify Functions | |
|---|---|---|
| Language | Ruby DSL | Rust or JS → WebAssembly |
| Execution model | Mutates a Cart object in place | Pure function, Input → Output |
| Budget | None enforced in practice | 5ms wall, 11M instructions, 256KB binary |
| Surfaces | One Script type per category (line item / shipping / payment) | Separate APIs (Cart Transform, Discount, Delivery Customization, Payment Customization) |
| Distribution | Pasted into Script Editor per store | Bundled inside an app, deployed via CLI |
| Editing | Live on prod | Local, version-controlled, cargo test |
| Data access | Whatever you could reference on the cart object | GraphQL input query, metafields only |
| Network | Yes, in some Script types | No (Enterprise plans get Fetch targets) |
The shape change matters before you write a line of Rust. A single 80-line line item Script that read a customer tag, looked up a metaobject, modified line item properties, set a price, and branched on the discount code — that Script becomes a Cart Transform Function plus a Discount Function plus an app shell plus a sync job that flattens the metaobject into a JSON metafield. The unit of code triples; the unit of deployment goes from “paste into a textarea” to “ship a versioned app.”
Here’s the simplest end of the contrast. A Script that takes 10% off any item tagged loyalty:
# Shopify Scripts — line item script
Input.cart.line_items.each do |line_item|
next unless line_item.variant.product.tags.include?("loyalty")
line_item.change_line_price(
line_item.line_price * 0.9,
message: "Loyalty 10% off"
)
end
Output.cart = Input.cart
The same logic as a Discount Function in Rust:
// extensions/loyalty-discount/src/run.rs
use shopify_function::prelude::*;
use shopify_function::Result;
#[shopify_function_target(query_path = "src/run.graphql", schema_path = "schema.graphql")]
fn run(input: input::ResponseData) -> Result<output::FunctionRunResult> {
let targets: Vec<output::Target> = input
.cart
.lines
.iter()
.filter_map(|line| {
let product = match &line.merchandise {
input::InputCartLinesMerchandise::ProductVariant(v) => &v.product,
_ => return None,
};
if !product.has_tag.unwrap_or(false) {
return None;
}
Some(output::Target::CartLine(output::CartLineTarget {
id: line.id.clone(),
quantity: None,
}))
})
.collect();
if targets.is_empty() {
return Ok(output::FunctionRunResult { discounts: vec![], discount_application_strategy: output::DiscountApplicationStrategy::FIRST });
}
Ok(output::FunctionRunResult {
discounts: vec![output::Discount {
message: Some("Loyalty 10% off".to_string()),
targets,
value: output::Value::Percentage(output::Percentage { value: dec!(10.0) }),
}],
discount_application_strategy: output::DiscountApplicationStrategy::FIRST,
})
}
# extensions/loyalty-discount/src/run.graphql
query Input {
cart {
lines {
id
merchandise {
... on ProductVariant {
product {
hasTag(tag: "loyalty")
}
}
}
}
}
}
# extensions/loyalty-discount/shopify.extension.toml
api_version = "2026-07"
name = "loyalty-discount"
type = "function"
handle = "loyalty-discount"
[[extensions.targeting]]
target = "purchase.product-discount.run"
input_query = "src/run.graphql"
export = "run"
[extensions.build]
command = "cargo build --target=wasm32-wasip1 --release"
path = "target/wasm32-wasip1/release/loyalty_discount.wasm"
Eleven lines of Ruby become ~40 lines of Rust, a GraphQL input query, an extension manifest, and an app shell to host it. The Rust is faster at runtime — that’s not in dispute — but the moment you scale beyond one Function the per-Function overhead starts to dominate the migration timeline. Plan for it.
2. Discounts: the stacking model changed underneath you
This is the section that broke our checkout. It’s also the one Shopify’s migration guide is quietest about.
In Scripts, you could stack a line item discount with a discount code discount on the same item. A loyalty Script might apply 10% to anything tagged loyalty, the customer entered SUMMER15 at checkout, and both landed — first the loyalty, then SUMMER15 on top of the already-discounted price. That composition was the normal behavior, not a hack.
In Functions, a single line item gets one discount. Two product-level Discount Functions targeting the same line item don’t compose — only one wins, and Shopify picks whichever value is larger. Run a 10% loyalty Function and a 15% promo Function against the same SKU and the loyalty signal silently disappears. There’s no error, no warning, no admin notice. The cart just shows 15%.
That’s not a configuration knob. It’s the contract. From shopify.dev/docs/api/functions/latest/discount: each line item receives at most one product-level discount per category, and conflicts resolve by value. Read that twice. Anything you used to layer in Scripts has to be consolidated.
The workarounds, in order of least to most painful:
Consolidate every discount path into one Function with branching. Instead of loyalty-discount and summer-promo as two separate Functions, you ship one discounts.wasm that takes both inputs (customer tags via metafields, active campaigns via metafields, manual codes via the discount code path) and computes the final value per line. You lose the clean separation of campaigns. You gain control over the math.
// inside the consolidated Discount Function
let loyalty_pct = if has_loyalty_tag { dec!(10.0) } else { dec!(0.0) };
let promo_pct = active_promo_for_line(&line, &input.shop.metafields);
let combined = dec!(100.0) - ((dec!(100.0) - loyalty_pct) * (dec!(100.0) - promo_pct) / dec!(100.0));
Compounding the percentages by hand is the part that feels wrong. It is also the part that matches what Scripts gave you for free.
Use compare-at price for the baseline discount. Set the loyalty 10% as a compare-at on the variant (or via a Cart Transform that adjusts price), then let the Discount Function apply only the promo. The compare-at is “free” — it isn’t a discount in Shopify’s model, so the Function discount stacks cleanly on top. This works when the baseline is contractual (a B2B price tier) and breaks the moment the baseline is conditional on something the Cart Transform can’t see.
Segment so two paths never target the same line. If your loyalty and promo Functions are scoped by collection or tag and you can guarantee no overlap, the conflict never fires. This is the “just don’t do it” option, and it’s the only one that doesn’t require code changes — you re-shape your campaigns instead.
There’s a second discount-related loss worth flagging: the discount code value isn’t exposed in the Function input. If your Script branched on Input.cart.discount_code.code == "SUMMER15" to apply different math depending on which code was entered, that branch can’t be ported as-is. There’s an open discussion at github.com/Shopify/function-examples/discussions/574 asking for it; as of the 2026-07 API version, it still isn’t there. The workaround is to encode the per-code logic into the discount object itself in the admin (different discount, different Function target) and let merchants configure it from the discount UI — not what you had with Scripts, but it survives.
And one trap during the overlap period: if you have a line item Script and a Discount Function both active on the same store, the Script runs first. Your Function sees the already-discounted prices on the line items and computes its percentage off the wrong base. You either turn the Script off the moment the Function ships, or your Function has to detect already-discounted lines (compare cost.subtotalAmount to cost.compareAtAmount) and skip them. We picked “turn the Script off” because the alternative is a ladder of conditionals nobody will remember in six months.
3. You can’t change line item options from a Discount Function
Scripts let you do everything from one place. Same Script that set the price could also mutate line_item.properties, change the variant, add a gift line, swap the SKU. We had a Script that did exactly this for a customizable product — when the customer checked the “engraved” property, the Script bumped the price by €5 and added an engraved=true line item property and tagged the order. Three mutations, one place.
In Functions that’s three places.
- The Discount Function can change price. It cannot mutate line items.
- Mutating line items (add properties, expand into multiple lines, merge, update title) requires the Cart Transform Function — different API, different extension, different deployment.
- Tagging the order at the moment of cart change requires either an Order Created webhook on the app side or a separate Order Transform.
So the one Script becomes two Functions (Cart Transform + Discount) plus a webhook listener on the app server. That’s not the painful part. The painful part is the ordering.
Cart Transform runs before Discount. The Discount Function sees the cart after the Cart Transform has expanded, merged, or relabeled lines. If your Cart Transform splits one line into two (a base SKU plus an engraving line), the line IDs your Discount Function is matching against don’t exist anymore — the Cart Transform created new ones. You either:
- Key your Discount Function on something stable (variant ID, product tag, a property the Cart Transform copies onto the new lines), not on
cart.lines.id, or - Pass coordination state through a metafield the Cart Transform writes and the Discount Function reads.
Option 1 is the right answer. We learned this by shipping option 2 first and then debugging why the discount stopped applying after a Cart Transform deploy.
The rough shape, for the engraving example:
# extensions/engraving-cart-transform/src/run.graphql
query Input {
cart {
lines {
id
quantity
merchandise {
... on ProductVariant {
id
product { hasTag(tag: "engravable") }
}
}
attribute(key: "engraving") { value }
}
}
}
// engraving-cart-transform: expand engraved lines into a labeled child line
// (so the discount function can target by the carried-over attribute, not by id)
Operation::Expand(ExpandOperation {
cart_line_id: line.id.clone(),
expanded_cart_items: vec![
ExpandedItem {
merchandise_id: variant_id.clone(),
quantity: line.quantity,
price: Some(PriceAdjustment {
adjustment: PriceAdjustmentValue::FixedPricePerUnit(
FixedPricePerUnit { amount: base_price + dec!(5.00) }
),
}),
attributes: Some(vec![Attribute {
key: "engraving_applied".to_string(),
value: "true".to_string(),
}]),
},
],
title: None,
image: None,
price: None,
})
The Discount Function then keys on attribute(key: "engraving_applied") instead of the original line ID, which is gone. This is the kind of thing Scripts let you ignore because everything happened in the same scope. Functions force you to design the data flow between extensions, which is more work and arguably better engineering — but it is more work, and the migration estimate has to include it.
4. Data access: metafields only, no metaobjects, no DB, no HTTP
The biggest architectural loss is the data boundary. Scripts didn’t have the input concept at all — you walked the cart object and called whatever was there. If you needed external data, you pre-fetched it via the Storefront API and pasted it into the Script as a constant, which was ugly but worked.
Functions have a hard contract: you declare what you read in a GraphQL query, Shopify materializes it into the WASM input, the Function executes against that and returns. The query has a 30-point complexity ceiling. The execution has no network. The only persistent data source you can read is metafields.
That last part is the one that hurts.
No metaobjects. Metaobjects are not exposed as Function input. Confirmed in community.shopify.com/c/shopify-functions/is-it-possible-to-get-metaobjects-entries-using-shopify/m-p/2332960 — Shopify’s recommendation is to denormalize the metaobject into a JSON metafield on whatever resource the Function actually reads (product, variant, customer, shop). If your pricing rules lived in a pricing_tier metaobject with relationships to products and customer segments, you flatten the relationship graph into a JSON blob and write it onto the shop metafield. Then you re-flatten it every time the metaobject changes via an admin API webhook on your app side.
You now own a sync job. The metaobject is the source of truth, the metafield is the cache, and the cache is on a different update cadence than the source. Welcome to distributed systems.
No HTTP from the Function. You cannot call your backend from inside the Function. You cannot pull a live exchange rate. You cannot ask a recommendation service “should this customer get the loyalty rate today.” Anything dynamic has to be pre-synced to a metafield by an out-of-band job. (Enterprise plans on Plus get Fetch targets, which are HTTP requests outside the deterministic Function with strict timeout and caching rules. Useful but a different shape — it’s not a method call, it’s a separate execution.)
Hard caps on what fits. From shopify.dev/docs/apps/build/functions/programming-languages/rust-for-functions and the input limits:
- Each metafield value is capped at 10,000 bytes. Anything larger is silently dropped from the Function input. Not truncated. Not errored. Dropped.
- The total query has a 30-point complexity ceiling.
cart { lines { merchandise { ... on ProductVariant { product { metafield(...) } } } } }is most of your budget on a deep cart. - JSON metafield deserialization inflates the WASM binary. The bigger your schema, the bigger your
.wasm, and you have a 256KB cap. We ran into this with a 6KB pricing JSON that pushed our binary from 180KB to 240KB in--releasebecauseserde_jsongenerated a deserializer per nested type.
Real example. A B2B store with 40 customer segments × 200 products × 5 price tiers. In Scripts that lived in a metaobject and was queried on the fly. In Functions:
- The 40,000-row pricing matrix is too big for one metafield (10KB cap).
- We split it: one JSON metafield per customer segment, written onto the customer object, holding only the prices for products that customer can see (~300 entries each, ~4KB).
- A worker on the app side listens for metaobject changes via Admin API webhooks and rewrites the relevant customer metafields.
- The Discount Function reads
customer.metafield(namespace: "pricing", key: "tier_v1")and applies it.
That’s four moving parts to do what one Script line did:
tier = MetaobjectQuery.find_for(Input.cart.customer, line_item.variant.product)
Patterns that no longer work at all:
- “Look up customer tier from our backend at checkout time.”
- “Pull a live FX rate before applying the discount.”
- “Filter products by a metaobject relationship inside the Function.”
- “Read a metafield value larger than 10KB.”
- “Branch on the discount code the customer typed.”
Each of these has a workaround. None of them is a one-liner.
5. The good parts (don’t pretend there aren’t any)
Section 1 through 4 reads like a complaint. The complaints are real. The pluses are also real — they’re just less surprising, so they take less space.
Sub-5ms execution on WebAssembly. Shopify’s own benchmark puts Function execution under the 5ms wall in normal carts. Scripts had no enforced budget but degraded under flash-sale load — we’d see checkout latency spike when a Script that did a lot of cart iteration ran on a 200-line wholesale order. Functions either fit in the budget or fail to deploy. You find out at build time, not at 11pm during a sale.
Local dev and unit tests. The Script Editor was edit-on-prod. There was no test runner, no version control unless you copy-pasted into a git repo by hand, and any typo went straight to checkout. With Functions you write Rust, you cargo test against recorded fixtures, you commit, you shopify app deploy. The Function Runner CLI takes a JSON input and a compiled .wasm and runs them locally — the same code path Shopify uses in production, executed on your laptop. This is the single biggest quality-of-life upgrade, and it would be hard to argue for going back.
Native to Checkout Extensibility. Scripts were one of the last things gating stores from moving off checkout.liquid to the modern checkout. Functions are first-class on the new checkout — they don’t block UI extensions, they don’t block payment customizations, they coexist with everything else. The migration is the price of admission to the rest of the platform.
Multi-currency works the same in primary and secondary currencies. Scripts had a known class of bug where line item logic that worked perfectly in your store’s primary currency would compute wrong (or fail silently) on a cart in a non-primary currency, because the Script saw presentment_currency after a conversion that the Script Editor didn’t surface. Functions handle multi-currency through Markets at the platform level. Same input, same output, regardless of presentment.
Distributed as apps, not pasted per store. One source of truth in a git repo, semver versions, deploy via CLI, rollbacks via CLI, install on N stores from a single manifest. If you run more than one store, the Scripts model was the bottleneck — every customization was a copy-paste with drift. Functions makes this part trivial.
The Cart Transform vs Discount split is honest. Section 3 is annoying. It’s also the right design — Scripts let you mix line item mutation, pricing, and tagging in one 200-line file that nobody could safely edit a year later. Forcing those concerns into separate Functions means each one is small enough to read in a single sitting, and the data flow between them is explicit instead of implicit. You’ll appreciate this six months after the migration. You will not appreciate it during the migration.
6. A migration checklist that survives contact with the runtime
Run this in order. Don’t skip steps. The order matters because each one surfaces blockers for the next one.
- Inventory every active Script. For each, note: type (line item / shipping / payment), what data it reads, what it mutates, what discount logic it applies, whether it branches on the discount code, whether it reads metaobjects or makes any external lookups.
- Classify each Script. Pure price change → Discount Function. Line item mutation (properties, variants, expansion) → Cart Transform Function. Both → two Functions, plus a decision about which one keys on what (variant ID? carried attribute? metafield?). Shipping rate logic → Delivery Customization. Payment method visibility → Payment Customization.
- Map the data sources. For each Script, list every external data source it touches — metaobjects, your backend, the Storefront API, the Admin API. Each one needs a sync job to flatten into a metafield before the Function can read it.
- Audit metafield sizes. Run a script (regular Ruby script, not a Shopify Script) over every product, variant, customer, and shop metafield you currently read. Anything over 10,000 bytes is already broken — Functions will silently drop it. Split or denormalize before you write the Function.
- Audit discount stacking. Identify every pair of Scripts that touched the same line item. Each pair is a future Function consolidation. Plan the math now (compounding percentages, baseline via compare-at, or strict segmentation) — not while you’re writing Rust.
- Stand up the app shell.
shopify app init, thenshopify app generate extension --type=functionfor each Function you identified in step 2. Pick Rust over JavaScript for any Function that touches more than a handful of line items — the JS path will hit the 11M-instruction ceiling on large carts, and you don’t want to find that out in production on a wholesale order. - Build a test harness before porting logic. Capture real cart payloads from production (Function Runner accepts a JSON input file), commit them as fixtures, and write
cargo testcases that compare expected discount output against the Script’s behavior. You want a regression net before you change anything. - Port the simplest Function first. Pure pricing, no metafield dependencies, no Cart Transform interaction. Ship it shadow-mode (configured in admin but not active) and verify the math against the Script’s output for a week.
- Plan the overlap window. Remember Scripts run before Functions. Either turn the Script off the moment the Function ships and accept the cutover risk, or design the Function to detect already-discounted lines (
compareAtAmountvssubtotalAmount) and skip them. Pick one and write it down — don’t let it be implicit. - Cut over before June 30, 2026. There is no extension. Shopify’s changelog post is unambiguous: April 15 freeze, June 30 stop. After June 30, the Scripts simply don’t run. Your discounts will silently stop applying if you haven’t migrated. Don’t wait for week 25.
7. What we got wrong on the first pass
A few things bit us that aren’t on the official migration checklist:
- We started with JavaScript Functions instead of Rust. The JS path is shipped as a recommendation for prototypes, and it’s faster to write. It’s also slower at runtime, and we hit the 11M-instruction ceiling on a 60-line wholesale cart that worked fine in dev. Rewriting to Rust took two days. Starting in Rust would have taken three days for the first Function and then an hour each for the rest. Pick Rust.
- We tried to reproduce the Script’s exact API surface in our metafield schema. This produced a
pricing_v1JSON metafield with nested objects and arrays that pushed the WASM binary close to the 256KB cap on the first compile. Flatten aggressively. The serializer pays a per-type cost. - We left a Script and a Function active on the same line items for “the overlap week.” Section 2 covers what happened: the Script ran first, the Function discounted off the already-discounted price, customers paid 18% off instead of 10% off. Refunds went out on Friday. Either ship Scripts-off-Function-on as one atomic deploy, or build the “skip already-discounted lines” check into the Function from day one.
- We didn’t test the Cart Transform → Discount Function ordering with a non-trivial cart. The dev store had three SKUs. Production had carts with line item expansions where the Discount Function couldn’t find the line IDs it expected. Test with realistic cart shapes. Capture them from production.
8. Conclusion
The migration is doable. It’s not a port — it’s a rewrite against a stricter runtime with sharper edges, a smaller data window, and a discount model that no longer composes by default. Sections 2, 3, and 4 are where you’ll spend most of your time. Everything else is mechanical Rust.
If you have one Script, you’ll be fine. If you have a stack of Scripts that talked to each other implicitly through the shared Cart object, plan for a real engineering project — closer to a small service rewrite than a syntax conversion. Inventory first, audit data sizes second, port the simplest Function third, and leave the consolidated discount Function for last because it’s the one where you’ll change your mind twice.
The deadline is June 30, 2026. The freeze is already in effect. Whatever’s still in the Script Editor on July 1 is gone.

