# Shipping Shopify Functions in Rust: a complete tutorial

- URL: https://sevsolutions.com/blog/shopify-functions-rust-tutorial
- Published: 2025-09-22T17:26:36.000Z
- Updated: 2026-10-27T12:00:00.000Z
- Category: Shopify Plus builds
- Tags: Rust, Shopify Functions, Shopify Plus, WebAssembly

A hands-on guide for developers, from six Rust Functions in production: when Rust beats JavaScript for checkout rules, the project setup, the settings that keep the file small, the traps nobody documents, and the speed data behind the extra build work.

## The short version

- This is a hands-on guide for developers. It shows how to write Shopify Functions in the Rust programming language. Functions are small programs that add your own rules to Shopify's checkout.
- What it means for your store: most Functions work fine in JavaScript. Rust pays off for complex rules and large carts. Shopify caps the work each run can do, and the same Function in JavaScript uses much more of that budget.
- The trade-off is a slower, more involved build. For simple rules, JavaScript is the right call.
- Is a Function on your store hitting Shopify's limit, or do you need complex pricing rules? [Talk to us](https://sevsolutions.com/contact). The build work sits under our [app development services](https://sevsolutions.com/services/app-development).

Sometimes a store needs a checkout rule that Shopify doesn't offer, such as wholesale pricing beyond Shopify's built-in quantity price breaks. A developer then builds it as a Shopify Function, a small program that runs inside Shopify's checkout. The Functions runtime accepts WebAssembly (Wasm), a code format that many programming languages can compile to. Many teams write Functions in JavaScript, because Shopify ships a JS template and the language is familiar, but Shopify's docs now strongly recommend Rust as the most performant choice, so a Function doesn't fail on a large cart. JavaScript works fine for simple Functions, and we cover the JS path in the [discount API deep dive](https://sevsolutions.com/blog/shopify-functions-discount-api-deep-dive). This tutorial is the Rust path: when Rust pays off, how the project actually looks, and the gotchas the Rust template doesn't document.

We have shipped six Rust Functions in production over the last 18 months. Two run discount logic for B2B Plus stores with complex tiered pricing. Two are cart-validation Functions (checks that stop an order that breaks a rule) that had to run in under a millisecond. Two are Payment Customization Functions (which change the payment options a buyer sees) with intricate routing rules. Each one paid back the extra build complexity within the first quarter. Here is what we learned.

## Why Rust over JavaScript

Three things to weigh, in the order they show up.

The first is binary size, and it doesn't favor Rust. The compiled Wasm file of a Shopify Function must be under 256kB. A JavaScript Function's module holds just your code: it doesn't include a JavaScript engine, because Shopify links it to the Javy engine at runtime. A Rust Function compiles everything it uses into the file, so it needs the size-optimized release profile from Shopify's templates (below).

The second is execution speed at P95-P99, the slowest 5% and 1% of runs. Both runtimes execute fast at the median, the typical run. The tail latency, how long those slowest runs take, differs. We measured P99 latency on a complex tier-pricing Function: Rust at 7.8ms, JavaScript at 22ms. Shopify's budget is an instruction count: up to 11 million instructions for carts with up to 200 line items, more for larger carts, and a Function that goes over is stopped with an `InstructionCountLimitExceededError`. A JavaScript Function executes significantly more instructions than the equivalent Rust one, and Shopify strongly recommends Rust as the most performant language choice for large carts. Rust gives you headroom there.

