# Shopify Catalog Cleanup

> Use when fixing the debris an audit found in a Shopify catalog: bulk-archive dead/discontinued/zero-inventory products, clean up catalog debris, kill ghost review stars (empty star ratings a theme still renders after a review app was uninstalled), remove orphaned metafields left by an uninstalled app, strip HTML artifacts from product descriptions (double-nested bold, fully-bold paragraphs, empty tags), or fix messed-up / mangled product descriptions in bulk. Mutating cleanup work: archive vs delete, metafieldsDelete, description rewrites. Not for FINDING what's wrong (use shopify-catalog-audit) or filling missing metadata like SEO titles and alt text (use shopify-seo-metadata).

- Skill: `kgelster/shopify-catalog-cleanup` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add kgelster/shopify-catalog-cleanup`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kgelster/shopify-catalog-cleanup/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: kgelster (https://skillmd.com/u/kgelster)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kgelster/shopify-catalog-cleanup

---


# Shopify Catalog Cleanup

Fixing the debris an audit found. This is the mutating counterpart to
`shopify-catalog-audit` (which finds problems) and `shopify-seo-metadata` (which
fills missing metadata). Everything here writes to the catalog, so the whole
skill is built around one rule: **preview the count, then mutate.**

## Store access

**Lane A: custom-app token (scriptable).** In Shopify admin: Settings → Apps
and sales channels → Develop apps → create an app → grant `read_products` and
`write_products`, install, copy the Admin API access token. Export it; never
write it to a committed file:

```bash
export SHOPIFY_STORE="your-store.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="<your Admin API access token>"   # env, not disk
```

```bash
curl -s "https://$SHOPIFY_STORE/admin/api/2025-07/graphql.json" \
  -H "X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ shop { name } }"}'
