New to n8n? Start with our step-by-step setup guide and the 2026 Shopify connection guide, then come back to build this workflow.
A Shopify bulk price update from Google Sheets with n8n turns your sale-prep spreadsheet into the thing that actually changes prices. You keep one sheet of variant IDs and new prices, flip an “apply” column to yes, and run the workflow. n8n groups the rows by product, calls the Shopify GraphQL Admin API once per product, logs every result to a second tab, and emails you a summary. Setup takes about 30 minutes and costs nothing beyond your own n8n instance.
What this workflow does
Every store hits the same week twice a year. Black Friday is coming, or the season is turning, and 200 variants need new prices by Friday. Shopify’s bulk editor handles it, but it is a grid you scroll sideways forever, with no dry run, no record of what changed, and no way to hand the price list to someone else for approval.
This workflow moves the decision into a Google Sheet and leaves execution to n8n. The sheet holds one row per variant: product ID, variant ID, the SKU so a human can read it, the new price, an optional compare-at price for the strike-through, and an apply column that acts as a safety switch. Rows without yes in apply are ignored.
When you run it, n8n reads the sheet, drops the rows you did not approve, groups the remaining variants by parent product, and sends one GraphQL mutation per product. Shopify accepts up to 250 variants in a single productVariantsBulkUpdate call, so a 300-variant repricing across 90 products becomes 90 API calls, not 300. Every result lands in a log tab with a timestamp and rolls into an email telling you which products succeeded and which did not.
Why it beats the default
The Shopify bulk editor is fine for ten variants. Past that it starts costing you in ways that are hard to see until something goes wrong.
A sheet gives you review before execution. Your buyer or your accountant can compare the price column against cost, and put yes in the apply column only for the rows they are confident about. Half the sheet ships, half waits. The bulk editor has no concept of a pending change.
A sheet gives you a rollback. Add an old_price column, fill it once from your product export, and if the sale prices were wrong you swap the two columns and re-run. Undoing a bulk edit in the admin means recovering the old values from a backup you probably did not take.
A sheet gives you an audit trail. The log tab records the product ID, how many variants were sent, whether Shopify accepted it, the error text if it did not, and when. Six months later, when someone asks why this SKU was 19.99 in November, the answer is a row, not a guess. A sheet is also delegable: hand the link to a merchandiser who has never opened the admin, they fill in prices, you press run.
What you need
- A self-hosted or cloud n8n instance. Most stores running this self-host, because the workflow writes directly to product data.
- A Shopify custom app created through the Dev Dashboard, with the
write_productsandread_productsAdmin API scopes. The 2026 Shopify to n8n connection guide walks through the current method. The old admin-side custom app flow no longer exists. - A Google Sheets OAuth2 credential in n8n, plus a Gmail credential for the summary email. Any SMTP node works instead of Gmail.
- A Google Sheet with two tabs. Tab
prices:product_id,variant_id,sku,new_price,compare_at_price,apply. Tablog:product_id,variants_sent,status,message,run_at.
Populate the prices tab from any Shopify product export. The export gives you SKUs and current prices; the numeric variant IDs come from a products query against the Admin API.
Node-by-node list
Ten nodes. The trigger is manual on purpose: nobody wants a cron job quietly changing prices at 3am.
Run bulk update, a Manual Trigger. You press it when the sheet is ready.Read price sheet, a Google Sheets node in read mode pointed at thepricestab. Returns one item per row.Keep rows marked apply, a Filter node. Passes only rows whereapplyequals yes.Group variants by product, a Code node running once for all items. Converts the numeric IDs into Shopify global IDs, validates each price, and emits one item per product carrying an array of variant inputs.Loop over products, a Loop Over Items node with batch size 1. One product per iteration.Update variant prices, an HTTP Request node posting theproductVariantsBulkUpdatemutation to the GraphQL Admin API. Continues on error so one bad product cannot abort the run.Build log row, an Edit Fields node. Flattens the mutation response into the five log columns.Throttle, a Wait node set to one second, wired back into the loop.Log results to Sheets, a Google Sheets append on thelogtab, fed by the loop’s done output.Summarize runandEmail summary, a Code node that counts successes and failures, then a Gmail node that sends you the result.
Step-by-step build
1. Create the trigger and read the sheet
Add a Manual Trigger and name it Run bulk update. Connect it to a Google Sheets node named Read price sheet. Choose your document, select the prices sheet, and leave the operation on read with no filters. Execute it once and confirm you see one item per spreadsheet row. new_price may arrive as a string or a number depending on cell formatting; either is fine, the Code node normalizes it.
2. Filter to approved rows
Add a Filter node named Keep rows marked apply with one condition: string, {{ $json.apply }}, equals, yes. Turn on the case-insensitive option. This node is your safety switch. Clear the apply column and running the workflow does nothing at all.
3. Group the variants by product
Add a Code node named Group variants by product, mode “Run once for all items”. It rejects rows with a missing product ID or an unparseable price, converts numeric IDs to the gid://shopify/... form the GraphQL API expects, formats prices to two decimals, and only sets a compare-at price when it is higher than the new price, because Shopify rejects a strike-through cheaper than the price it strikes through.
const GQL = `mutation bulkPriceUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id price compareAtPrice }
userErrors { field message }
}
}`;
const groups = {};
for (const item of $input.all()) {
const row = item.json;
const pid = String(row.product_id ?? '').trim();
if (!pid) continue;
const price = Number(String(row.new_price ?? '').replace(',', '.'));
if (!Number.isFinite(price) || price <= 0) continue;
const variant = {
id: `gid://shopify/ProductVariant/${String(row.variant_id).trim()}`,
price: price.toFixed(2),
};
const compareAt = Number(String(row.compare_at_price ?? '').replace(',', '.'));
if (Number.isFinite(compareAt) && compareAt > price) {
variant.compareAtPrice = compareAt.toFixed(2);
}
(groups[pid] ||= []).push(variant);
}
return Object.entries(groups).map(([pid, variants]) => ({
json: {
gql: GQL,
productGid: `gid://shopify/Product/${pid}`,
productId: pid,
variants,
variantCount: variants.length,
},
}));
4. Loop one product at a time
Add a Loop Over Items node named Loop over products and set batch size to 1. Its “loop” output feeds the HTTP request. Its “done” output feeds the logging branch you build in step 8.
5. Send the GraphQL mutation
Add an HTTP Request node named Update variant prices on the loop output. Configure it exactly like this:
- Method: POST
- URL:
https://YOUR-STORE.myshopify.com/admin/api/2026-04/graphql.json - Authentication: Predefined Credential Type, then Shopify Access Token API, then pick the credential you created from your Dev Dashboard app.
- Send Body: on. Body Content Type: JSON. Specify Body: Using JSON.
- JSON body, as an expression:
{{ JSON.stringify({ query: $json.gql, variables: { productId: $json.productGid, variants: $json.variants } }) }}
Then open Settings on the node and set On Error to “Continue (using regular output)”. This matters. Without it, one product with a deleted variant kills the whole run at product 47 of 90, and you have no record of which ones already went through. Note also that Shopify returns HTTP 200 even for a rejected mutation. The failures live in data.productVariantsBulkUpdate.userErrors, which is why the next node reads that array rather than trusting the status code.
6. Flatten the response into a log row
Add an Edit Fields node named Build log row and add five assignments:
product_id, string,{{ $('Loop over products').item.json.productId }}variants_sent, number,{{ $('Loop over products').item.json.variantCount }}status, string,{{ $json.data?.productVariantsBulkUpdate && $json.data.productVariantsBulkUpdate.userErrors.length === 0 ? 'ok' : 'error' }}message, string,{{ ($json.data?.productVariantsBulkUpdate?.userErrors ?? []).map(e => e.message).join('; ') || ($json.errors ?? []).map(e => e.message).join('; ') }}run_at, string,{{ $now.toISO() }}
Reaching back to $('Loop over products') instead of reading the HTTP response is deliberate: when a call fails outright, the response carries no product ID at all.
7. Throttle and close the loop
Add a Wait node named Throttle, set to 1 second, and connect it back into the input of Loop over products. Shopify’s GraphQL Admin API uses a leaky-bucket cost model rather than a simple request cap, and a variants mutation is cheap, so one second per product leaves a lot of headroom. A 90-product run finishes in about a minute and a half.
8. Log everything and email yourself
From the loop’s done output, add a Google Sheets node named Log results to Sheets. Operation: append row. Sheet: log. Mapping: map automatically from input, since the Edit Fields node already produced matching column names. After it, add a Code node named Summarize run, mode “Run once for all items”:
const rows = $input.all().map(i => i.json);
const ok = rows.filter(r => r.status === 'ok');
const failed = rows.filter(r => r.status !== 'ok');
const variants = ok.reduce((n, r) => n + Number(r.variants_sent || 0), 0);
return [{
json: {
subject: `Shopify repricing: ${ok.length} products updated, ${failed.length} failed`,
html: `<p>Products updated: ${ok.length}</p>`
+ `<p>Variants repriced: ${variants}</p>`
+ `<p>Failed: ${failed.length}</p>`
+ (failed.length
? `<ul>${failed.map(r => `<li>${r.product_id}: ${r.message}</li>`).join('')}</ul>`
: ''),
},
}];
Finish with a Gmail node named Email summary, operation send, To set to your address, Subject {{ $json.subject }}, Message {{ $json.html }}, email type HTML.
9. Do a dry run
Before pointing this at 300 rows, put yes in the apply column for exactly one cheap product. Run it, check the product in the Shopify admin, check the log tab, check the email. Then widen.
Common mistakes
Using SKUs instead of variant IDs
The mutation takes variant global IDs, not SKUs. A SKU is a label you chose; the variant ID is Shopify’s primary key. Keep the SKU column so humans can read the sheet, but never send it.
Sending more than 250 variants for one product
productVariantsBulkUpdate caps at 250 variants per call. Almost no product has that many, but if one of yours is that configurable, chunk the variants array inside the Code node and emit two items for that product.
Setting a compare-at price below the new price
Shopify rejects it, and the product’s whole mutation fails rather than half-applying. The Code node guards against this by only including compareAtPrice when it exceeds the price. To clear an old sale price deliberately, send compareAtPrice: null, not an empty string.
Trusting the HTTP status code
A GraphQL mutation that fails validation still returns 200. If your log tab is full of ok but nothing changed in the store, you are reading the status code instead of userErrors.
Reaching for the old REST variants endpoint
Older tutorials show a PUT to /admin/api/xxxx-xx/variants/123.json. Shopify moved product and variant writes to GraphQL, and that REST path is gone on current API versions. Copy an old guide and you get a 404, then blame your credential.
Comma decimal separators
In a European locale, 19,99 arrives as a string that Number() turns into NaN. The Code node replaces the comma before parsing and skips the row if it still cannot read a positive number. Watch for a log tab shorter than expected.
Cost at realistic volume
Nothing in this workflow has a per-use price attached. Self-hosted n8n on a small VPS runs about 5 to 10 USD a month, and that instance is already paying for every other workflow you run on it. If you are on n8n Cloud, one repricing run across 90 products consumes a single execution, because everything after the trigger happens inside one execution.
The Shopify Admin API is free and included with your plan. Google Sheets and Gmail are free at any volume a store will produce. There is no AI model here, so there is no token cost either.
The honest comparison is against the bulk-editing apps in the Shopify App Store, at 10 to 30 USD a month for scheduled price changes. This workflow does the scheduled part too: swap the Manual Trigger for a Schedule Trigger and add a run_on date column the Filter node checks against today. Most stores keep it manual, because the whole point of the sheet is that a person looked at it.
Get the ready-to-import template
If you would rather not wire ten nodes by hand, we build and test these workflows for stores that want them running the same day. See what we do, or browse the wider n8n Shopify automation collection.
FAQ
Where do I get the variant IDs for my sheet?
Run a one-off n8n workflow that queries the GraphQL products connection, pulls the product ID, variant ID, SKU and current price, and writes them straight into the prices tab. That becomes your starting spreadsheet, and you overwrite the price column each season. It takes three nodes and about ten minutes.
Can I schedule this instead of clicking run?
Yes. Replace the Manual Trigger with a Schedule Trigger, and add a date column to the sheet that your Filter node compares against today’s date. Be careful, though. A scheduled price change with nobody watching is how a misplaced decimal point becomes a very good day for your customers.
What happens if the workflow fails halfway through?
The products already processed keep their new prices and the rest stay untouched. Your log tab shows exactly which product IDs completed. Set the apply column back to no for those rows and re-run the remainder. The workflow is safe to run twice anyway, because setting a price to a value it already holds is a no-op.
Will this work for WooCommerce?
Yes, with a smaller build. Replace the Code node’s grouping logic with a plain pass-through, since WooCommerce updates one variation at a time, and point the HTTP Request at the wc/v3 product variations endpoint with a regular_price field. The sheet, the filter, the loop, the log and the email stay identical.
Does this update inventory or only price?
Only price and compare-at price. Inventory lives on a different object in Shopify’s data model and moves through inventoryLevels, so folding the two into one mutation is not possible. Keep repricing and restocking as separate workflows, which is what you want operationally in any case.
Related guides
Repricing pairs naturally with the other catalog jobs. See the bulk product upload from CSV build for getting products into the store in the first place, the auto-hide out-of-stock products workflow for keeping the storefront honest, and the weekly best sellers report for deciding which prices to change next. More builds live in Shopify and e-commerce.