The companion post: what to actually do, in code
Our previous post laid out what breaks on June 30, 2026 when Shopify Scripts deprecates. This one is the engineer’s playbook: how to actually port the Ruby to Functions, what the Shopify CLI looks like, what the input/output contracts are, and the gotchas we have hit on a dozen Plus migrations.
You are reading this because you are the one doing the migration. Bookmark this. The CLI commands, code snippets, and test-matrix patterns are the actual playbook we use on paid engagements.
If you have not read the strategic overview yet, do that first. It frames the deadline, the revenue stakes, and the five migration patterns. This post assumes you have already inventoried your store’s Scripts, mapped each one to a revenue line, and are ready to start writing Functions.
Understanding the Architectural Shift
Shopify Scripts and Shopify Functions are not the same product with a new name. They are different in five fundamental ways, and every one of them affects how you migrate.
Runtime model
Scripts ran in a Ruby sandbox hosted by Shopify. You wrote Ruby, pasted it into the admin, hit save. Shopify executed it at cart and checkout, in a tightly limited environment with no network, no storage, and no app context.
Functions run as WebAssembly modules. You write Rust or JavaScript, compile to WASM, and Shopify executes that bytecode inside its checkout pipeline. The sandbox is even tighter than Scripts: no network, no storage, sub-millisecond execution targets, hard CPU and memory limits.
Deployment model
Scripts were edited in the Plus admin. There was no version control, no deployment step, no CI. You pasted Ruby into a text box and clicked save. Audit trail: a few admin log entries.
Functions are deployed via the Shopify CLI as part of a custom app. Source lives in your repo. Builds run in CI. Deployments are explicit. Rollback is a redeploy of a previous version.
App scoping
Scripts had no app association. They lived under the merchant’s Plus admin, edited by anyone with the right role.
Functions are app-scoped. You build a Shopify app (custom or public), the Function lives inside that app, and the app gets installed on the store with specific scopes. The merchant’s admin shows the Function as part of the app’s UI surface. No app, no Function.
Input and output contracts
The Shopify Scripts API exposed Input.cart, Input.cart.customer, Input.cart.line_items as Ruby objects you could read and mutate.
Functions take a GraphQL input at the top of the WASM module and return a mutation operation. The shapes are strictly typed, the inputs are configured per-Function, and the output schema is defined by the Function target (e.g. cart.lines.discounts.generate.run, cart.delivery-options.transform.run, cart.checkout.payment-methods.transform.run).
Composability
Scripts ran as one combined Ruby file with implicit ordering. Functions are independent units. Multiple Functions can target the same surface, and Shopify merges their output according to documented rules (e.g. discount Functions stack additively unless one returns Replace).
Practical implication: the migration is not just “rewrite the Ruby in Rust.” You also rethink the deployment story (Shopify CLI + CI), the app architecture (one app per logical group of Functions?), and the merge semantics (will three discount Functions stack correctly?).

