David Lewin David Lewin
E-Commerce, Research & Ads

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.

Short answer

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.

How this guide was made

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

Shopify nativeNative + the script belowSearchanise
Finds zero-result queriesSearch reports in Analytics, one screen at a timeMonthly CSV with search count and share of failed searches per queryBuilt into its analytics, exportable as CSV
Tells you why a query failedNoTypo, partial match, combination or gap, checked against your catalog's wordsShows the query; diagnosis is yours
SynonymsSearch & Discovery appSearch & Discovery app, fed by the reportBuilt in
Redirect a query to a pageNot built inTheme snippet reading a shop metafield the script publishesBuilt in, to internal or external pages
Typo handlingSemantic search expands related wordsFlags typos so you can add them"Did you mean?" suggestions
CostFreeFree, plus your time each monthFree 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.

Worked example: monthly revenue at stake by zero-result rate
Worked example: monthly revenue at stake by zero-result rate5% of 10,000 searches fail: 1200 USD; 10% of 10,000 searches fail: 2400 USD; 20% of 10,000 searches fail: 4800 USD5% of 10,000 searches fail: 1200 USD5% of 10,000 searches fail1200 USD10% of 10,000 searches fail: 2400 USD10% of 10,000 searches fail2400 USD20% of 10,000 searches fail: 4800 USD20% of 10,000 searches fail4800 USD
Arithmetic, not data: failed searches × 3% search conversion × $80 average order. Put your own conversion rate and order value into the script; not every failed search would have converted at the normal rate.

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.

The monthly loop
The monthly loopShopifyQL / Admin GraphQL → zero_results.py → Reviewed CSV → Search & Discovery app / Shop metafieldShopifyQL: FROM searches, grouped by query and results flagShopifyQLFROM searches, grouped byquery and results flagAdmin GraphQL: active products: titles, types, vendors, tagsAdmin GraphQLactive products: titles,types, vendors, tagszero_results.py: normalize queries, compare words with the catalog vocabulary, estimate revenue at stakezero_results.pynormalize queries, compare words with the catalog vocabulary,estimate revenue at stakeReviewed CSV: you decide: synonym, product data fix, redirect or nothingReviewed CSVyou decide: synonym, product data fix, redirect or nothingSearch & Discovery app: synonyms for typos and customer vocabularySearch & Discovery appsynonyms for typos andcustomer vocabularyShop metafield: redirects the theme follows when a search finds nothingShop metafieldredirects the theme followswhen a search finds nothing
The script never changes search behavior on its own. It writes a report; a person decides; only the redirect column is published, and only as paths on your own store.

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 THROTTLED error in a 200 response, not an HTTP 429; the script handles both.
  • Publishes only what you approved. --publish reads the redirect_to column you filled in, refuses anything that isn't a path on your store, and writes one JSON metafield with metafieldsSet.

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
CheckResult
"hikng boots" (38) and "Hikng Boots" (4) mergedPass: 42 searches, typo, suggestion "hiking boots"
Query that fails 25 of 30 timesPass: 83% failure share, partial, "sneakers" not in the catalog
Words that exist but never togetherPass: "red boots" is a combo
Query with 2 searchesPass: left out under the --min-searches default of 3
Redirect to an external URLPass: refused, nothing published
Two reviewed redirectsPass: one JSON metafield on the shop, two keys

6. Fixing each type

TypeWhat it meansFix
typoEvery word exists after a spelling fixAdd the misspelling as a synonym, or redirect to the corrected search
partialThe shopper uses a word your catalog doesn'tAdd the customer's word: synonym, product type, tag or description
comboEvery word exists, but never on one productA filter or collection that answers it, or accept it
gapNothing in the catalog matchesStock 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

ToolRoleWhy it fits
SearchaniseReplacement storefront search with redirects, synonyms and analyticsSearch-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.

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.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.