Diagnostic WooCommerce core: products and catalog

Duplicate or missing SKUs in WooCommerce

Two products in the catalog quietly share the same SKU, or a product has no SKU at all. Nobody notices until a stock feed matches the wrong item, a report double counts sales, or a warehouse picks the wrong box. WooCommerce never stops this from happening on its own. Here is why it creeps in and a small script that finds every conflict and tells you exactly which ones are safe to fix and which ones need a human to look first.

Python and Node.js Runs on a schedule Safe by default (dry run)
A white cardboard box on a wooden table
Photo by Kadarius Seegars on Unsplash
The short answer

WooCommerce does not require a SKU to be filled in or unique, so imports, cloned products, and concurrent edits can leave two products sharing one SKU or a product with a blank SKU. Run a small Python or Node.js report on a schedule that reads every product and variation, groups them by SKU, and reports every group that is duplicated or blank. It never renames a SKU on its own. For a product with no paid order behind it, confirmed by checking Stripe for the PaymentIntent saved on any matching order, it flags the conflict as safe to auto-fix. For a product a paid order already depends on, it flags the conflict for a person to fix by hand, since a script cannot know which SKU is the right one. Full code, tests, and a dry run guard are below.

The problem in plain words

A SKU is supposed to be a short, unique label that ties a product in WooCommerce to the same item everywhere else, your warehouse, your accounting software, your ad feed. It only works as a shared key when it is actually unique and actually present.

WooCommerce lets you save a product with an empty SKU, and it lets two different products share the exact same SKU, without warning you either time. The conflict sits quietly in the catalog. Inventory tools and price feeds that key off SKU start matching the wrong product, or skipping one entirely, and the only sign is a report that does not add up.

Product A SKU: TS-BLUE-M Product B SKU: TS-BLUE-M import, clone, or race on save Same SKU twice nothing warns you Wrong item matched in feeds and reports
Two products end up sharing one SKU, or one has none at all, and WooCommerce lets the catalog save either way. The mismatch only shows up in whatever tool keys off SKU next.

Why it happens

The WooCommerce product editor does not require a SKU and does not reject a duplicate one by default. A few common ways the catalog ends up broken:

None of these throw an error at save time. The catalog looks normal in the product list. The conflict only becomes visible once a report, a price feed, or a fulfillment tool tries to use the SKU as a unique key and gets the wrong answer, or no answer at all.

The key insight

Not every duplicate or missing SKU is equally risky to fix. A product nobody has ever paid for can safely get a new placeholder SKU right away. A product that is already tied to a real, Stripe confirmed paid order is a different story, since changing its SKU could break how that order reconciles later. The safe move is to separate the two cases and only auto-fix the ones with nothing on the line.

The fix, as a flow

We add a job that runs on a schedule, walks every product and variation, and groups them by SKU. Any group that is duplicated or blank gets checked against recent orders. If Stripe confirms a succeeded payment already depends on one of the items in that group, the conflict is flagged for a person to review. If nothing paid depends on it yet, the conflict is flagged as safe to auto-fix with a new placeholder SKU.

Scheduled job once a week List products and variations Group by SKU flag dupes and blanks Stripe confirms a paid order depends on it? yes, review by hand no Auto-fixable safe to assign new SKU
The report never changes a SKU on its own. It only sorts every conflict into "safe to auto-fix" or "needs a person," based on whether Stripe confirms real money already rides on that item.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read access to products and orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export ORDER_LOOKBACK_DAYS="90"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export ORDER_LOOKBACK_DAYS="90"
export DRY_RUN="true"   // start safe, change to false to write
2

Walk every product and variation

Page through the products endpoint, and for every variable product, page through its variations too. A SKU conflict on a variation is just as real as one on a simple product, and it is easy to miss if you only look at parent products.

step2.py
import requests
from requests.auth import HTTPBasicAuth

AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET)

def all_products():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"per_page": 100, "page": page, "status": "any"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for product in batch:
            yield {"id": product["id"], "sku": product.get("sku", ""), "type": "product"}
            if product.get("type") == "variable":
                yield from variations_of(product["id"])
        page += 1