Setting Up Your First Function: The Scaffolding
Install the Shopify CLI. The current version handles Function generation out of the box.
npm install -g @shopify/cli@latest shopify version
Create or pick a Shopify app to host the Functions. If you do not already have a custom app, create one:
shopify app init --name=plus-functions-migration cd plus-functions-migration
Inside the app, generate a Function with the type matching what you are migrating:
# Discount Function (replaces Line Item Scripts) shopify app generate extension --type=product_discounts --name=tier-discount # Delivery Customization (replaces Shipping Scripts) shopify app generate extension --type=delivery_customization --name=region-shipping # Payment Customization (replaces Payment Scripts) shopify app generate extension --type=payment_customization --name=hide-cod-international
For each Function you generate, the CLI scaffolds a directory with:
shopify.extension.toml(Function metadata, target, input config)src/main.rsorsrc/index.js(the Function body)input.graphql(the GraphQL query that defines what your Function receives)package.jsonorCargo.toml(dependencies)
Pick Rust unless you have a reason not to. JavaScript is fine for simple Functions, but Rust gets you stricter type safety, better performance against the CPU limit, and tooling that catches input-contract drift at compile time. We migrated one customer’s JS Function to Rust mid-project because the JS version intermittently hit the CPU limit on large carts. Type errors in the JS version had been silently coerced; Rust would have caught them.
Deploy the Function to a development store first:
shopify app dev # In the CLI's prompts, pick or create a dev store # Browse to the URL it prints, install the app on your dev store
The Function is now live in dev. It will not actually do anything until you attach it via the dev store’s admin (discount automation, shipping rule, or payment rule, depending on type).
Pattern A: Migrating a Line Item Script to a Discount Function
This is the most common migration. Here is a real Script we have seen in the wild:
# Legacy Shopify Script: tier_gold customer gets 15% off Input.cart.line_items.each do |line_item| if Input.cart.customer && Input.cart.customer.tags.include?("tier_gold") discount = line_item.line_price * 0.15 line_item.change_line_price( line_item.line_price - discount, message: "Tier Gold discount" ) end end Output.cart = Input.cart
The equivalent Discount Function in Rust looks structurally different. You query the input, generate a discount operation, return it. The Function does not mutate the cart; it tells Shopify what discount to apply, and Shopify applies it.
First the GraphQL input query (input.graphql), which tells Shopify what data to pass to your Function:
query Input { cart { buyerIdentity { customer { hasTag(tag: "tier_gold") } } lines { id cost { amountPerQuantity { amount } } } } }
Then the Function body in Rust (src/main.rs, abbreviated):
use shopify_function::prelude::*; #[shopify_function] fn cart_lines_discounts_generate_run( input: input::ResponseData, ) -> Result<output::CartLinesDiscountsGenerateRunResult> { let has_tier_gold = input .cart .buyer_identity .as_ref() .and_then(|b| b.customer.as_ref()) .map(|c| c.has_tag) .unwrap_or(false); if !has_tier_gold { return Ok(output::CartLinesDiscountsGenerateRunResult { operations: vec![], }); } let targets: Vec<_> = input .cart .lines .iter() .map(|line| output::Target { cart_line: Some(output::CartLineTarget { id: line.id.clone(), quantity: None, }), ..Default::default() }) .collect(); Ok(output::CartLinesDiscountsGenerateRunResult { operations: vec![output::Operation { product_discounts_add: Some(output::ProductDiscountsAddOperation { candidates: vec![output::ProductDiscountCandidate { targets, value: output::ProductDiscountCandidateValue { percentage: Some(output::Percentage { value: dec!(15) }), ..Default::default() }, message: Some("Tier Gold discount".to_string()), ..Default::default() }], selection_strategy: output::ProductDiscountSelectionStrategy::First, ..Default::default() }), ..Default::default() }], }) }
The pattern shift to internalize: Scripts mutated state imperatively. Functions are pure: read input, return operation, Shopify applies the operation. Once you stop fighting the new model, the code shrinks.
The Function above replaces about 8 lines of Ruby with about 30 lines of Rust. Most of the Rust is boilerplate around the type system. The actual decision logic is two checks: does the customer have the tag, what discount to apply.
Deploy:
shopify app deploy # Follow prompts. The deploy creates an app version.
In the merchant’s admin (or your dev store), create an automatic discount that uses the deployed Function. The “Tier Gold” customer can now check out and see the 15% applied.
Pattern B: Migrating Shipping Scripts to Delivery Customizations
Shipping Scripts hid or renamed shipping methods. The Function equivalent is a delivery_customization Function that takes the cart’s delivery options and returns a list of operations to hide or rename them.
Original Script (hide overnight shipping for heavy carts):
if Input.cart.subtotal_price_was > 50.0 && Input.cart.total_weight > 5000 Input.shipping_rates.delete_if do |rate| rate.name.include?("Overnight") end end Output.shipping_rates = Input.shipping_rates
The Delivery Customization Function input (input.graphql):
query Input { cart { cost { subtotalAmount { amount } } deliveryGroups { deliveryOptions { handle title } } lines { quantity merchandise { ... on ProductVariant { weight } } } } }
And the Function body (JavaScript example, since shipping rules are usually simple enough):
export function run(input) { const subtotal = parseFloat(input.cart.cost.subtotalAmount.amount); const totalWeight = input.cart.lines.reduce( (sum, line) => sum + (line.merchandise.weight || 0) * line.quantity, 0 ); if (subtotal <= 50 || totalWeight <= 5000) { return { operations: [] }; } const overnight = input.cart.deliveryGroups .flatMap((g) => g.deliveryOptions) .filter((o) => o.title.toLowerCase().includes("overnight")); return { operations: overnight.map((option) => ({ hide: { deliveryOptionHandle: option.handle }, })), }; }
The Function deploys the same way (shopify app deploy) and attaches in the admin under Settings > Shipping and delivery > Customizations.
Pattern C: Migrating Shopify Plus Checkout Scripts to Checkout Extensibility
If your store also has legacy checkout.liquid with custom shopify checkout scripts pulling third-party data, doing tax-ID validation, or rendering a custom banner, that is a different migration entirely. Checkout Extensibility replaces the entire checkout.liquid surface with a React-based extension system.
The shopify checkout scripts equivalent in Extensibility is a Checkout UI extension:
shopify app generate extension --type=checkout_ui_extension --name=tax-id-validator
This scaffolds a directory with a React component and a TOML manifest. The component declares its extension target (e.g. purchase.checkout.delivery-address.render-after), receives the checkout state via hooks, and renders UI into the checkout flow.
import { reactExtension, useApplyAttributeChange, BlockStack, TextField, Banner, } from "@shopify/ui-extensions-react/checkout"; export default reactExtension( "purchase.checkout.delivery-address.render-after", () => <TaxIdValidator /> ); function TaxIdValidator() { const applyAttributeChange = useApplyAttributeChange(); // ... validate against tax-ID service, set checkout attribute, render Banner }
Checkout Extensibility is React-server-rendered. No direct DOM access, no jQuery, no third-party scripts that load via the old additional_checkout_scripts Liquid hook. Anything that needs a server-side call goes through your Shopify app’s backend or through a separately deployed serverless function the UI extension calls.
The migration cost on Checkout Extensibility scales with how much custom logic lived in your old checkout.liquid. A simple banner and one custom field is a few days. A multi-step custom checkout with third-party integrations is the 6-to-10-week project we warned about in the previous post.
Testing Your Function Before Cutover
The shopify scripts api had no formal test harness. Most teams tested in the admin by adding a test product to a cart with a test customer.
Functions support unit tests via the CLI:
cd extensions/tier-discount shopify app function test
The CLI runs your Function against fixture inputs (JSON) and compares to fixture outputs. You write the fixtures alongside the Function.
Real test matrix we use on Plus migrations:
| Test scenario | Customer | Cart | Expected discount |
|---|---|---|---|
| Tagged customer, normal cart | tier_gold | 2 products | 15% on every line |
| Untagged customer | retail | 2 products | No discount |
| Tagged customer, empty cart | tier_gold | 0 lines | No operations |
| Tagged customer, one free item | tier_gold | 1 free product | No discount on free line |
| Customer with multiple tags | tier_gold + vip | 2 products | 15% (single discount, not stacked) |
| Tagged guest customer | tier_gold (guest) | 2 products | 15% applied |
Add fixtures for each row. The cost of writing the fixtures once is far less than the cost of catching the bug after cutover.
Beyond the unit tests, run a real-customer test on a dev store with the Function attached. Place an order. Confirm the discount in the order details, the customer’s email receipt, and the back-office finance export. If any of those three diverge, the Function is wrong.
The Cutover Plan
Order of operations matters. We have seen merchants get this wrong and lose 24 hours of double-discounted orders.
- Deploy the Function to production via
shopify app deploy. - Install the app on the production store. Confirm the Function appears in the admin under the appropriate surface (Discounts > Functions, Shipping > Customizations, Payments > Customizations).
- Attach the Function (e.g. create an automatic discount that uses it).
- Run a real-customer test on production. Tagged test customer places a small order, confirm the math.
- Verify in finance: the order export, the daily revenue summary, the customer’s email receipt all show the discount correctly.
- Only after all of the above: disable the legacy Script in Plus admin > Apps > Shopify Scripts.
- Re-verify the real-customer test. Same order math.
The 24-hour failure mode: team disables the Script before verifying the Function. Some other process (cached cart state, a stale shipping profile, a misconfigured discount automation) prevents the Function from running. Customers see no discount. Tagged customers complain. Team scrambles to re-enable the Script. The Plus admin Scripts edit interface is still slow even when re-enabled, and the testing window is gone.
Common Pitfalls We Keep Finding
The Function is deployed but not attached
This is the silent-failure pattern from the previous post, restated more specifically. Functions exist as code inside an app. They do not run unless something in the admin says “use this Function.” A deployed Function with no discount automation, no shipping rule, no payment rule pointing at it is just bytecode sitting in S3.
Discount stacking math is different
Scripts could apply discounts arbitrarily. Functions return operations that Shopify merges. If three Discount Functions all target the same line, the merger applies them according to documented rules: by default, the largest discount wins per line, and the selectionStrategy: ALL operations can stack additively. Migrating multiple stacked Scripts to multiple stacked Functions can change the final price. Test every combination.
The shopify scripts api customer tag access vs Function tag access
Scripts could read Input.cart.customer.tags directly. Functions access tags via the GraphQL input, but the input has to be configured to include them. Defaults vary by Function type. Always check your input.graphql includes the tag query (customer { hasTag(tag: "...") } or customer { metafields { ... } }) before assuming the data is available.
Slack between deploy and admin propagation
There is a 30-to-60-second propagation delay between shopify app deploy completing and the Function being visible in the admin’s automation picker. Do not race the deploy. Wait, refresh, attach.
Performance Limits and the CPU Wall
Shopify Functions run inside a sandbox with hard CPU and memory limits, and they fail closed when you exceed them. This is a real production constraint that the Shopify Scripts API did not enforce visibly. We have shipped Functions to stores where the first production cart with 80 line items triggered a CPU-limit failure, dropped the discount, and the customer checked out at full price. That is a worse failure mode than a slow Script; with Scripts you might notice latency, with Functions you silently lose the discount.
The practical limits as of 2026: Functions get roughly 11 million instructions per invocation and need to return in single-digit milliseconds against the worst-case cart. The exact figures move with Shopify’s runtime updates, so check the current docs before you ship. What matters is the order of magnitude: a Function that allocates large vectors, iterates over the full cart times the full product catalog, or does complex string parsing is on the wrong side of the limit.

The patterns we have used to stay under the limit:
- Profile against your worst-case cart, not your average cart. Build a fixture for the biggest cart you have ever seen in production (we have seen merchants with 200-line-item B2B reorders). Run the Function against it. If you are at 70% of the CPU budget on that fixture, you have no headroom for next quarter’s growth.
- Avoid nested iteration over the cart. A loop over
cart.linesinside a loop overcart.linesis O(n^2) against cart size. With 100 lines, that is 10,000 iterations. Refactor to a single pass with a hashmap lookup. - Move expensive computation out of the Function. If your Discount Function needs a price list that has 5,000 SKUs, do not load all 5,000 SKUs into the Function input. Use the Function to look up by SKU into a metafield-backed price list configured per-Company.
- Prefer Rust over JavaScript for large carts. Rust’s compiled WASM is materially faster than the JS engine’s WASM for the same logic. We have seen 3x to 5x speedups on cart-heavy workloads when porting from JS to Rust.
The edge case that bites: B2B carts at the largest customer tier are 5 to 20x bigger than retail carts. If you only test against retail-sized fixtures, the first B2B cart through the migrated Function might be the one that hits the wall. We have audited two stores where the post-migration CPU failure surfaced specifically on the largest B2B reorder, and the failure was invisible because the customer’s reorder still completed (at full price, no discount, looking exactly like a normal checkout to anyone not auditing line-item math).
When Functions are not the right answer
Three situations where the migration target should not be a Function:
- You need network calls. Functions cannot make HTTP requests. If your Script called an external tax-ID validator, a fraud-score API, or a third-party loyalty system, that logic moves to a Checkout UI Extension (which can call your app’s backend), not a Function.
- You need persistent state between invocations. Functions are stateless. State for counters, accumulators, or session-like data lives on the customer’s metafields or in your app’s backend, not in the Function.
- The Script was already at the Plus checkout scripts edge layer. If you were using Plus Checkout Scripts (the deeper customization layer that piped through
additional_checkout_scripts), the migration is to Checkout Extensibility, not Functions. Most teams discover this when they try to migrate UI customizations into a Discount Function and find no UI surface there.
What This Looks Like as a Paid Engagement
If you would rather not do this yourself, the Shopify Teardown is built for exactly this scope: 30 calendar days, fixed price, find $75,000/year in recoverable revenue or refund. The first 10 days are the audit and the migration plan. Days 11 to 30 are the actual Function port, Checkout Extensibility migration where needed, test matrix, and cutover.
Frequently Asked Questions
- Can I write Functions in Python or TypeScript?
TypeScript yes (it compiles to JS for the JS target). Python no. The two supported source languages are Rust (recommended) and JavaScript/TypeScript. - Do Functions count against my Shopify Plus subscription?
No additional Shopify fee. You pay for development. If you build in-house, the Shopify marginal cost is zero. - Can a single app host multiple Functions?
Yes. We usually put related Functions (e.g. all discount-related Functions) in one app, and unrelated ones (e.g. payment customizations) in a sibling app. Easier to reason about scopes and permissions. - Is there a Shopify scripts api to Function transpiler?
No. We have not seen one that works. The semantic gap is too large. Plan for a manual port. - What happens if my Function exceeds the CPU limit?
The Function fails to execute, Shopify logs the error, and the surface (discount, shipping, payment) falls back to whatever native behavior would otherwise apply. Catch this in testing. Functions with large iteration counts or complex string parsing are the usual offenders. - Can I keep my old shopify checkout scripts past June 30 if I am willing to pay extra?
No. There is no paid extension. The deprecation applies to all Plus stores regardless of tier or contract. - Where do I find the official Shopify Functions docs?
shopify.dev/docs/apps/functions. The reference for input/output schemas per Function target is the most useful page. Bookmark it.