![Cold-start latency of a Rust Function by percentile: 1.8 ms at P50, the median run, rising to 7.8 ms at P99, the slowest 1% of runs.](https://sevsolutions.com/posts/shopify-functions-rust-tutorial-cold-start.svg)

Figure: Production data from a tier-pricing Function. JS equivalent: P50 ~4ms, P99 ~22ms

The third is type safety. Discount Function inputs are large GraphQL-shaped JSON objects: structured data in the shape of a query written in GraphQL, the query language of Shopify's APIs. The fields are nested, optional, sometimes ambiguously typed (Money values come as strings, dates come as ISO strings, IDs are opaque). JavaScript leaves it up to you to remember which fields are nullable. Rust forces an explicit `Option<T>` everywhere, and the compiler refuses to let you ignore it. We have seen production bugs in JS Functions where a missing currency field crashed the discount silently; we have not seen the equivalent bug in our Rust Functions. The compiler caught them in dev.

The honest counterargument: build complexity. A clean release build of a Rust Function takes us 60-90 seconds (vs 2 seconds for JS). The local toolchain needs the `wasm32-unknown-unknown` target installed. The CI/CD setup, the automated build and deploy, is more involved. For a Function with simple logic that stays well inside the instruction limit, JS is the right call.

## Project layout

A Rust Function in our typical project structure:

```text
discount-bogo/
├── Cargo.toml
├── shopify.extension.toml
├── schema.graphql            ← Shopify schema (Shopify CLI)
├── src/
│   ├── main.rs               ← typegen module + the Function
│   └── cart_lines_discounts_generate_run.graphql  ← input query
└── tests/
    └── fixtures/
        ├── cart-with-bogo.json
        ├── cart-empty.json
        └── ...
```

The Cargo.toml, the project's manifest with its name, dependencies and build settings, is the most important file. Three things to get right:

```toml
[package]
name = "discount-bogo"
version = "1.0.0"
edition = "2021"

[dependencies]
shopify_function = "2"

[profile.release]
opt-level = "z"  # size-optimized
lto = true
codegen-units = 1
strip = true
panic = "abort"
```

Shopify's templates set `opt-level`, `lto` and `strip`; `codegen-units` and `panic` are our additions.

The package `name` matters more than it looks: the build `path` in `shopify.extension.toml`, `target/wasm32-unknown-unknown/release/discount-bogo.wasm`, is named after it, so rename one and you have to rename the other.

The release profile flags compound. `opt-level = "z"` optimizes for size (vs the default `"3"` which optimizes for speed); `lto = true` enables link-time optimization; `codegen-units = 1` disables parallel code generation in favor of fewer, more aggressive optimizations; `strip = true` removes debug symbols; `panic = "abort"` ends the run on a panic (more below). The chart further down shows what the size steps did to our reference Function.

The dependencies stay minimal: `shopify_function` (the official crate; a crate is a Rust library) is the only one in the Discount Function template from Shopify CLI, Shopify's command-line tool, and it brings its own `Deserialize` derive for a JSON metafield configuration (a metafield is a custom field stored in Shopify). Avoid heavy dependencies — every transitive crate adds wasm size. We try to keep the crate count under 15 for a typical Discount Function.

## The shopify-function crate

The `shopify_function` crate handles the input deserialization and output serialization. The basic shape:

```rust
use shopify_function::prelude::*;
use shopify_function::Result;
use std::process;

#[typegen("schema.graphql")]
pub mod schema {
    #[query("src/cart_lines_discounts_generate_run.graphql")]
    pub mod cart_lines_discounts_generate_run {}
}

#[shopify_function]
fn cart_lines_discounts_generate_run(
    input: schema::cart_lines_discounts_generate_run::Input,
) -> Result<schema::CartLinesDiscountsGenerateRunResult> {
    // Your logic here
    Ok(schema::CartLinesDiscountsGenerateRunResult { operations: vec![] })
}

fn main() {
    log!("Please invoke a named export.");
    process::abort();
}
```

The `#[shopify_function]` macro exports a Wasm function named after the Rust function (the name must match the target's `export` in `shopify.extension.toml`) and reads the input and writes the output through the Shopify Functions WebAssembly API. The `#[typegen]` macro generates the `schema` module's types from `schema.graphql` and your input query at build time — they are not types you write by hand.

The codegen step is the part that surprises Rust developers used to typed APIs. You write a GraphQL query in `src/cart_lines_discounts_generate_run.graphql`. The `typegen` macro runs at compile time, parses the query against the Shopify schema, generates Rust types, and makes them available as `schema::cart_lines_discounts_generate_run::Input`. Change the query → regen the types. The compiler then forces you to update your function logic to match.

This is the type-safety win in practice. A typo in your query, or a field your copy of the schema doesn't have, is caught at compile time, not at deploy time, not in production.

The trap: `schema.graphql` doesn't come from the [shopify-function crate](https://crates.io/crates/shopify_function). Each function gets a copy when you create it, and `shopify app function schema` writes the latest one for the API version in `shopify.extension.toml`. Bump `api_version` without running it again and your types still describe the old API. We pin the crate's major version (`"2"`), since updating to its 1.0 and 2.0 releases each took code changes, and run weekly dependency updates.

## Local testing harness

The same vitest-vs-CLI tradeoff applies in Rust (vitest is the JavaScript test tool from the discount API deep dive). The official `shopify app function run` command runs the Wasm module you've already built, so every change needs a build before you can run it (see the build times below). The fast path is `cargo test` with JSON fixtures, saved sample inputs: it tests the logic without building the Wasm file.

Our pattern:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use shopify_function::{run_function_with_input, Result};

    #[test]
    fn bogo_two_eligible_one_discounted() -> Result<()> {
        let result = run_function_with_input(
            cart_lines_discounts_generate_run,
            include_str!("../tests/fixtures/cart-with-two-bogos.json"),
        )?;
        assert_eq!(result.operations.len(), 1);
        Ok(())
    }
}
```

The `include_str!` macro embeds the JSON fixture at compile time, no filesystem I/O at test runtime. `cargo test` runs in 1-2 seconds for a typical Function.

The fixtures themselves come from one of two places. Either you write them by hand (tedious but precise), or you capture them from real runs: while `shopify app dev` runs, Shopify CLI saves each run on your dev store as a log file in your app's `.shopify/logs` folder, input included, and `shopify app function replay` reruns your Function on one of them. We do both — hand-written fixtures for unit-level cases (empty cart, single line, edge values) and captured fixtures for end-to-end smoke tests against representative real carts.

## Deploying

`shopify app deploy` is the one-line answer. Behind the scenes:

The Shopify CLI runs the build command in `shopify.extension.toml`, `cargo build --target=wasm32-unknown-unknown --release`. The output Wasm goes to `target/wasm32-unknown-unknown/release/discount-bogo.wasm`. The CLI then creates an app version, a snapshot of your app configuration and all its extensions, and releases it to your app's users. Deploying doesn't create the discount: you create it with the `discountAutomaticAppCreate` mutation, which links it to the Function by its handle.

Two operational notes.

The CLI deploys to the app your configuration is linked to, not to a store. If you keep more than one app configuration (a staging app and a production app, say), name the one you mean with `--config`, or you'll release to the wrong app.

A deploy releases every extension in the app together. If your app has three Functions and you deploy after only updating one, the other two redeploy as well. This is normally fine but can cause function execution metrics to reset (Shopify treats it as a new version), which makes performance comparisons noisy. Deploy intentionally.

## Wasm size optimization in detail

![Wasm file size, compressed, after each Cargo profile setting: 1,240 kB for a debug build, 412 kB for a basic release build, then 198 kB with opt-level z, 148 kB with lto and 128 kB with strip.](https://sevsolutions.com/posts/shopify-functions-rust-tutorial-size-optimization.svg)

Figure: Same Function, identical behavior. Each step is a Cargo profile flag

Each optimization is a single Cargo profile flag and they compound:

The `opt-level = "z"` change is the biggest single drop — from 412kB to 198kB gzipped on our reference Function. Rust's "z" optimizes ruthlessly for binary size, including inlining decisions, loop unrolling avoidance, and dead-code elimination. The runtime is slightly slower but for Function workloads (simple logic), the speed cost is unmeasurable.

The `lto = true` step adds link-time optimization. Cross-crate inlining gets aggressive; unused code paths in dependencies get stripped. ~50kB saved on a typical Function.

The `strip = true` step removes the symbol table from the binary. Strictly debugging information; doesn't affect runtime. ~20kB saved.

The `panic = "abort"` step goes beyond Shopify's templates, whose release profile sets `lto`, `opt-level = "z"` and `strip`. Shopify Functions can't recover from a panic anyway (the Function execution is terminated and the discount silently fails).

The `codegen-units = 1` flag is the slowest of the optimizations to compile but produces the smallest output. Default Rust uses parallel codegen with 16 units; setting it to 1 forces single-threaded compilation but enables more aggressive optimization across the entire crate. Recommended only for release builds; debug builds should stay at the default.

The runtime cost of all this size optimization: Rust's compiler is slow with `lto + opt-level=z`. A typical clean release build takes 60-90 seconds for our Functions. Incremental builds are much faster (5-10 seconds for typical changes), but CI builds always start clean.

## The gotchas nobody documents

Five things that bit us in production and weren't in any tutorial:

The first is `std::time`. Rust's standard library `Instant::now()` and `SystemTime::now()` panic on the wasm32-unknown-unknown target because there's no platform clock. The Shopify Function input includes `shop.localTime`, the current time in the store's timezone: query its `date`, or ask `dateTimeBetween` whether now falls inside a window, and use that. If you genuinely need wall-clock time for some calculation, you have to take it as input — Wasm without WASI has no way to read a clock.

The second is randomness. `std::random` and most random-number crates panic the same way. The fix is the same — take the seed as input from the Function's metafield configuration, or use a deterministic ID-based hash for "random" selection in your discount logic. Real randomness is not available without [WASI preview2](https://github.com/WebAssembly/WASI/blob/wasi-0.2/docs/Preview2.md), which Shopify Functions don't currently expose.

The third is the JSON schema mismatch trap. The `typegen` macro builds Rust types from your `cart_lines_discounts_generate_run.graphql` query against `schema.graphql`, and a field that isn't in that schema (a typo, say) fails the build. A null doesn't: if the query asks for a field that's optional and the cart has a null for that field, the input still deserializes, the field reads as `None`, and if your logic only acts on `Some`, the discount silently doesn't apply. Keep a test fixture with a null in every optional field your logic reads.

The fourth is `unsafe` blocks. The `shopify_function` crate uses `unsafe` internally for the Wasm export glue. You should not use `unsafe` in your Function logic. If you find yourself writing `unsafe`, you're solving the wrong problem. Real Discount Functions are pure data transformations; they don't need raw pointer manipulation.

The fifth is observability. You can't debug a Function by printing to stdout or stderr. The crate's `log!` macro writes to your Function's logs instead (in 2.x it replaces `eprintln!`), and Shopify truncates what one run logs at 1 kB, so log the decision your Function made, not its whole input.

## Production observability

Once a Rust Function is deployed, the production observability picture:

Shopify keeps a record of every Function run in your app's Dev Dashboard: when it ran and on which shop, its instruction count against the limit that applied, and the error if it failed. Useful for catching regressions.

Your app's Dev Dashboard lists each run under Logs, with its input, its output and what it wrote with `log!`, where the merchant's access scopes allow. The pattern we use for production debug: write a JSON-stringified debug payload with `log!` only when an unexpected condition is hit (cart with negative pricing, missing customer, schema field unexpectedly null), and parse it out of the logs in our weekly review. Logs are truncated after 1 kB, so keep it short.

For latency tracking specifically, a timer has no clock to read on this target (the `std::time` gotcha above), so count instructions: every instruction a Function executes adds checkout latency. The Dev Dashboard shows each run's instruction count against its limit, and `shopify app function run` reports it locally under Benchmark Results.

The "it crashed silently" debug guide for Functions: when the Function's discount doesn't fire and you can't tell why, check three places. (1) The Function's runs in your app's Dev Dashboard — a panic fails the run, and the failed run shows its error and logs. (2) The order's discount applications after purchase — they list the discounts that actually applied. (3) The merchant's discount admin — sometimes the Function is registered but the merchant accidentally disabled the discount. We have hit all three on real production debugging sessions.

For the JavaScript-side companion to this Rust path — when the build complexity of Rust isn't justified — the [discount API deep dive](https://sevsolutions.com/blog/shopify-functions-discount-api-deep-dive) covers the same Function patterns from the JS angle. For the B2B-specific patterns that often justify Rust, [B2B checkout extensions guide](https://sevsolutions.com/blog/shopify-b2b-checkout-extensions-2026) covers the per-account pricing and Net-30 logic that pushes Function complexity past the JS sweet spot.

If you have a Function in JavaScript that's running into its instruction limit and you want a port to Rust, [talk to us](https://sevsolutions.com/contact). A typical port takes 2-4 weeks for a single Function — most of the time goes to the test fixtures, not the Rust itself. We package this under [app development services](https://sevsolutions.com/services/app-development) and ship the running Rust Function plus the vitest-equivalent cargo test suite, so your team can maintain it after handoff.
