Shopify zero-result searches: find the missed revenue with ShopifyQL, fix it natively, or hand it to Searchanise
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Shopify records every online store search with a search_results_were_returned flag, and since Admin API 2025-10 you can query it with ShopifyQL (FROM searches, read_reports scope). Pull the zero-result queries monthly, compare their words against your catalog's own vocabulary, and sort them: typos and missing synonyms go into the Search & Discovery app, combinations need merchandising, true gaps are a stocking or redirect decision. The script in this guide does the sorting and publishes reviewed redirects to a shop metafield your theme reads. If you'd rather not maintain that loop, Searchanise handles synonyms, typo suggestions, search redirects and no-result analytics as one app, from $16 a month billed yearly for up to 1,500 products.
A shopper who types into your search box has already told you what they want. When the answer is "No results found", most of them leave, and nothing in the default dashboards makes that loss loud. This guide pulls every zero-result query out of Shopify with ShopifyQL, sorts them into problems you can actually fix, and wires the fixes back into the store: synonyms and product data first, redirects for the rest. Then it compares that do-it-yourself loop with a search app that does it for you.
The searches schema, the shopifyqlQuery field, and the Search & Discovery behavior come from shopify.dev and the Shopify Help Center; Searchanise features and prices from its own docs and pricing page, checked in October 2026. The script was tested against a mock Admin GraphQL API that throttles once and returns ShopifyQL rows and a two-page catalog. It was not run against a live store, and the Liquid snippet in section 7 was not run on a live theme. Revenue figures are arithmetic from inputs you supply, not measurements.
1. The short answer
| Native + the script below | |||
|---|---|---|---|
| Finds zero-result queries | Search reports in Analytics, one screen at a time | Monthly CSV with search count and share of failed searches per query | Built into its analytics, exportable as CSV |
| Tells you why a query failed | No | Typo, partial match, combination or gap, checked against your catalog's words | Shows the query; diagnosis is yours |
| Synonyms | Search & Discovery app | Search & Discovery app, fed by the report | Built in |
| Redirect a query to a page | Not built in | Theme snippet reading a shop metafield the script publishes | Built in, to internal or external pages |
| Typo handling | Semantic search expands related words | Flags typos so you can add them | "Did you mean?" suggestions |
| Cost | Free | Free, plus your time each month | Free up to 25 products; $16 to $291 a month billed yearly by catalog size |
2. Why zero results are expensive
Search traffic is high-intent traffic: the shopper has a product in mind. A zero-result page gives them nothing to click, so the order you lose is one that was already half made. The size of the loss is store-specific, which is why the script asks for your numbers: --conv (the share of searches that end in an order) and --aov (average order value). For each failed query it multiplies searches by both. With 42 failed searches for "hikng boots", a 3% search conversion rate and an $80 average order, that's 42 × 0.03 × 80 = about $100 a month on one typo.
3. Where the data lives
Shopify's search reports sit under Analytics, Reports. For automation, the same data is in ShopifyQL's searches schema: one metric, searches, with dimensions including search_query, search_query_intent and the flag that matters here, search_results_were_returned. A sibling schema, search_queries, adds search_result_was_clicked for queries that found results nobody wanted.
ShopifyQL returned to the GraphQL Admin API in version 2025-10 as the shopifyqlQuery field. It needs the read_reports scope, returns rows as objects keyed by column name, and reports syntax problems in parseErrors instead of failing the request, so check that list before trusting the rows. The query the script runs:
FROM searches
SHOW searches
GROUP BY search_query, search_results_were_returned
DURING last_month
ORDER BY searches DESC
LIMIT 1000
Grouping by both columns, instead of filtering on the flag, gives you each query's success and failure counts in one pass. That matters: a query that fails 100% of the time is a different problem from one that fails 15% of the time because shoppers type it in different ways.
4. The script
#!/usr/bin/env python3
"""Find the Shopify searches that return nothing, sort them into typos, partial matches and real gaps,
and publish reviewed redirects to a shop metafield that the theme reads.
Python 3.9+, standard library only. Needs a custom app token with read_reports and read_products
(--publish also writes a shop metafield) on Admin API 2025-10 or later.
export SHOPIFY_SHOP=my-store.myshopify.com SHOPIFY_TOKEN=shpat_...
python3 zero_results.py --during last_month --out zero.csv # report
python3 zero_results.py --publish zero.csv # after filling in redirect_to
"""
import argparse
import csv
import difflib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
API = os.environ.get('SHOPIFY_API',
'https://%s/admin/api/2025-10/graphql.json' % os.environ.get('SHOPIFY_SHOP'))
WORD = re.compile(r"[a-z0-9]+(?:['-][a-z0-9]+)*")
DURING = re.compile(r'^[a-z_]+$') # ShopifyQL named ranges: last_month, this_quarter, ...
def norm(q):
"""Same normalization as the theme snippet: lowercase, collapse whitespace."""
return ' '.join(q.lower().split())
def gql(query, variables=None):
body = json.dumps({'query': query, 'variables': variables or {}}).encode()
req = urllib.request.Request(API, body, {'Content-Type': 'application/json',
'X-Shopify-Access-Token': os.environ['SHOPIFY_TOKEN']})
for attempt in range(5):
try:
with urllib.request.urlopen(req, timeout=60) as r:
out = json.load(r)
except urllib.error.HTTPError as e:
if e.code in (429, 502, 503) and attempt < 4:
time.sleep(float(e.headers.get('Retry-After') or 2 ** attempt))
continue
raise
throttled = any(err.get('extensions', {}).get('code') == 'THROTTLED' for err in out.get('errors', []))
if throttled and attempt < 4: # GraphQL cost limit: wait for the bucket to refill
time.sleep(2 ** attempt)
continue
if out.get('errors'):
sys.exit('GraphQL error: %s' % out['errors'])
return out['data']
sys.exit('still throttled after 5 attempts')
def shopifyql(q):
d = gql('query($q: String!) { shopifyqlQuery(query: $q) { tableData { rows } parseErrors } }',
{'q': q})['shopifyqlQuery']
if d['parseErrors']:
sys.exit('ShopifyQL: %s' % d['parseErrors'])
return d['tableData']['rows']
def search_stats(during):
rows = shopifyql('FROM searches SHOW searches GROUP BY search_query, search_results_were_returned '
'DURING %s ORDER BY searches DESC LIMIT 1000' % during)
total, zero = {}, {}
for r in rows:
q, n = norm(r['search_query'] or ''), int(float(r['searches']))
if not q:
continue
total[q] = total.get(q, 0) + n
if str(r['search_results_were_returned']).lower() == 'false':
zero[q] = zero.get(q, 0) + n
return total, zero
def vocabulary():
"""Every word in active product titles, types, vendors and tags: what the catalog calls things."""
words, cursor = {}, None
while True:
d = gql('''query($c: String) { products(first: 250, after: $c, query: "status:active") {
pageInfo { hasNextPage endCursor } nodes { title productType vendor tags } } }''',
{'c': cursor})['products']
for p in d['nodes']:
for text in [p['title'], p['productType'], p['vendor']] + p['tags']:
for w in WORD.findall((text or '').lower()):
words[w] = words.get(w, 0) + 1
if not d['pageInfo']['hasNextPage']:
return words
cursor = d['pageInfo']['endCursor']
def classify(q, vocab, keys):
fixed, missing = [], []
for w in WORD.findall(q):
if w in vocab or (w.endswith('s') and w[:-1] in vocab) or w + 's' in vocab:
fixed.append(w)
continue
close = difflib.get_close_matches(w, keys, n=1, cutoff=0.8) if len(w) > 3 else []
if close:
fixed.append(close[0])
else:
fixed.append(w)
missing.append(w)
words = WORD.findall(q)
if not words:
return 'noise', ''
if not missing: # every word exists in the catalog, possibly after a spelling fix;
# without a fix the words exist but not together ("red boots" when no boot is red)
return ('typo', ' '.join(fixed)) if fixed != words else ('combo', '')
if len(missing) < len(words):
return 'partial', ' '.join(w for w in fixed if w not in missing)
return 'gap', ''
def report(a):
total, zero = search_stats(a.during)
vocab = vocabulary()
keys = list(vocab)
all_searches, zero_searches = sum(total.values()), sum(zero.values())
rows = []
for q, n in sorted(zero.items(), key=lambda kv: -kv[1]):
if n < a.min_searches:
continue
kind, suggestion = classify(q, vocab, keys)
# ponytail: revenue is an estimate from your own conversion rate and AOV, not a measurement
rows.append({'query': q, 'searches': n, 'zero_share': '%.0f%%' % (100 * n / total[q]), 'type': kind,
'suggestion': suggestion, 'est_revenue': '%.2f' % (n * a.conv * a.aov),
'redirect_to': ''})
with open(a.out, 'w', newline='', encoding='utf-8') as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ['query'])
w.writeheader()
w.writerows(rows)
print('%d searches, %d with no results (%.1f%%), %d queries written to %s' % (
all_searches, zero_searches, 100 * zero_searches / max(all_searches, 1), len(rows), a.out))
for kind in ('typo', 'partial', 'gap', 'combo', 'noise'):
hit = [r for r in rows if r['type'] == kind]
if hit:
print(' %-20s %3d queries, %5d searches' % (kind, len(hit), sum(r['searches'] for r in hit)))
def publish(a):
redirects = {}
with open(a.publish, encoding='utf-8') as f:
for r in csv.DictReader(f):
url = (r.get('redirect_to') or '').strip()
if not url:
continue
# Only paths on this store: an open redirect from your own search box is a phishing gift
if not url.startswith('/') or url.startswith('//') or '\\' in url:
sys.exit('refusing %r for %r: use a path like /collections/boots' % (url, r['query']))
redirects[norm(r['query'])] = url
shop = gql('{ shop { id } }')['shop']['id']
out = gql('''mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) {
metafields { id } userErrors { field message } } }''',
{'m': [{'ownerId': shop, 'namespace': 'custom', 'key': 'search_redirects', 'type': 'json',
'value': json.dumps(redirects, sort_keys=True)}]})['metafieldsSet']
if out['userErrors']:
sys.exit('metafieldsSet: %s' % out['userErrors'])
print('published %d redirects to shop.metafields.custom.search_redirects' % len(redirects))
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--during', default='last_month', help='ShopifyQL range, e.g. last_month, this_quarter')
ap.add_argument('--out', default='zero_results.csv')
ap.add_argument('--min-searches', type=int, default=3, help='skip one-off queries')
ap.add_argument('--conv', type=float, default=0.03, help='your search-to-order conversion rate')
ap.add_argument('--aov', type=float, default=60.0, help='your average order value')
ap.add_argument('--publish', metavar='CSV', help='push the redirect_to column to the shop metafield')
a = ap.parse_args()
if not DURING.match(a.during):
sys.exit('--during takes a named range like last_month')
publish(a) if a.publish else report(a)
if __name__ == '__main__':
main()
What it does, and why:
- Normalizes before counting. "Hikng Boots" and "hikng boots" are one query. The lowercase-and-collapse-whitespace rule is the same one the theme snippet applies, so redirect keys match what shoppers type.
- Uses your catalog as the dictionary. It collects every word in active products' titles, product types, vendors and tags, then checks each query word against it: exact, simple plural, or a close spelling (Python's
difflib, 0.8 similarity, words over three letters). No external dictionary, so brand names and your own jargon count as real words. - Sorts each query into one of five types. typo: every word exists after a spelling fix. partial: some words exist, some don't, usually customer vocabulary you don't use ("sneakers" when you say "trainers"). combo: every word exists but no product has them together. gap: nothing matches. noise: no real words.
- Retries throttling. GraphQL cost limits come back as a
THROTTLEDerror in a 200 response, not an HTTP 429; the script handles both. - Publishes only what you approved.
--publishreads theredirect_tocolumn you filled in, refuses anything that isn't a path on your store, and writes one JSON metafield withmetafieldsSet.
5. Test results
Run against a mock Admin API with eight query rows and a two-product catalog ("Alpine Hiking Boots" tagged waterproof, "Trail Running Shoe" tagged red), with the first request throttled:
506 searches, 101 with no results (20.0%), 5 queries written to zero.csv
typo 1 queries, 42 searches
partial 1 queries, 25 searches
gap 1 queries, 17 searches
combo 1 queries, 9 searches
noise 1 queries, 6 searches
hikng boots 42 100% typo hiking boots 100.80
waterproof sneakers 25 83% partial waterproof 60.00
kayak paddle 17 100% gap 40.80
red boots 9 100% combo 21.60
!!! 6 100% noise 14.40
external URL: 1 refusing 'https://evil.example/' for 'kayak paddle': use a path like /collections/boots
published 2 redirects to shop.metafields.custom.search_redirects
| Check | Result |
|---|---|
| "hikng boots" (38) and "Hikng Boots" (4) merged | Pass: 42 searches, typo, suggestion "hiking boots" |
| Query that fails 25 of 30 times | Pass: 83% failure share, partial, "sneakers" not in the catalog |
| Words that exist but never together | Pass: "red boots" is a combo |
| Query with 2 searches | Pass: left out under the --min-searches default of 3 |
| Redirect to an external URL | Pass: refused, nothing published |
| Two reviewed redirects | Pass: one JSON metafield on the shop, two keys |
6. Fixing each type
| Type | What it means | Fix |
|---|---|---|
| typo | Every word exists after a spelling fix | Add the misspelling as a synonym, or redirect to the corrected search |
| partial | The shopper uses a word your catalog doesn't | Add the customer's word: synonym, product type, tag or description |
| combo | Every word exists, but never on one product | A filter or collection that answers it, or accept it |
| gap | Nothing in the catalog matches | Stock it, redirect to the closest collection, or leave it |
Gaps are the most valuable rows in the report: they're demand you don't serve yet. Show them to whoever buys stock before you hide them with a redirect.
Synonyms. Shopify's free Search & Discovery app lets you define synonym groups for storefront search. Feed it the partial rows: "sneakers" with "trainers", "hoodie" with "sweatshirt". Keep groups to true equivalents. A group of "boots, shoes, sandals" makes every footwear search return everything, which is a zero-result problem turned into an irrelevant-results problem.
Product data, the fix that also helps SEO. Shopify's semantic search, on automatically, expands results using a product's title, description, product type, category and tags. Search engines read the same title and description. So the cheapest fix for a partial row is often to use the shopper's word in the product itself:
- Put the term shoppers use in the title or the first sentence of the description, once, naturally: "Waterproof leather hiking boots (also called trekking boots)". One mention beats a keyword list at the bottom of the page, which reads as spam to people and search engines alike.
- Set the product type and the Shopify product category on every product. They cost nothing and feed both search and Google Shopping.
- Use tags for attributes shoppers search but don't belong in a title: materials, use cases, sizes like "wide fit".
- Don't create a product page per misspelling. Synonyms and redirects handle typos without thin pages.
Boosts. For queries that return results but the wrong ones first, Search & Discovery can boost products for up to 10 search terms each. Shopify's own advice is to boost one or a few products per term, and you don't need to add misspellings or plural forms to a boost.
7. Redirects for what's left
Shopify has no built-in "redirect this search term" setting. URL redirects in the admin act on paths that would otherwise 404, and /search?q=... never does. A few lines in the search results section can do it, reading the map the script publishes:
{%- comment -%}
Top of the search results section (sections/main-search.liquid in Dawn-based themes).
Redirects reviewed zero-result queries to the page you chose; the map lives in a JSON shop metafield.
{%- endcomment -%}
{%- if search.performed and search.results_count == 0 -%}
{%- assign key = search.terms | downcase | split: ' ' | join: ' ' -%}
{%- assign target = shop.metafields.custom.search_redirects.value[key] -%}
{%- if target != blank -%}
<script>location.replace({{ target | json }});</script>
{%- endif -%}
{%- endif -%}
- Only zero-result searches redirect. If you add the product later, the search starts working and the redirect stops firing on its own.
- Keys match the script. Lowercase, whitespace collapsed on both sides.
- The target is output as JSON, so a stray quote in a path can't break out of the script tag; the script already limits targets to paths on your store.
- It's a client-side redirect, after the empty results page starts loading, so shoppers may see a brief flash. A search app that owns the results page handles redirects inside its own search, with no theme code.
Use redirects sparingly: a gap query redirected to a vaguely related collection still disappoints. Good candidates are typos of your bestsellers, old product names, and brand or category terms that have a natural landing page.
8. When an app beats the loop: Searchanise
The script and snippet cost nothing, but someone has to run them every month, review a CSV and keep the theme snippet alive through theme updates. Searchanise replaces the storefront search with its own and puts the pieces in one admin:
- Redirects for specific search terms, to internal pages, collections or blog posts, or external pages, with no theme code.
- Synonyms for search terms, and "Did you mean?" suggestions so a typo returns results instead of a blank page.
- Analytics that list queries with no results, which search queries lead to purchases, and which synonyms and terms you're missing, exportable as CSV. The same report this guide builds, without the script.
- Pricing by catalog size, billed yearly per month: Basic $16 (1,500 products), Essential $33 (7,500), Advanced $74 (15,000), Growth $116 (30,000), Pro $174 (50,000), Premium $291 (300,000). Monthly billing is about 20% more; a free plan covers 25 products, and every plan starts with a 14-day trial with all features. The pricing page doesn't show which features sit on which tier in readable form, so confirm redirects and analytics on the plan you pick.
9. Our opinion: trade-offs
- Measure first, whatever you choose. The ShopifyQL query takes minutes to run and tells you whether zero results are 2% of searches or 20%. At 2% with no high-volume failures, the native fixes and an occasional look at the report are enough.
- Fix product data before adding tools. Words in titles, descriptions, types and tags help Shopify search, any search app and Google at once. Synonyms and redirects only help your search box.
- The script fits stores with a developer and a stable catalog. A monthly review of 30 rows is quick; the report doubles as a demand signal for buying.
- Searchanise fits when search is a sales channel. Large or fast-changing catalogs, many typo-prone product names, or no developer to maintain a theme snippet. You trade a monthly fee for redirects, synonyms, typo suggestions and analytics that keep working through theme changes.
Related: monitoring competitor prices with products.json, Shopify and BigCommerce API rate limits at 50k products and custom Shopify events in ActiveCampaign.
10. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Replacement storefront search with redirects, synonyms and analytics | Search-term redirects without theme code, "Did you mean?" for typos, and a no-results report with CSV export, priced by catalog size from $16 a month billed yearly, with a 14-day trial. |
Alternatives: free and official tools
We don't earn anything from these links.
- Shopify Search & Discovery: Shopify's free app for synonyms, product boosts and filters.
- ShopifyQL
searchesschema: every metric and dimension the script uses. shopifyqlQueryreference: the GraphQL field, scopes and error handling.- Modifying search in Shopify: boosts, semantic search and result types.
11. Pitfalls
- Filtering on the flag in ShopifyQL. You lose the success counts and can't tell a 100% failure from a 10% one. Group by it instead.
- Trusting rows without reading
parseErrors. A misspelled column returns no table and a parse error, not an HTTP error. - Synonym groups that are too broad. They swap zero results for irrelevant results, which is harder to spot in reports.
- Redirecting gaps by reflex. A gap is a buying signal; a redirect hides it from next month's report.
- Open redirects. Never let a search term send shoppers to an arbitrary URL. The script only publishes paths on your own store.
- Keyword-stuffed descriptions. One natural mention of the customer's term helps; a block of variants at the bottom of the page hurts.
Pull zero-result searches from ShopifyQL's searches schema every month, grouped by query and the results flag. Fix product data first, add true synonyms in Search & Discovery, treat gaps as demand, and redirect only what's left. If you don't want to maintain that loop, Searchanise gives you search redirects, synonyms, typo suggestions and a no-results report in one app, priced by catalog size.