step2.js
async function* allProducts() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?per_page=100&page=${page}&status=any`);
    if (!batch.length) return;
    for (const product of batch) {
      yield { id: product.id, sku: product.sku || "", type: "product" };
      if (product.type === "variable") {
        yield* variationsOf(product.id);
      }
    }
    page++;
  }
}
3

Group by SKU, in a pure function

Blank SKUs get trimmed and grouped together under the empty string, so every product missing a SKU shows up in one group. Keeping this step pure, no network calls, makes it trivial to test with plain lists.

group.py
from collections import defaultdict

def group_by_sku(products):
    groups = defaultdict(list)
    for item in products:
        sku = (item.get("sku") or "").strip()
        groups[sku].append({"product_id": item["id"], "type": item.get("type", "product")})
    return groups
group.js
export function groupBySku(products) {
  const groups = new Map();
  for (const item of products) {
    const sku = (item.sku || "").trim();
    if (!groups.has(sku)) groups.set(sku, []);
    groups.get(sku).push({ productId: item.id, type: item.type || "product" });
  }
  return groups;
}
4

Check Stripe for a real paid order behind each item

Read recent paid orders from WooCommerce, then confirm each one against Stripe using the PaymentIntent id saved in the order's _stripe_intent_id meta, falling back to transaction_id when it looks like a PaymentIntent id. Only a PaymentIntent that Stripe reports as succeeded counts as a real paid order, since a note or a manual status change is not proof of payment.

stripe_check.py
import stripe

def intent_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def stripe_confirms_paid(order):
    intent_id = intent_id_of(order)
    if not intent_id:
        return False
    try:
        intent = stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return False
    return intent.get("status") == "succeeded"


def product_ids_with_paid_orders(orders):
    ids = set()
    for order in orders:
        if not stripe_confirms_paid(order):
            continue
        for line in order.get("line_items") or []:
            pid = line.get("variation_id") or line.get("product_id")
            if pid:
                ids.add(pid)
    return ids
stripe-check.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function stripeConfirmsPaid(order) {
  const intentId = intentIdOf(order);
  if (!intentId) return false;
  try {
    const intent = await stripe.paymentIntents.retrieve(intentId);
    return intent.status === "succeeded";
  } catch {
    return false;
  }
}

async function productIdsWithPaidOrders(orders) {
  const ids = new Set();
  for (const order of orders) {
    if (!(await stripeConfirmsPaid(order))) continue;
    for (const line of order.line_items || []) {
      const pid = line.variation_id || line.product_id;
      if (pid) ids.add(pid);
    }
  }
  return ids;
}
5

Decide, with one pure function

The rule is simple. A unique, non-blank SKU is fine. A duplicated or blank SKU where no item in the group has a real paid order behind it is safe to auto-fix. A duplicated or blank SKU where at least one item does is flagged for a person to review, since a script has no way to know which SKU should win.

decide.py
def decide(sku, entries, has_paid_order):
    if sku != "" and len(entries) == 1:
        return ("ok", "unique SKU")
    if sku == "":
        reason = "missing SKU"
    else:
        reason = f"SKU '{sku}' shared by {len(entries)} items"
    if has_paid_order:
        return ("review", f"{reason}, at least one item has a paid order behind it")
    return ("auto_fixable", f"{reason}, no paid orders depend on these items yet")
decide.js
export function decide(sku, entries, hasPaidOrder) {
  if (sku !== "" && entries.length === 1) return ["ok", "unique SKU"];
  const reason = sku === "" ? "missing SKU" : `SKU '${sku}' shared by ${entries.length} items`;
  if (hasPaidOrder) {
    return ["review", `${reason}, at least one item has a paid order behind it`];
  }
  return ["auto_fixable", `${reason}, no paid orders depend on these items yet`];
}
6

Wire it together with a dry run guard

The loop groups every product, checks each conflicting group against Stripe confirmed paid orders, and reports the two categories. Leave DRY_RUN on for the first few runs so it only logs what it would flag. Once the report looks right, switch it off to let it write a note on each conflicting product for your team to see in the admin.

Run it safe

Always start with DRY_RUN=true. This job never renames a SKU by itself, even when it is marked auto-fixable, it only reports what is safe to change. Review the report, then decide how you want to apply the actual SKU changes.

The full code

Here is the complete report in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again since it never writes to a product unless DRY_RUN is off.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

sku_audit.py
"""Find duplicate and missing SKUs across WooCommerce products and variations.

Two products can end up sharing one SKU, or having a blank one, after a CSV
import, a plugin sync, or two editors saving at the same time. WooCommerce
does not stop this at the database level, so the store ends up with broken
inventory sync, wrong analytics, and orders that point at the wrong item.

This walks every product and variation, groups them by SKU, and reports every
group that is duplicated or blank. It never renames a SKU on its own. For a
product that is tied to a real paid order (checked against Stripe using the
PaymentIntent id saved on the order), it only flags the conflict for a human
to fix by hand, since renaming a SKU under a paid order can break fulfillment
and reporting. For a product with no paid order behind it, it is safe to flag
as auto-fixable, since nothing downstream depends on that SKU yet.

Read only by default. Run on a schedule.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
from collections import defaultdict

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("sku_audit")

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
    os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
    os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
ORDER_LOOKBACK_DAYS = int(os.environ.get("ORDER_LOOKBACK_DAYS", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def decide(sku, entries, has_paid_order):
    """Pure decision function. No I/O.

    sku: the SKU string, "" for blank.
    entries: list of {"product_id": int, "type": "product"|"variation"} sharing this SKU.
    has_paid_order: True if any entry in this group is a line item on an order
        that Stripe confirms was actually paid (a succeeded PaymentIntent).

    Returns a tuple (action, reason):
      "ok"          - a normal, unique, non-blank SKU. Nothing to do.
      "review"      - conflict exists, but a paid order depends on one of the
                      items, so a human must decide which SKU is authoritative.
      "auto_fixable" - conflict exists and no paid order depends on any item
                      in the group, so it is safe to assign new placeholder
                      SKUs automatically.
    """
    if sku != "" and len(entries) == 1:
        return ("ok", "unique SKU")
    if sku == "":
        reason = "missing SKU"
    else:
        reason = f"SKU '{sku}' shared by {len(entries)} items"
    if has_paid_order:
        return ("review", f"{reason}, at least one item has a paid order behind it")
    return ("auto_fixable", f"{reason}, no paid orders depend on these items yet")


def group_by_sku(products):
    """Pure. Groups a flat list of {"id", "sku", "type"} dicts by SKU."""
    groups = defaultdict(list)
    for item in products:
        sku = (item.get("sku") or "").strip()
        groups[sku].append({"product_id": item["id"], "type": item.get("type", "product")})
    return groups


def all_products():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"per_page": 100, "page": page, "status": "any"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for product in batch:
            yield {"id": product["id"], "sku": product.get("sku", ""), "type": "product"}
            if product.get("type") == "variable":
                yield from variations_of(product["id"])
        page += 1


def variations_of(product_id):
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations",
            params={"per_page": 100, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for variation in batch:
            yield {"id": variation["id"], "sku": variation.get("sku", ""), "type": "variation"}
        page += 1


def paid_orders_recent():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=ORDER_LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def stripe_confirms_paid(order):
    """Retrieve the order's PaymentIntent from Stripe and check it succeeded."""
    intent_id = intent_id_of(order)
    if not intent_id:
        return False
    try:
        intent = stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return False
    return intent.get("status") == "succeeded"


def product_ids_with_paid_orders():
    """Product ids that appear as a line item on an order Stripe confirms paid."""
    ids = set()
    for order in paid_orders_recent():
        if not stripe_confirms_paid(order):
            continue
        for line in order.get("line_items") or []:
            pid = line.get("variation_id") or line.get("product_id")
            if pid:
                ids.add(pid)
    return ids


def note_on_products(entries, message):
    for entry in entries:
        path = (
            f"/wp-json/wc/v3/products/variations/{entry['product_id']}"
            if entry["type"] == "variation"
            else f"/wp-json/wc/v3/products/{entry['product_id']}"
        )
        log.info("Would tag product %s (%s): %s", entry["product_id"], entry["type"], message)


def run():
    products = list(all_products())
    groups = group_by_sku(products)
    paid_ids = product_ids_with_paid_orders()

    to_review = 0
    to_autofix = 0
    for sku, entries in groups.items():
        has_paid_order = any(e["product_id"] in paid_ids for e in entries)
        action, reason = decide(sku, entries, has_paid_order)
        if action == "ok":
            continue
        log.warning(
            "%s: %s -> %s",
            "REVIEW" if action == "review" else "AUTO-FIXABLE",
            reason,
            [e["product_id"] for e in entries],
        )
        if not DRY_RUN:
            note_on_products(entries, reason)
        if action == "review":
            to_review += 1
        else:
            to_autofix += 1

    log.info(
        "Done. %d SKU conflict(s) need review, %d SKU conflict(s) safe to auto-fix.%s",
        to_review, to_autofix, " (dry run, nothing written)" if DRY_RUN else "",
    )


if __name__ == "__main__":
    run()
sku-audit.js
/**
 * Find duplicate and missing SKUs across WooCommerce products and variations.
 *
 * Two products can end up sharing one SKU, or having a blank one, after a CSV
 * import, a plugin sync, or two editors saving at the same time. WooCommerce
 * does not stop this at the database level, so the store ends up with broken
 * inventory sync, wrong analytics, and orders that point at the wrong item.
 *
 * This walks every product and variation, groups them by SKU, and reports
 * every group that is duplicated or blank. It never renames a SKU on its own.
 * For a product tied to a real paid order (checked against Stripe using the
 * PaymentIntent id saved on the order), it only flags the conflict for a
 * human to fix by hand, since renaming a SKU under a paid order can break
 * fulfillment and reporting. For a product with no paid order behind it, it
 * is safe to flag as auto-fixable, since nothing downstream depends on that
 * SKU yet.
 *
 * Read only by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/duplicate-or-missing-skus/
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const ORDER_LOOKBACK_DAYS = Number(process.env.ORDER_LOOKBACK_DAYS || 90);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

/**
 * Pure decision function. No I/O.
 *
 * sku: the SKU string, "" for blank.
 * entries: list of { productId, type: "product" | "variation" } sharing this SKU.
 * hasPaidOrder: true if any entry in this group is a line item on an order
 *   that Stripe confirms was actually paid (a succeeded PaymentIntent).
 *
 * Returns [action, reason]:
 *   "ok"           - a normal, unique, non-blank SKU. Nothing to do.
 *   "review"       - conflict exists, but a paid order depends on one of the
 *                    items, so a human must decide which SKU is authoritative.
 *   "auto_fixable" - conflict exists and no paid order depends on any item in
 *                    the group, so it is safe to assign new placeholder SKUs
 *                    automatically.
 */
export function decide(sku, entries, hasPaidOrder) {
  if (sku !== "" && entries.length === 1) return ["ok", "unique SKU"];
  const reason = sku === "" ? "missing SKU" : `SKU '${sku}' shared by ${entries.length} items`;
  if (hasPaidOrder) {
    return ["review", `${reason}, at least one item has a paid order behind it`];
  }
  return ["auto_fixable", `${reason}, no paid orders depend on these items yet`];
}

/** Pure. Groups a flat list of { id, sku, type } into a Map keyed by SKU. */
export function groupBySku(products) {
  const groups = new Map();
  for (const item of products) {
    const sku = (item.sku || "").trim();
    if (!groups.has(sku)) groups.set(sku, []);
    groups.get(sku).push({ productId: item.id, type: item.type || "product" });
  }
  return groups;
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* allProducts() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?per_page=100&page=${page}&status=any`);
    if (!batch.length) return;
    for (const product of batch) {
      yield { id: product.id, sku: product.sku || "", type: "product" };
      if (product.type === "variable") {
        yield* variationsOf(product.id);
      }
    }
    page++;
  }
}

