Repair WooCommerce core: products and catalog
Broken featured images on WooCommerce products
A shopper opens a product page, or an order confirmation email lands in an inbox, and instead of the product photo there is a small broken image icon. The product is real, the price is right, the listing still sells, but the picture is gone. This is one of the quieter WooCommerce problems, because nothing crashes and no error shows up in a log. Here is why it happens and a small script that finds every product pointing at a missing file and clears it safely.
WooCommerce stores a featured image as an attachment ID on the product, not as a picture. When the file behind that ID is deleted, moved, or never finished uploading, the product keeps pointing at nothing and every page that shows the product renders a broken image icon. Run a small Python or Node.js script on a schedule that checks each product's featured image URL, and clears the image reference on any product whose file no longer resolves. WooCommerce then falls back to its placeholder image instead of a broken icon. Full code, tests, and a dry run guard are below.
The problem in plain words
A WooCommerce product does not carry its photo directly. It carries a reference, an attachment ID, that points at a media file stored in the WordPress uploads folder. The theme and the product page ask for that attachment's URL and drop it into an img tag.
That reference is only as good as the file behind it. If the file is gone, the browser gets a 404 for the image URL and shows its default broken image icon. The product itself is completely fine. Its title, price, description, and stock are untouched. Only the picture is missing, which makes this bug easy to miss until a customer or a teammate happens to look at the right page.
Why it happens
The WordPress media library and the product catalog are two separate systems joined by an ID. A few common ways they fall out of sync:
- A site migration or a host move copied the database but not the full
wp-content/uploadsfolder, so every attachment ID now points at a file that was never brought over. - A cleanup plugin, a media library tool, or a manual delete removed an image that still looked "unused" because nothing scans product featured image fields before deleting.
- A bulk import or a product sync job set the image field before the matching upload had actually finished, so the ID was saved a moment too early.
- A CDN or object storage integration changed how media URLs are built, and older attachments were never re-pointed at the new location.
WordPress core does not verify that an attachment ID still has a file behind it before rendering it. It trusts the reference. That is efficient, but it means a file removed anywhere outside of WooCommerce quietly breaks the product page with no warning to the shop manager.
The product's image field can be wrong, but the file system is the source of truth for whether the picture exists. A script that actually requests the image URL and checks the response, rather than trusting the database record, is the only reliable way to find every broken reference in the catalog.
The fix, as a flow
We do not touch products at random. We add a job that runs on a schedule, looks at products tied to orders that Stripe confirms were genuinely paid, and checks each one's featured image URL. If the URL does not resolve, we clear the image reference on the product so WooCommerce falls back to its placeholder instead of showing a broken icon.
Build it step by step
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 and write access to orders and products. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
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 LOOKBACK_HOURS="24"
export DRY_RUN="true" # start safe, change to false to write
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 LOOKBACK_HOURS="24"
export DRY_RUN="true" // start safe, change to false to write
List products from recent paid orders
Rather than scan the entire catalog blind, we start from orders WooCommerce marked Processing or Completed in the lookback window. Each order lists its line items, and each line item carries a product_id. We collect the unique product IDs worth checking from there.
import requests
from requests.auth import HTTPBasicAuth
WOO_URL = "https://yourstore.com"
AUTH = HTTPBasicAuth("ck_...", "cs_...")
def recent_paid_orders(since_iso):
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed", "after": since_iso, "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
const WOO_URL = "https://yourstore.com";
const AUTH = "Basic " + Buffer.from("ck_...:cs_...").toString("base64");
async function* recentPaidOrders(sinceIso) {
let page = 1;
while (true) {
const res = await fetch(
`${WOO_URL}/wp-json/wc/v3/orders?status=processing,completed&after=${sinceIso}&per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } }
);
const batch = await res.json();
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Confirm the order is really paid with Stripe
WooCommerce order status alone can be wrong. To keep this script from touching products on orders that only look paid, we read the Stripe PaymentIntent ID from the order, either from the _stripe_intent_id meta field or from transaction_id, and confirm the amount and status directly with Stripe before we bother checking that order's products.
PAID_STATUSES = {"processing", "completed"}
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 order_amount_minor(order):
return round(float(order["total"]) * 100)
def payment_confirmed(order, intent):
if order["status"] not in PAID_STATUSES:
return False
if intent is None or intent.get("status") != "succeeded":
return False
return abs(order_amount_minor(order) - intent.get("amount_received", 0)) <= 1
const PAID_STATUSES = new Set(["processing", "completed"]);
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;
}
function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
function paymentConfirmed(order, intent) {
if (!PAID_STATUSES.has(order.status)) return false;
if (!intent || intent.status !== "succeeded") return false;
return Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) <= 1;
}
Decide, with one pure function
Keep the decision in its own function that takes the product and whether its image resolved, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If the product has no image at all, skip it. If we could not judge reachability, skip it. If the image resolves, skip it. Otherwise, clear it.
def decide(product, image_reachable):
images = product.get("images") or []
if not images:
return ("skip", "product has no featured image")
if image_reachable is None:
return ("skip", "no reachability result to judge")
if image_reachable:
return ("skip", "featured image resolves fine")
return ("clear", "featured image file is missing (404 or error)")
export function decide(product, imageReachable) {
const images = product.images || [];
if (images.length === 0) return ["skip", "product has no featured image"];
if (imageReachable === null || imageReachable === undefined) {
return ["skip", "no reachability result to judge"];
}
if (imageReachable) return ["skip", "featured image resolves fine"];
return ["clear", "featured image file is missing (404 or error)"];
}
Check the file and clear the reference
A quick HEAD request against the image URL tells us whether the file is really there. Some hosts reject HEAD, so we fall back to a GET. When the action is clear, we update the product's images field to an empty list through the REST API. WooCommerce then shows its placeholder image instead of a broken icon, and nothing else about the product changes.
def image_url_reachable(url):
try:
r = requests.head(url, timeout=15, allow_redirects=True)
if r.status_code == 405:
r = requests.get(url, timeout=15, stream=True)
return r.status_code < 400
except requests.RequestException:
return False
def clear_featured_image(product_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"images": []},
auth=AUTH, timeout=30,
).raise_for_status()
async function imageUrlReachable(url) {
try {
let res = await fetch(url, { method: "HEAD" });
if (res.status === 405) res = await fetch(url, { method: "GET" });
return res.status < 400;
} catch {
return false;
}
}
async function clearFeaturedImage(productId) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({ images: [] }),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which products it would clear. Read the output, trust it, then switch it off to let it write. Run it once a day with cron, since featured images do not usually go missing in the middle of a checkout.
Always start with DRY_RUN=true. Clearing an image reference is easy to reverse by re-uploading the photo, but you still want to see the exact list of products before anything writes.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, confirms payment with Stripe before checking any product, logs what it does, respects the dry run flag, and is safe to run again and again because it only clears a product whose image truly does not resolve.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find and clear WooCommerce product featured images that point to a missing file.
This walks products that appear on recent paid orders (verified against Stripe so we
only touch products real customers actually bought), checks whether each product's
featured image URL resolves, and clears the image reference on any product whose file
is missing. WooCommerce then falls back to the store placeholder image instead of a
broken icon. Read only by default. Run on a schedule.
"""
import os
import time
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_broken_images")
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"),
)
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "24"))
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(product, image_reachable):
"""Pure decision: should we clear this product's featured image reference?"""
images = product.get("images") or []
if not images:
return ("skip", "product has no featured image")
if image_reachable is None:
return ("skip", "no reachability result to judge")
if image_reachable:
return ("skip", "featured image resolves fine")
return ("clear", "featured image file is missing (404 or error)")
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def payment_confirmed(order, intent):
"""True when Stripe confirms this order was really paid the amount on file."""
if order["status"] not in PAID_STATUSES:
return False
if intent is None or intent.get("status") != "succeeded":
return False
return abs(order_amount_minor(order) - intent.get("amount_received", 0)) <= 1
def recent_paid_orders(lookback_hours):
since = time.strftime(
"%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - lookback_hours * 3600)
)
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={
"status": "processing,completed",
"after": since,
"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 get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def get_product(product_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def image_url_reachable(url):
try:
r = requests.head(url, timeout=15, allow_redirects=True)
if r.status_code == 405:
r = requests.get(url, timeout=15, stream=True)
return r.status_code < 400
except requests.RequestException:
return False
def clear_featured_image(product_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"images": []},
auth=AUTH,
timeout=30,
).raise_for_status()
def run():
checked_products = {}
cleared = 0
for order in recent_paid_orders(LOOKBACK_HOURS):
intent = get_intent(intent_id_of(order))
if not payment_confirmed(order, intent):
continue
for line_item in order.get("line_items", []):
product_id = line_item.get("product_id")
if not product_id or product_id in checked_products:
continue
checked_products[product_id] = True
product = get_product(product_id)
if product is None:
log.warning("Product %s from order %s no longer exists", product_id, order["id"])
continue
images = product.get("images") or []
reachable = image_url_reachable(images[0]["src"]) if images else None
action, reason = decide(product, reachable)
if action == "skip":
continue
log.warning(
"Product %s: %s. %s", product_id, reason, "would clear" if DRY_RUN else "clearing"
)
if not DRY_RUN:
clear_featured_image(product_id)
cleared += 1
log.info("Done. %d product(s) %s.", cleared, "to clear" if DRY_RUN else "cleared")
if __name__ == "__main__":
run()
/**
* Find and clear WooCommerce product featured images that point to a missing file.
*
* This walks products that appear on recent paid orders (verified against Stripe so
* we only touch products real customers actually bought), checks whether each
* product's featured image URL resolves, and clears the image reference on any
* product whose file is missing. WooCommerce then falls back to the store
* placeholder image instead of a broken icon. Read only by default. Run on a schedule.
*/
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 LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 24);
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;
}
export function decide(product, imageReachable) {
const images = product.images || [];
if (images.length === 0) return ["skip", "product has no featured image"];
if (imageReachable === null || imageReachable === undefined) {
return ["skip", "no reachability result to judge"];
}
if (imageReachable) return ["skip", "featured image resolves fine"];
return ["clear", "featured image file is missing (404 or error)"];
}
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
export function paymentConfirmed(order, intent) {
if (!PAID_STATUSES.has(order.status)) return false;
if (!intent || intent.status !== "succeeded") return false;
return Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) <= 1;
}
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.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* recentPaidOrders(lookbackHours) {
const since = new Date(Date.now() - lookbackHours * 3600 * 1000).toISOString();
let page = 1;
while (true) {
const batch = await woo(
`/orders?status=processing,completed&after=${since}&per_page=50&page=${page}`
);
if (!batch || batch.length === 0) return;
for (const order of batch) yield order;
page++;
}
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function imageUrlReachable(url) {
try {
let res = await fetch(url, { method: "HEAD" });
if (res.status === 405) res = await fetch(url, { method: "GET" });
return res.status < 400;
} catch {
return false;
}
}
async function clearFeaturedImage(productId) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({ images: [] }),
});
}
export async function run() {
const checked = new Set();
let cleared = 0;
for await (const order of recentPaidOrders(LOOKBACK_HOURS)) {
const intent = await getIntent(intentIdOf(order));
if (!paymentConfirmed(order, intent)) continue;
for (const lineItem of order.line_items || []) {
const productId = lineItem.product_id;
if (!productId || checked.has(productId)) continue;
checked.add(productId);
const product = await woo(`/products/${productId}`);
if (!product) {
console.warn(`Product ${productId} from order ${order.id} no longer exists`);
continue;
}
const images = product.images || [];
const reachable = images.length ? await imageUrlReachable(images[0].src) : null;
const [action, reason] = decide(product, reachable);
if (action === "skip") continue;
console.warn(`Product ${productId}: ${reason}. ${DRY_RUN ? "would clear" : "clearing"}`);
if (!DRY_RUN) await clearFeaturedImage(productId);
cleared++;
}
}
console.log(`Done. ${cleared} product(s) ${DRY_RUN ? "to clear" : "cleared"}.`);
}
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 payment check are the parts most worth testing, because together they decide which products get touched. Because we kept decide and paymentConfirmed pure, the tests need no network and no Stripe account. They just feed in plain objects and check the result.
from repair_broken_images import decide, intent_id_of, payment_confirmed
def product(**over):
base = {"id": 1, "images": [{"id": 55, "src": "https://example.com/wp-content/uploads/photo.jpg"}]}
base.update(over)
return base
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000}
base.update(over)
return base
def test_clear_when_image_not_reachable():
assert decide(product(), False)[0] == "clear"
def test_skip_when_image_reachable():
assert decide(product(), True)[0] == "skip"
def test_skip_when_no_images_at_all():
assert decide(product(images=[]), None)[0] == "skip"
def test_payment_confirmed_true_when_matching_and_succeeded():
order = {"status": "processing", "total": "50.00"}
assert payment_confirmed(order, intent()) is True
def test_payment_confirmed_false_when_amount_mismatch():
order = {"status": "processing", "total": "80.00"}
assert payment_confirmed(order, intent()) is False
def test_intent_id_from_meta():
order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
assert intent_id_of(order) == "pi_123"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, paymentConfirmed } from "./repair-broken-images.js";
const product = (over = {}) => ({
id: 1,
images: [{ id: 55, src: "https://example.com/wp-content/uploads/photo.jpg" }],
...over,
});
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });
test("clear when image not reachable", () => {
assert.equal(decide(product(), false)[0], "clear");
});
test("skip when image reachable", () => {
assert.equal(decide(product(), true)[0], "skip");
});
test("skip when no images at all", () => {
assert.equal(decide(product({ images: [] }), null)[0], "skip");
});
test("paymentConfirmed true when matching and succeeded", () => {
assert.equal(paymentConfirmed({ status: "processing", total: "50.00" }, intent()), true);
});
test("paymentConfirmed false when amount mismatch", () => {
assert.equal(paymentConfirmed({ status: "processing", total: "80.00" }, intent()), false);
});
test("intentIdOf from meta", () => {
assert.equal(
intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }),
"pi_123"
);
});
Case studies
The move that left the pictures behind
A store moved hosts and the team copied the WordPress database but ran out of time to fully sync wp-content/uploads before going live. Roughly three hundred products, mostly older, seasonal items, showed a broken image icon on the new server while everything else worked.
The script ran in dry run first, listed every affected product in under a minute, and the team re-uploaded the real photos for the top sellers while letting the fix clear the rest to the placeholder so the catalog looked clean immediately.
The unused media cleaner that was not so sure
A media cleanup plugin was installed to shrink a bloated uploads folder. It flagged a batch of images as unused and deleted them, but its scan missed images that were only referenced through a product's gallery field rather than the main content editor.
Nobody noticed until a customer sent a screenshot of a broken thumbnail. The script found nineteen affected products the same day it was scheduled to run and cleared every one, well before the next sale.
After this runs on a schedule, a deleted or missing image file becomes a placeholder for a day instead of a broken icon indefinitely. Keep it running even after you find the root cause of a specific incident, since files can go missing again from a completely different cause next time.
FAQ
Why does my WooCommerce product show a broken image icon?
The product still points at an attachment ID whose file no longer exists in the uploads folder. This happens after a migration that skipped media, a plugin or host that deleted unused files, or an upload that never finished. A script that checks each featured image URL and clears the reference when the file is gone fixes it.
Is it safe to clear a product's image with a script?
Yes, when the script only clears a product after confirming the image URL truly does not resolve, and it only checks products tied to real, Stripe confirmed paid orders. Start in dry run mode to review the exact list before it writes anything.
Will clearing the image delete the product or its data?
No. Clearing the featured image only removes the broken image reference from the product. WooCommerce then falls back to the store's placeholder image. The product, its price, its stock, and its orders are untouched.
Related field notes
Citations
On the problem:
- WordPress Developer docs: how attachments and media are stored and referenced. developer.wordpress.org/apis/handbook/rest-api/reference/media
- WooCommerce docs: managing product images and galleries. woocommerce.com/document/managing-product-images
- WordPress support forum: featured images broken after a site or host migration. wordpress.org/support/topic/images-broken-after-migration
On the solution:
- WooCommerce REST API: read and update products, including the images field. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: list and filter orders by status and date. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent to confirm its status and amount. docs.stripe.com/api/payment_intents/retrieve
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.
Did this fix your broken images?
If this saved you a pile of "why does my product look broken" messages, 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