```

**Lane B: Shopify CLI OAuth (no stored token).** `shopify store auth --store
$SHOPIFY_STORE --scopes read_products,write_products` then `shopify store
execute`. Good for token-less stores where the owner logs in interactively.

**Toolkit preflight.** Lane B rides on the Shopify CLI: run `shopify version`
first and install it if missing. For the full Admin GraphQL schema and
validated execution, pair this skill with Shopify's official AI toolkit
plugin. In Claude Code, check `claude plugin list`; if it isn't there:

```bash
claude plugin marketplace add Shopify/Shopify-AI-Toolkit
claude plugin install shopify-plugin@shopify-ai-toolkit
```

Recommended, not required: Lane A needs only curl and a token. The toolkit
gives your agent the API; this skill gives it the playbook.

## The one rule: preview count, then mutate

Every recipe in [references/recipes.md](references/recipes.md) pairs a read-only
count/preview query with the mutation it feeds. The discipline is not optional:

1. **Run the count first.** `products(query:"…")` with a filter returns
   `count` and a page of titles. Read them.
2. **Sanity-check the number against expectation.** If you expected ~40 dead
   products and the count says 900, **stop and re-scope**: your filter is
   wrong, not the store. A count far above expectation is a bug in your query.
3. **Only then mutate,** and prefer the reversible path (below).

## Non-destructive doctrine

- **Archive, never delete.** Archiving a product (`status: ARCHIVED`) hides it
  from the storefront and channels but keeps the record, its handle, its history,
  and its URL-redirect potential. It is fully reversible: flip status back to
  `ACTIVE` or `DRAFT`. Deletion is not reversible: the product, its variants,
  and its metafields are gone. Default to archive; only delete when the owner
  explicitly asks and understands it is permanent.
- **Tag what you touch.** When you bulk-archive, add a dated tag
  (`archived-2026-07-05`) in the same mutation. That tag is your undo list: to
  reverse, query `tag:archived-2026-07-05` and set status back. Without it you
  cannot cleanly separate your batch from products that were already archived.
- **Read before you write.** A read query is cheap; an unscoped mutation is not
  reversible. Verify a write landed by re-reading the object through the Admin
  API, not the storefront (the storefront is CDN-cached and lies about
  freshness).
- Never print or commit the Admin API access token.

## Recipes, not scripts

The GraphQL blocks in the reference are copy-and-adapt starting points: your
agent writes throwaway code per engagement against the current schema, runs it,
discards it. Bulk mutations should batch and respect throttling: read the
`throttleStatus` in the `extensions` block and back off when
`currentlyAvailable` drops, rather than hammering a fixed rate.

## Three fix classes

### 1. Bulk-archiving dead products

Design the criteria before you touch anything. Common "dead" signals, combined
with AND (a product is rarely dead on one signal alone):

- Zero total inventory across all variants, and
- Zero sales in a trailing window (e.g. 12 months), and
- Created/updated long enough ago to not be a new launch, and
- Not in a collection you care about, or already tagged discontinued.

Turn that into a `products(query: …)` search filter, run the **count** query,
read the sample titles, confirm the number is in the ballpark you expected, then
run `productUpdate` to set `status: ARCHIVED` and append the dated tag. The
unarchive path is the same query on your tag, flipping status back. Recipes:
[references/recipes.md](references/recipes.md).

### 2. Orphaned metafield debris (the ghost-star class)

Classic symptom: a store uninstalled a product-review app (Shopify's own Product
Reviews is the usual culprit), but the theme still renders **empty star ratings**
on every product. The app left its metafields behind: the `spr.reviews` field the
Product Reviews app wrote, plus a `reviews` namespace holding `reviews.rating` and
`reviews.rating_count`. The theme's star snippet reads them, finds `0`, and paints
five empty stars.

Fix safely:

1. **Confirm the app is truly gone.** Check Settings → Apps; the namespace must
   belong to an app that is uninstalled, not merely paused. Deleting metafields
   an installed app owns will just have the app rewrite them.
2. **Inventory the orphaned namespaces.** Query the product metafield
   definitions and a sample of products for every suspect namespace (`spr` and
   `reviews` for the Product Reviews app) so you know exactly which
   `namespace.key` pairs you are removing and how many products carry them.
3. **Delete via `metafieldsDelete`**, keyed by owner GID + namespace + key,
   covering both `spr.reviews` and the `reviews.rating` / `reviews.rating_count`
   fields. Preview the affected-count first, same as archiving; batch the
   identifiers to stay under rate/cost limits.
4. **Re-read** a few products to confirm the fields are gone, then reload the
   storefront product: the empty stars should be gone (the theme snippet may
   also need its now-dead reference removed; flag that to whoever owns the theme).

**Matrixify warning:** a blank cell in a Matrixify update **deletes** that
metafield, it does not skip it. That is a footgun for accidental deletion and a
tool for deliberate bulk removal: either way, know which you are doing. For
spreadsheet-driven bulk work and that trap, see `shopify-matrixify`.

### 3. HTML artifacts in descriptions

Editors, app injections, and copy-paste leave residue in `descriptionHtml`:
double-nested `<strong><strong>…`, whole paragraphs wrapped in bold, empty
`<p></p>` / `<span></span>` tags, stray inline styles. Detect cheaply with an
index scan (pull `descriptionHtml` for the scope, regex for the artifact
patterns) rather than eyeballing products one by one. Fix by rewriting
`descriptionHtml` per product via `productUpdate`: a targeted string transform,
not a from-scratch rewrite that would lose real formatting.

**Log old and new for every rewrite.** As you go, write each rewrite (product
id, old `descriptionHtml`, new `descriptionHtml`, status) to a CSV, the same
discipline `shopify-seo-metadata` mandates for meta writes. That log is two
things: your diff review (read a sample of old→new pairs before declaring
done) and your only undo list. Archiving gets a dated tag to query for
reversal; a rewrite has nothing, so without the log a bad transform is
unrecoverable. This matters downstream too: skills that generate from body
HTML (SEO meta, for one) inherit whatever a bad rewrite left behind.

**Post-edit scan doctrine.** After ANY bulk content edit, sweep the affected
scope again for residue: double-bold, orphaned tags, half-applied
transforms, **before** you declare done. A bulk regex fix routinely leaves a
new artifact on the 3% of products whose markup didn't match your assumption.
The passing re-scan is the evidence; "the transform ran" is not.

## References

- [references/recipes.md](references/recipes.md): count-before-mutate archive
  query + mutation, metafield-namespace inventory + `metafieldsDelete`,
  description-artifact scan + safe rewrite.

## Provenance and maintenance

Last verified: 2026-07-05. GraphQL pinned to Admin API 2025-07; Shopify
deprecates versions on a rolling quarterly schedule, so verify against
[shopify.dev](https://shopify.dev/docs/api/admin-graphql) before trusting a
version-specific claim. Read-only re-verification a stranger can run (a
metafield-namespace inventory and an archived-product count, both non-mutating):

```bash
# how many products are already archived, and a metafield namespace sample
curl -s "https://$SHOPIFY_STORE/admin/api/2025-07/graphql.json" \
  -H "X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ productsCount(query:\"status:archived\") { count } products(first:5) { nodes { title metafields(first:20) { nodes { namespace key } } } } }"}'
```

Canonical docs: [productUpdate](https://shopify.dev/docs/api/admin-graphql/latest/mutations/productupdate),
[metafieldsDelete](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldsdelete).
These skills capture operational lessons the docs don't; they are not a
replacement for the reference.