async function* variationsOf(productId) {
  let page = 1;
  while (true) {
    const batch = await woo(`/products/${productId}/variations?per_page=100&page=${page}`);
    if (!batch.length) return;
    for (const variation of batch) {
      yield { id: variation.id, sku: variation.sku || "", type: "variation" };
    }
    page++;
  }
}

async function* paidOrdersRecent() {
  const after = new Date(Date.now() - ORDER_LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function stripeConfirmsPaid(order) {
  const intentId = intentIdOf(order);
  if (!intentId) return false;
  try {
    const intent = await stripe.paymentIntents.retrieve(intentId);
    return intent.status === "succeeded";
  } catch {
    return false;
  }
}

async function productIdsWithPaidOrders() {
  const ids = new Set();
  for await (const order of paidOrdersRecent()) {
    if (!(await stripeConfirmsPaid(order))) continue;
    for (const line of order.line_items || []) {
      const pid = line.variation_id || line.product_id;
      if (pid) ids.add(pid);
    }
  }
  return ids;
}

function noteOnProducts(entries, message) {
  for (const entry of entries) {
    console.log(`Would tag product ${entry.productId} (${entry.type}): ${message}`);
  }
}

export async function run() {
  const products = [];
  for await (const item of allProducts()) products.push(item);
  const groups = groupBySku(products);
  const paidIds = await productIdsWithPaidOrders();

  let toReview = 0;
  let toAutofix = 0;
  for (const [sku, entries] of groups) {
    const hasPaidOrder = entries.some((e) => paidIds.has(e.productId));
    const [action, reason] = decide(sku, entries, hasPaidOrder);
    if (action === "ok") continue;
    console.warn(
      `${action === "review" ? "REVIEW" : "AUTO-FIXABLE"}: ${reason} -> [${entries.map((e) => e.productId).join(", ")}]`
    );
    if (!DRY_RUN) noteOnProducts(entries, reason);
    if (action === "review") toReview++;
    else toAutofix++;
  }

  console.log(
    `Done. ${toReview} SKU conflict(s) need review, ${toAutofix} SKU conflict(s) safe to auto-fix.` +
    (DRY_RUN ? " (dry run, nothing written)" : "")
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule and the grouping step are the parts most worth testing, since they decide what gets reported as safe versus what gets sent to a human. Because both are pure, no network and no Stripe account are needed. The tests just feed in plain lists and check the result.

test_duplicate_sku_decide.py
from sku_audit import decide, group_by_sku


def entries(n, start_id=1, type_="product"):
    return [{"product_id": start_id + i, "type": type_} for i in range(n)]


def test_ok_when_unique_sku():
    assert decide("ABC-1", entries(1), has_paid_order=False)[0] == "ok"


def test_auto_fixable_when_duplicate_and_no_paid_order():
    action, reason = decide("ABC-1", entries(2), has_paid_order=False)
    assert action == "auto_fixable"
    assert "shared by 2" in reason


def test_review_when_duplicate_and_paid_order_exists():
    action, reason = decide("ABC-1", entries(2), has_paid_order=True)
    assert action == "review"
    assert "paid order" in reason


def test_auto_fixable_when_missing_sku_and_no_paid_order():
    action, reason = decide("", entries(3), has_paid_order=False)
    assert action == "auto_fixable"
    assert "missing SKU" in reason


def test_review_when_missing_sku_and_paid_order_exists():
    action, reason = decide("", entries(1), has_paid_order=True)
    assert action == "review"
    assert "missing SKU" in reason


def test_group_by_sku_groups_correctly():
    products = [
        {"id": 1, "sku": "ABC-1", "type": "product"},
        {"id": 2, "sku": "ABC-1", "type": "variation"},
        {"id": 3, "sku": "", "type": "product"},
        {"id": 4, "sku": " ", "type": "product"},
        {"id": 5, "sku": "XYZ-9", "type": "product"},
    ]
    groups = group_by_sku(products)
    assert len(groups["ABC-1"]) == 2
    assert len(groups[""]) == 2
    assert len(groups["XYZ-9"]) == 1


def test_group_by_sku_strips_whitespace():
    products = [{"id": 1, "sku": "  SPACED-1  ", "type": "product"}]
    groups = group_by_sku(products)
    assert "SPACED-1" in groups
    assert " SPACED-1  " not in groups
sku-audit.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, groupBySku } from "./sku-audit.js";

const entries = (n, startId = 1, type = "product") =>
  Array.from({ length: n }, (_, i) => ({ productId: startId + i, type }));

test("ok when unique sku", () => {
  assert.equal(decide("ABC-1", entries(1), false)[0], "ok");
});

test("auto_fixable when duplicate and no paid order", () => {
  const [action, reason] = decide("ABC-1", entries(2), false);
  assert.equal(action, "auto_fixable");
  assert.match(reason, /shared by 2/);
});

test("review when duplicate and paid order exists", () => {
  const [action, reason] = decide("ABC-1", entries(2), true);
  assert.equal(action, "review");
  assert.match(reason, /paid order/);
});

test("auto_fixable when missing sku and no paid order", () => {
  const [action, reason] = decide("", entries(3), false);
  assert.equal(action, "auto_fixable");
  assert.match(reason, /missing SKU/);
});

test("review when missing sku and paid order exists", () => {
  const [action, reason] = decide("", entries(1), true);
  assert.equal(action, "review");
  assert.match(reason, /missing SKU/);
});

test("groupBySku groups correctly", () => {
  const products = [
    { id: 1, sku: "ABC-1", type: "product" },
    { id: 2, sku: "ABC-1", type: "variation" },
    { id: 3, sku: "", type: "product" },
    { id: 4, sku: " ", type: "product" },
    { id: 5, sku: "XYZ-9", type: "product" },
  ];
  const groups = groupBySku(products);
  assert.equal(groups.get("ABC-1").length, 2);
  assert.equal(groups.get("").length, 2);
  assert.equal(groups.get("XYZ-9").length, 1);
});

test("groupBySku strips whitespace", () => {
  const groups = groupBySku([{ id: 1, sku: "  SPACED-1  ", type: "product" }]);
  assert.ok(groups.has("SPACED-1"));
  assert.ok(!groups.has(" SPACED-1  "));
});

Case studies

Bulk import

The migration that copied one SKU onto forty products

A store migrating from another platform ran a CSV import where the mapping tool pointed the SKU column at the wrong field. Forty unrelated products all landed with the same placeholder SKU, while their real SKUs from the old platform were dropped entirely.

The audit found the group of forty in its first run and confirmed none of them had a paid order yet, since the store had not gone live on the new catalog. The whole group was safe to auto-fix with fresh SKUs before a single sale happened.

Cloned product

The seasonal variant that never got its own SKU

A merchandiser duplicated a bestselling product to launch a limited color variant, reusing the "Copy to a new draft" button to save time. The SKU field carried over untouched, so the new variant quietly shared a SKU with the original once both went live.

By the time the audit ran, a handful of orders for the new variant had already gone through and Stripe confirmed the payments. That group was flagged for review instead of auto-fixed, since two live SKUs pointed at real revenue and only a person could tell which product each order was really for.

What good looks like

After this runs on a schedule, a broken import or a rushed clone gets caught within a week instead of surfacing months later as a wrong stock count or a misattributed sale. New conflicts with nothing at stake get cleaned up automatically, and anything touching real money lands in front of a person with the context to fix it correctly.

FAQ

Why do WooCommerce products end up with duplicate or missing SKUs?

A CSV import that skips SKU validation, a plugin that clones a product without changing its SKU, or two editors saving the same product at nearly the same time can all leave two products sharing one SKU or a product with a blank one. WooCommerce does not enforce a unique or required SKU at the database level, so nothing stops it from happening.

Is it safe to auto-fix a duplicate or missing SKU with a script?

It depends on whether a paid order already depends on that product. When Stripe confirms no succeeded payment references the item, assigning it a new placeholder SKU is low risk. When a real paid order is tied to the product, a person should decide which SKU is correct by hand, since a script cannot know which one is right.

How often should I run a SKU audit?

Once a week is enough for most stores, and right after any bulk import or catalog migration. Running it on read only mode costs nothing and catches conflicts long before they show up as a stock or reporting problem.

Related field notes

Citations

On the problem:

  1. WooCommerce docs: managing product data, including the SKU field and its behavior. woocommerce.com/document/managing-products
  2. WooCommerce docs: product CSV importer and exporter, including SKU handling during import. woocommerce.com/document/product-csv-importer-exporter
  3. WooCommerce REST API: the product object, including the read-only rules around the sku field. woocommerce.github.io/woocommerce-rest-api-docs

On the solution:

  1. WooCommerce REST API: list products and variations with pagination. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a PaymentIntent to confirm its current status. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce REST API: list orders and read line items to find which products they reference. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clean up your catalog?

If this saved you a stock mismatch or a misattributed sale, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes