A Shopify dead stock report in n8n tells you which products are quietly eating your cash: units sitting in the warehouse that nobody has bought in weeks. Shopify shows you what sells, but it has no built-in view for what does not. This guide builds a free workflow that runs every week, cross-checks your full catalog against the last 60 days of orders, flags every variant that still has inventory but zero sales, and emails you a ranked digest plus a running log in Google Sheets. The build takes about 30 minutes, or a couple of minutes if you import the ready-made template at the end.
What it does
The workflow answers one uncomfortable question on a schedule: which SKUs am I paying to store while they earn nothing? Every Monday morning it pulls your entire product list and every order from the last 60 days, matches them up, and produces a list of variants that have stock on hand but made no sales in that window.
Each flagged row includes the product name, variant, SKU, units on hand, units sold in the window (zero, by default), and the capital tied up in that stock (unit price times quantity). The list is sorted so the most expensive dead stock sits at the top, then it lands in your inbox as a clean table and appends to a Google Sheet so you can watch the trend over time.
Because it reads live Shopify data through the Admin API, there is nothing to maintain. If a slow product finally sells, it drops off next week’s report on its own. If a new product stalls, it appears. To connect n8n to your store first, follow connect Shopify to n8n (2026 method), which covers the current Dev Dashboard credential flow.
Why it beats the default
Shopify’s own reporting is built around sales. The ABC analysis and sell-through reports rank what moves; nothing in the admin surfaces the inverse, the products that are not moving at all. To find dead stock by hand you would export products, export orders, line them up in a spreadsheet, and subtract, which is exactly the kind of chore that gets done once and then never again.
Third-party inventory apps do offer dead stock reports, but they charge a monthly fee, want broad access to your store, and lock the logic inside their dashboard. This workflow keeps the definition of dead stock in your hands: 60 days and zero sales by default, but a two-number edit away from a 90-day, slow-moving definition. It runs on n8n you already control, writes to a Sheet you own, and costs nothing beyond the schedule it runs on. For the wider picture of what else you can automate on a store, see the pillar guide on n8n Shopify automation.
What you need
- An n8n instance (Cloud or self-hosted, version 1.0 or newer).
- A Shopify store with Admin API access, connected to n8n with an access token. Admin API version 2026-04 is current.
- A Google account for Google Sheets, and a Gmail account for the digest email.
- A blank Google Sheet with a header row (the workflow appends to it).
Time: about 30 minutes from scratch, or under 5 minutes if you import the template and fill in your credentials.
Node-by-node list
Seven nodes, one clean line with a small fork at the end so the report goes to two places at once.
Weekly schedule (Mon 7am)
|
Get products ──► Get orders (last 60 days)
|
Find dead stock (Code: join + flag)
| |
Save to Google Sheets Build email summary
|
Send email digest (Gmail)
- Weekly schedule (
Schedule Trigger) fires every Monday at 7am. - Get products (
Shopify) pulls the full catalog with all variants. - Get orders (last 60 days) (
Shopify) pulls every order created in the window. - Find dead stock (
Code) joins the two data sets and outputs one row per dead variant. - Save to Google Sheets (
Google Sheets) appends every flagged row to your log. - Build email summary (
Code) rolls the rows into one HTML table. - Send email digest (
Gmail) emails you the summary.
Step-by-step build
1. Add the weekly schedule
Add a Schedule Trigger. Set the interval to Weeks, every 1 week, trigger day Monday, at hour 7. Monday morning means the report is waiting when you plan the week. You can switch it to daily while you test, then move it back to weekly once you trust the output.
2. Get products
Add a Shopify node, resource Product, operation Get All, and turn on Return All. This returns every product, each with its full variants array. Every variant carries the two fields the report depends on: inventory_quantity and sku.
Tip: Leave the filters empty. You want the whole catalog so that products with zero sales are included; a sales filter here would hide exactly what you are hunting for.
3. Get orders from the last 60 days
Add a second Shopify node, resource Order, operation Get All, Return All on. Under Filters, set Created At Min to an expression and Status to any:
={{ $now.minus({ days: 60 }).toISO() }}
In this node’s Settings tab, turn on Execute Once. The products node emits many items; without Execute Once, the orders node would run once per product. Execute Once makes it fetch the order history a single time, which is both correct and far faster.
Note: Status any includes open, closed, and cancelled orders. Cancelled orders still show real demand, so counting them keeps a product that sold and was refunded from being mislabeled as dead.
4. Find dead stock (the Code node)
Add a Code node. It reads both Shopify nodes by reference, sums sales per SKU (with variant ID as a fallback), and outputs one item per variant that still holds stock but sold nothing.
const DAYS = 60;
const SALES_THRESHOLD = 0; // units sold at or below this = dead
const products = $('Get products').all().map(i => i.json);
const orders = $('Get orders (last 60 days)').all().map(i => i.json);
const soldBySku = {};
const soldByVariant = {};
for (const order of orders) {
for (const li of (order.line_items || [])) {
const qty = Number(li.quantity) || 0;
if (li.sku) soldBySku[li.sku] = (soldBySku[li.sku] || 0) + qty;
if (li.variant_id != null) soldByVariant[li.variant_id] = (soldByVariant[li.variant_id] || 0) + qty;
}
}
const reportDate = new Date().toISOString().slice(0, 10);
const rows = [];
for (const p of products) {
for (const v of (p.variants || [])) {
const onHand = Number(v.inventory_quantity) || 0;
if (onHand <= 0) continue;
const sold = (v.sku && soldBySku[v.sku] != null) ? soldBySku[v.sku] : (soldByVariant[v.id] || 0);
if (sold > SALES_THRESHOLD) continue;
const price = Number(v.price) || 0;
rows.push({
report_date: reportDate,
product: p.title,
variant: v.title === 'Default Title' ? '' : v.title,
sku: v.sku || '',
on_hand: onHand,
units_sold_60d: sold,
unit_price: price,
tied_up_value: Math.round(price * onHand * 100) / 100
});
}
}
rows.sort((a, b) => b.tied_up_value - a.tied_up_value);
return rows.map(r => ({ json: r }));
A single flagged row looks like this:
{
"report_date": "2026-08-24",
"product": "Cedar Camp Mug",
"variant": "12 oz / Slate",
"sku": "CCM-SLT-12",
"on_hand": 84,
"units_sold_60d": 0,
"unit_price": 18.00,
"tied_up_value": 1512.00
}
Tip: To catch slow movers rather than only stone-dead stock, raise SALES_THRESHOLD to 2 and DAYS to 90. Now anything that sold two or fewer units in 90 days is flagged.
5. Save to Google Sheets
Add a Google Sheets node, operation Append Row. Pick your document and sheet, and set the mapping mode to Map Automatically. Because the Code node’s field names (report_date, product, sku, and so on) match your header row, each field drops into the right column with no manual mapping. Appending rather than overwriting builds a history you can chart later.
6. Build the email summary
Add a second Code node. It takes all the flagged rows and collapses them into a single item holding one HTML table, so the next node sends one email instead of one per row.
const items = $input.all().map(i => i.json);
if (items.length === 0) return [];
const count = items.length;
const reportDate = items[0].report_date;
const totalValue = items.reduce((s, r) => s + (Number(r.tied_up_value) || 0), 0);
let rowsHtml = '';
for (const r of items.slice(0, 100)) {
rowsHtml += '<tr><td>' + r.product + '</td><td>' + r.sku +
'</td><td>' + r.on_hand + '</td><td>' + Number(r.tied_up_value).toFixed(2) + '</td></tr>';
}
const html = '<h2>Dead stock report - ' + reportDate + '</h2>' +
'<p>' + count + ' SKUs are holding stock but sold nothing in 60 days. ' +
'Capital tied up: ' + totalValue.toFixed(2) + '.</p>' +
'<table><tr><th>Product</th><th>SKU</th><th>On hand</th><th>Value</th></tr>' + rowsHtml + '</table>';
return [{ json: { subject: 'Dead stock report: ' + count + ' SKUs (' + reportDate + ')', html } }];
7. Send the digest
Add a Gmail node, operation Send. Set the recipient to your address, set Email Type to HTML, and map the subject and message to the fields from the previous node:
Subject: ={{ $json.subject }}
Message: ={{ $json.html }}
Save the workflow and toggle it Active. When there is no dead stock, the summary node returns nothing and no email is sent, so a quiet inbox means a healthy catalog.
Common mistakes
- Skipping Execute Once on the orders node. Without it, the orders pull runs once per product and the workflow crawls. Turn it on in the node’s Settings tab.
- Filtering products by inventory in the Shopify node. The point is to see everything and let the Code node decide. Filter early and you hide the stock you are looking for.
- Header row that does not match. Automatic mapping in Google Sheets keys off exact column names. If your header says
Product Namebut the field isproduct, that column stays blank. Match them. - Reading
inventory_quantityas reliable across locations. This field is the total across locations. If you run multiple warehouses and need a per-location view, that is a different report (see the multi-location guide below). - Forgetting cancelled orders. Setting Status to
anymatters. Leave it at the default and a refunded sale can make a real seller look dead.
Cost at realistic volume
This workflow uses no paid AI and no premium services, so the running cost is essentially zero. A weekly run makes two Shopify API calls (paginated), one Google Sheets append, and one Gmail send.
| Service | Usage per run | Cost |
|---|---|---|
| Shopify Admin API | 2 paginated pulls | Free (within rate limits) |
| Google Sheets | 1 append (many rows) | Free |
| Gmail | 1 email | Free |
| n8n | 1 execution / week | Free self-hosted; ~4 executions/month on Cloud |
On n8n Cloud a weekly schedule costs about four executions a month, a rounding error against the tier you are already paying for. Self-hosted, it is free. The real return is the capital you free up by spotting dead stock early and clearing it before it ages further.
Get the dead stock report template
The full guide above is free to follow. If you would rather skip the build, the ready-to-import template drops all seven nodes and both Code scripts straight onto your canvas. Import it, add your Shopify, Google Sheets, and Gmail credentials, and your first report can run today. Prefer it done for you? Our done-for-you service installs and tailors it to your store.
Instant download · Works on n8n Cloud and self-hosted
Frequently asked questions
What counts as dead stock in this workflow?
A variant is flagged when it still has inventory on hand and sold zero units in the last 60 days. Both the window and the sales threshold are single numbers at the top of the Code node, so you can loosen the definition to slow-moving stock in one edit.
Will the report double-count variants sold under different SKUs?
No. Sales are summed by SKU first and by variant ID as a fallback, so each variant is matched to its own orders. Variants with a blank SKU still match on variant ID, which is how Shopify links a line item back to the exact variant.
Does it work with a large catalog?
Yes. Both Shopify pulls use Return All, so the workflow paginates through every product and every order in the window. For very large stores the run takes longer, but it stays inside the Admin API rate limits because n8n throttles the paged requests automatically.
Can I send the report to Slack or Telegram instead of email?
Yes. The Build email summary node produces a single item with the table already built, so you can swap the Gmail node for a Telegram or Slack node and point it at the same field. The Google Sheets branch stays exactly as it is.
How is dead stock different from a low-stock alert?
A low-stock alert fires when you are about to run out of a product that sells. A dead stock report does the opposite: it finds products you have too much of because nobody is buying them. The two reports use the same data but flag opposite problems.
Related guides
- n8n Shopify automation — the full pillar guide to automating a store.
- Shopify multi-location inventory report — stock levels broken out per warehouse.
- Shopify weekly best-sellers report — the mirror image, your fastest movers.
- Browse all n8n templates for more ready-to-import workflows.
- More Shopify automation guides.