Skip to content

Diagnostic

Cover image missing or duplicated, breaking storefront image links

A product should always have exactly one cover image, the picture the storefront shows in listings and on the product page. Sometimes it has none, and the main image link falls back to nothing. Sometimes it has two, and an API upload that should have just added a picture throws a database error instead. Both are the same underlying gap: nothing keeps the cover flag exactly right when the write path is a CSV import, a product duplication, or an interrupted webservice call. Here is why it happens and a small script that finds and safely fixes it.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A product photo shoot setup
Photo by Randy Fath on Unsplash
The short answer

PrestaShop's ps_image table has a unique key on (id_product, cover), so the database itself only ever allows one row per product where cover = 1. But nothing in the webservice image upload path, POST /api/images/products/{id}, checks for an existing cover before inserting a new one, so pushing a second image at a product that already has a cover throws a duplicate key SQL error, tracked in PrestaShop/PrestaShop#22803 and #23777. Separately, CSV import, product duplication, and an interrupted write can leave a product with zero cover rows, since the cover flag is not copied or assigned automatically, which breaks the storefront main image lookup, Image::getCover($id_product). Run a Python or Node.js script that reads every product's images, counts how many have cover == 1, and reports the products with zero or more than one. Full code, tests, and a guarded repair path are below.

The problem in plain words

Every product with photos is supposed to have one, and only one, image marked as the cover. That is the picture that shows up in category listings, in search results, and as the big image on the product page before a shopper clicks a thumbnail. The database enforces this with a unique key on (id_product, cover) in ps_image, so PrestaShop itself will never let two rows sit at cover = 1 for the same product at the same time.

The trouble is that this guarantee only protects the database from a bad write. It does nothing to guarantee a write happens correctly in the first place. A CSV import can bring in new images without ever touching the cover flag. Duplicating a product can copy the images but skip re-assigning which one is the cover. And the webservice image upload endpoint never checks whether a cover already exists before it tries to insert a new image as one, so a second upload at a product that already has a cover fails outright, and the write is partial or rejected. Either way a product ends up with zero cover rows or two, and the storefront main image lookup expects exactly one.

Import, duplicate, or API upload cover flag not managed ps_image rows cover count drifts unique key catches writes, not gaps Zero covers getCover finds nothing Two covers next insert throws Storefront image link broken
The unique key stops two covers from being written at once, but it cannot stop a product from ending up with zero, and the API's own upload path has no check that would prevent hitting the key when a cover already exists.

Why it happens

The root cause sits in the gap between what the database enforces and what the application code manages. A few concrete ways stores end up here:

Either way the storefront main image lookup, Image::getCover($id_product), expects exactly one cover row and returns nothing useful when there are zero, so the main image link 404s or falls back to a placeholder. See the citations at the end for the exact issues and forum reports.

The key insight

The webservice has no atomic "set this image as cover" call. A naive attempt to fix a multi cover product by writing a new cover directly can hit the very same unique key conflict that caused the original error. So the safe approach is to treat every affected product as flag first, fix later: read the cover state, report anything wrong, and only write when a human has opted into repair, one image at a time, re-checking after every write.

The fix, as a flow

We do not touch the broken upload path. Instead we read every product's images, count how many carry cover = 1, and classify each product as fine, missing a cover, or carrying duplicates. By default the job only reports. The guarded repair path demotes every extra cover but one, or promotes a chosen image when there are none, using individual PUT calls, confirming the result after each one before moving on.

Read product images id_image, cover, position classifyCoverState pure, no I/O ok, or no_cover, or multi_cover? broken, report Report by default DRY_RUN true, no write One PUT re-read, confirm ok, skip No action needed
Every product is classified with one pure function. Broken products are reported by default, and the guarded repair changes one image at a time, re-reading after each write to confirm before moving on.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with access to products and images. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   # start safe, change to false to repair covers
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true"   // start safe, change to false to repair covers
2

Enumerate products, then read each product's images

Call GET products?display=full&filter[active]=1&output_format=JSON, paginated with &limit=0,100, to enumerate active product ids, or hit GET products/{id}?output_format=JSON for a single targeted check. For each id, call GET images/products/{id_product}?output_format=JSON to get the list of image ids on that product.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()

def active_product_ids(limit="0,100"):
    data = api_get("products", params={"display": "full", "filter[active]": "1", "limit": limit})
    products = data.get("products") or []
    if isinstance(products, dict):
        products = [products]
    return [int(p["id"]) for p in products]

def product_image_ids(id_product):
    data = api_get(f"images/products/{id_product}")
    images = data.get("image") or []
    if isinstance(images, dict):
        images = [images]
    return [int(img["id"]) for img in images]
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function activeProductIds(limit = "0,100") {
  const data = await apiGet("products", { display: "full", "filter[active]": "1", limit });
  let products = data.products || [];
  if (!Array.isArray(products)) products = [products];
  return products.map((p) => Number(p.id));
}

async function productImageIds(idProduct) {
  const data = await apiGet(`images/products/${idProduct}`);
  let images = data.image || [];
  if (!Array.isArray(images)) images = [images];
  return images.map((img) => Number(img.id));
}
3

Fetch each image's cover flag and position

The plain images list under a product only gives you ids, not the cover flag. Fetch each image's full record with GET images/products/{id_product}/{id_image}?output_format=JSON and read its cover and position fields. Products with zero images are skipped entirely, since there is no cover to check.

step3.py
def fetch_image_record(id_product, id_image):
    data = api_get(f"images/products/{id_product}/{id_image}")
    img = data.get("image") or {}
    return {
        "id_image": int(img.get("id", id_image)),
        "cover": img.get("cover"),
        "position": int(img.get("position", 0)),
    }

def fetch_product_images(id_product):
    return [fetch_image_record(id_product, iid) for iid in product_image_ids(id_product)]
step3.js
async function fetchImageRecord(idProduct, idImage) {
  const data = await apiGet(`images/products/${idProduct}/${idImage}`);
  const img = data.image || {};
  return {
    id_image: Number(img.id ?? idImage),
    cover: img.cover,
    position: Number(img.position ?? 0),
  };
}

async function fetchProductImages(idProduct) {
  const ids = await productImageIds(idProduct);
  const records = [];
  for (const idImage of ids) records.push(await fetchImageRecord(idProduct, idImage));
  return records;
}
4

Decide, with one pure function

Keep the decision in its own function that takes only the images array you already fetched, no I/O at all. It counts how many images carry a truthy cover, and picks which single image should end up as the cover, using the lowest position with ties broken by the lowest id_image. This is the single decision point unit tests target: given any array of image and cover and position tuples, it deterministically says whether the product is fine, has zero covers, or has duplicate covers.

decide.py
def _truthy_cover(value):
    return value is True or value == "1" or value == 1

def classify_cover_state(images):
    """
    images: [{'id_image': int|str, 'cover': '0'|'1'|bool, 'position': int}, ...]
    Returns {'status': 'ok'|'no_cover'|'multi_cover'|'no_images', 'coverIds': [...], 'chosenCoverId': ...}
    """
    if not images:
        return {"status": "no_images", "coverIds": [], "chosenCoverId": None}

    cover_ids = [img["id_image"] for img in images if _truthy_cover(img.get("cover"))]

    if len(cover_ids) == 1:
        return {"status": "ok", "coverIds": cover_ids, "chosenCoverId": cover_ids[0]}

    def sort_key(img):
        return (img.get("position", 0), img["id_image"])

    if len(cover_ids) == 0:
        chosen = sorted(images, key=sort_key)[0]["id_image"]
        return {"status": "no_cover", "coverIds": [], "chosenCoverId": chosen}

    cover_images = [img for img in images if img["id_image"] in cover_ids]
    chosen = sorted(cover_images, key=sort_key)[0]["id_image"]
    return {"status": "multi_cover", "coverIds": cover_ids, "chosenCoverId": chosen}
decide.js
function truthyCover(value) {
  return value === true || value === "1" || value === 1;
}

export function classifyCoverState(images) {
  // images: [{ id_image, cover: "0"|"1"|boolean, position: number }, ...]
  if (!images || images.length === 0) {
    return { status: "no_images", coverIds: [], chosenCoverId: null };
  }

  const coverIds = images.filter((img) => truthyCover(img.cover)).map((img) => img.id_image);

  if (coverIds.length === 1) {
    return { status: "ok", coverIds, chosenCoverId: coverIds[0] };
  }

  const byPositionThenId = (a, b) =>
    (a.position ?? 0) - (b.position ?? 0) || (a.id_image > b.id_image ? 1 : a.id_image < b.id_image ? -1 : 0);

  if (coverIds.length === 0) {
    const chosen = [...images].sort(byPositionThenId)[0].id_image;
    return { status: "no_cover", coverIds: [], chosenCoverId: chosen };
  }

  const coverImages = images.filter((img) => coverIds.includes(img.id_image));
  const chosen = [...coverImages].sort(byPositionThenId)[0].id_image;
  return { status: "multi_cover", coverIds, chosenCoverId: chosen };
}
5

Report by default, never bulk write

For every product where classifyCoverState returns no_cover or multi_cover, log a row with the product id, the status, the current cover ids, and the suggested cover id. Nothing is written unless the repair path is explicitly enabled.

report.py
def report_product(id_product, classification, log):
    if classification["status"] in ("ok", "no_images"):
        return
    log.warning(
        "Product %s status=%s coverIds=%s suggestedCoverId=%s",
        id_product, classification["status"], classification["coverIds"], classification["chosenCoverId"],
    )
report.js
function reportProduct(idProduct, classification) {
  if (classification.status === "ok" || classification.status === "no_images") return;
  console.warn(
    `Product ${idProduct} status=${classification.status} coverIds=${JSON.stringify(classification.coverIds)} suggestedCoverId=${classification.chosenCoverId}`
  );
}
6

The guarded repair, one image at a time

Only when DRY_RUN is false and repair is explicitly enabled: for a multi_cover product, demote every extra cover image, one at a time, with PUT images/products/{id_product}/{id_image} sending {"image": {"id": id_image, "cover": "0"}}, for every id in coverIds except chosenCoverId. For a no_cover product, promote chosenCoverId the same way with cover: "1". After every single PUT, re-read the image and confirm the resulting state before touching the next one, since a partial failure from the same unique key constraint, PrestaShop#22803, must not be retried blindly.

Run it safe

Always start with DRY_RUN=true. This job is a diagnostic first: its default output is a report of products with zero or duplicate covers, not a fix. Only enable writes once you have reviewed the report, and remember every write is a single PUT followed by a re-read, never a bulk change.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, never bulk-writes cover flags, defaults to reporting only, and re-verifies every write before moving to the next product.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
fix_cover_image.py
"""Detect and safely repair PrestaShop products whose ps_image cover flag is
missing or duplicated, which breaks the storefront main image link.

ps_image enforces a unique key on (id_product, cover), so the database only
ever allows one row per product where cover = 1. But the webservice image
upload path, POST /api/images/products/{id}, never checks for an existing
cover before inserting a new image, so a second cover upload on a product
that already has one throws a duplicate key SQL error, tracked in
PrestaShop/PrestaShop#22803 and #23777. Separately, CSV import, product
duplication, and an interrupted API write can leave a product with zero
cover rows, since the cover flag is not copied or assigned automatically,
which breaks Image::getCover($id_product) on the storefront.

This script reads each product's images, classifies the cover state with a
pure function, and reports every product with zero or more than one cover.
Under DRY_RUN=true it only reports. The guarded repair path, only run with
DRY_RUN=false, demotes every extra cover but one or promotes a chosen image,
one PUT at a time, re-reading after each write to confirm the result before
moving to the next product.

Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

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

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def _truthy_cover(value):
    return value is True or value == "1" or value == 1


def classify_cover_state(images):
    """Pure decision function, no I/O.

    images: [{'id_image': int|str, 'cover': '0'|'1'|bool, 'position': int}, ...]

    Returns {'status': 'ok'|'no_cover'|'multi_cover'|'no_images', 'coverIds': [...], 'chosenCoverId': ...}
    """
    if not images:
        return {"status": "no_images", "coverIds": [], "chosenCoverId": None}

    cover_ids = [img["id_image"] for img in images if _truthy_cover(img.get("cover"))]

    if len(cover_ids) == 1:
        return {"status": "ok", "coverIds": cover_ids, "chosenCoverId": cover_ids[0]}

    def sort_key(img):
        return (img.get("position", 0), img["id_image"])

    if len(cover_ids) == 0:
        chosen = sorted(images, key=sort_key)[0]["id_image"]
        return {"status": "no_cover", "coverIds": [], "chosenCoverId": chosen}

    cover_images = [img for img in images if img["id_image"] in cover_ids]
    chosen = sorted(cover_images, key=sort_key)[0]["id_image"]
    return {"status": "multi_cover", "coverIds": cover_ids, "chosenCoverId": chosen}


def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def api_put_cover(id_product, id_image, cover_value):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/images/products/{id_product}/{id_image}",
        params={"output_format": "JSON"},
        auth=AUTH,
        json={"image": {"id": id_image, "cover": cover_value}},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def active_product_ids(limit="0,100"):
    data = api_get("products", params={"display": "full", "filter[active]": "1", "limit": limit})
    products = data.get("products") or []
    if isinstance(products, dict):
        products = [products]
    return [int(p["id"]) for p in products]


def product_image_ids(id_product):
    data = api_get(f"images/products/{id_product}")
    images = data.get("image") or []
    if isinstance(images, dict):
        images = [images]
    return [int(img["id"]) for img in images]


def fetch_image_record(id_product, id_image):
    data = api_get(f"images/products/{id_product}/{id_image}")
    img = data.get("image") or {}
    return {
        "id_image": int(img.get("id", id_image)),
        "cover": img.get("cover"),
        "position": int(img.get("position", 0)),
    }


def fetch_product_images(id_product):
    return [fetch_image_record(id_product, iid) for iid in product_image_ids(id_product)]


def report_product(id_product, classification):
    if classification["status"] in ("ok", "no_images"):
        return
    log.warning(
        "Product %s status=%s coverIds=%s suggestedCoverId=%s",
        id_product, classification["status"], classification["coverIds"], classification["chosenCoverId"],
    )


def repair_product(id_product, classification):
    status = classification["status"]
    chosen = classification["chosenCoverId"]

    if status == "multi_cover":
        for id_image in classification["coverIds"]:
            if id_image == chosen:
                continue
            api_put_cover(id_product, id_image, "0")
            confirmed = fetch_image_record(id_product, id_image)
            if _truthy_cover(confirmed.get("cover")):
                raise RuntimeError(
                    f"Product {id_product} image {id_image} still cover after demote, stopping"
                )
        api_put_cover(id_product, chosen, "1")
        confirmed = fetch_image_record(id_product, chosen)
        if not _truthy_cover(confirmed.get("cover")):
            raise RuntimeError(f"Product {id_product} chosen cover {chosen} did not confirm, stopping")

    elif status == "no_cover":
        api_put_cover(id_product, chosen, "1")
        confirmed = fetch_image_record(id_product, chosen)
        if not _truthy_cover(confirmed.get("cover")):
            raise RuntimeError(f"Product {id_product} chosen cover {chosen} did not confirm, stopping")


def run(product_ids):
    broken = 0
    for id_product in product_ids:
        images = fetch_product_images(id_product)
        classification = classify_cover_state(images)
        if classification["status"] in ("ok", "no_images"):
            continue
        broken += 1
        report_product(id_product, classification)
        if not DRY_RUN:
            log.info("Repairing product %s (%s)", id_product, classification["status"])
            repair_product(id_product, classification)
    log.info("Done. %d product(s) with a broken cover %s.", broken, "found" if DRY_RUN else "repaired")


if __name__ == "__main__":
    target_product_ids = [int(p) for p in os.environ.get("PRODUCT_IDS", "").split(",") if p.strip()]
    run(target_product_ids)
fix-cover-image.js
/**
 * Detect and safely repair PrestaShop products whose ps_image cover flag is
 * missing or duplicated, which breaks the storefront main image link.
 *
 * ps_image enforces a unique key on (id_product, cover), so the database only
 * ever allows one row per product where cover = 1. But the webservice image
 * upload path, POST /api/images/products/{id}, never checks for an existing
 * cover before inserting a new image, so a second cover upload on a product
 * that already has one throws a duplicate key SQL error, tracked in
 * PrestaShop/PrestaShop#22803 and #23777. Separately, CSV import, product
 * duplication, and an interrupted API write can leave a product with zero
 * cover rows, since the cover flag is not copied or assigned automatically,
 * which breaks Image::getCover($id_product) on the storefront.
 *
 * This script reads each product's images, classifies the cover state with a
 * pure function, and reports every product with zero or more than one cover.
 * Under DRY_RUN=true it only reports. The guarded repair path, only run with
 * DRY_RUN=false, demotes every extra cover but one or promotes a chosen image,
 * one PUT at a time, re-reading after each write to confirm the result before
 * moving to the next product.
 *
 * Guide: https://www.allanninal.dev/prestashop/cover-image-missing-or-duplicated/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

function truthyCover(value) {
  return value === true || value === "1" || value === 1;
}

/**
 * Pure decision function, no I/O.
 *
 * images: [{ id_image, cover: "0"|"1"|boolean, position }, ...]
 *
 * Returns { status: "ok"|"no_cover"|"multi_cover"|"no_images", coverIds: [...], chosenCoverId }
 */
export function classifyCoverState(images) {
  if (!images || images.length === 0) {
    return { status: "no_images", coverIds: [], chosenCoverId: null };
  }

  const coverIds = images.filter((img) => truthyCover(img.cover)).map((img) => img.id_image);

  if (coverIds.length === 1) {
    return { status: "ok", coverIds, chosenCoverId: coverIds[0] };
  }

  const byPositionThenId = (a, b) =>
    (a.position ?? 0) - (b.position ?? 0) || (a.id_image > b.id_image ? 1 : a.id_image < b.id_image ? -1 : 0);

  if (coverIds.length === 0) {
    const chosen = [...images].sort(byPositionThenId)[0].id_image;
    return { status: "no_cover", coverIds: [], chosenCoverId: chosen };
  }

  const coverImages = images.filter((img) => coverIds.includes(img.id_image));
  const chosen = [...coverImages].sort(byPositionThenId)[0].id_image;
  return { status: "multi_cover", coverIds, chosenCoverId: chosen };
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function apiPutCover(idProduct, idImage, coverValue) {
  const url = new URL(`${PRESTASHOP_URL}/api/images/products/${idProduct}/${idImage}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ image: { id: idImage, cover: coverValue } }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT images/products/${idProduct}/${idImage}`);
  return res.json();
}

async function activeProductIds(limit = "0,100") {
  const data = await apiGet("products", { display: "full", "filter[active]": "1", limit });
  let products = data.products || [];
  if (!Array.isArray(products)) products = [products];
  return products.map((p) => Number(p.id));
}

async function productImageIds(idProduct) {
  const data = await apiGet(`images/products/${idProduct}`);
  let images = data.image || [];
  if (!Array.isArray(images)) images = [images];
  return images.map((img) => Number(img.id));
}

async function fetchImageRecord(idProduct, idImage) {
  const data = await apiGet(`images/products/${idProduct}/${idImage}`);
  const img = data.image || {};
  return {
    id_image: Number(img.id ?? idImage),
    cover: img.cover,
    position: Number(img.position ?? 0),
  };
}

async function fetchProductImages(idProduct) {
  const ids = await productImageIds(idProduct);
  const records = [];
  for (const idImage of ids) records.push(await fetchImageRecord(idProduct, idImage));
  return records;
}

function reportProduct(idProduct, classification) {
  if (classification.status === "ok" || classification.status === "no_images") return;
  console.warn(
    `Product ${idProduct} status=${classification.status} coverIds=${JSON.stringify(classification.coverIds)} suggestedCoverId=${classification.chosenCoverId}`
  );
}

async function repairProduct(idProduct, classification) {
  const { status, chosenCoverId } = classification;

  if (status === "multi_cover") {
    for (const idImage of classification.coverIds) {
      if (idImage === chosenCoverId) continue;
      await apiPutCover(idProduct, idImage, "0");
      const confirmed = await fetchImageRecord(idProduct, idImage);
      if (truthyCover(confirmed.cover)) {
        throw new Error(`Product ${idProduct} image ${idImage} still cover after demote, stopping`);
      }
    }
    await apiPutCover(idProduct, chosenCoverId, "1");
    const confirmed = await fetchImageRecord(idProduct, chosenCoverId);
    if (!truthyCover(confirmed.cover)) {
      throw new Error(`Product ${idProduct} chosen cover ${chosenCoverId} did not confirm, stopping`);
    }
  } else if (status === "no_cover") {
    await apiPutCover(idProduct, chosenCoverId, "1");
    const confirmed = await fetchImageRecord(idProduct, chosenCoverId);
    if (!truthyCover(confirmed.cover)) {
      throw new Error(`Product ${idProduct} chosen cover ${chosenCoverId} did not confirm, stopping`);
    }
  }
}

export async function run(productIds) {
  let broken = 0;
  for (const idProduct of productIds) {
    const images = await fetchProductImages(idProduct);
    const classification = classifyCoverState(images);
    if (classification.status === "ok" || classification.status === "no_images") continue;
    broken++;
    reportProduct(idProduct, classification);
    if (!DRY_RUN) {
      console.log(`Repairing product ${idProduct} (${classification.status})`);
      await repairProduct(idProduct, classification);
    }
  }
  console.log(`Done. ${broken} product(s) with a broken cover ${DRY_RUN ? "found" : "repaired"}.`);
  return broken;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const targetProductIds = (process.env.PRODUCT_IDS || "")
    .split(",").map((s) => s.trim()).filter(Boolean).map(Number);
  run(targetProductIds).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision function is the part most worth testing, because it decides which products get reported and which single image would become the cover. Because we kept classify_cover_state pure, the test needs no network and no PrestaShop store. It just feeds in plain fixtures and checks the answer.

test_cover_image_classify.py
from fix_cover_image import classify_cover_state


def test_no_images_means_no_images_status():
    result = classify_cover_state([])
    assert result == {"status": "no_images", "coverIds": [], "chosenCoverId": None}


def test_ok_when_exactly_one_cover():
    images = [
        {"id_image": 1, "cover": "1", "position": 0},
        {"id_image": 2, "cover": "0", "position": 1},
    ]
    result = classify_cover_state(images)
    assert result == {"status": "ok", "coverIds": [1], "chosenCoverId": 1}


def test_no_cover_picks_lowest_position():
    images = [
        {"id_image": 5, "cover": "0", "position": 2},
        {"id_image": 3, "cover": "0", "position": 0},
        {"id_image": 4, "cover": "0", "position": 1},
    ]
    result = classify_cover_state(images)
    assert result["status"] == "no_cover"
    assert result["coverIds"] == []
    assert result["chosenCoverId"] == 3


def test_no_cover_breaks_position_tie_by_lowest_id():
    images = [
        {"id_image": 9, "cover": "0", "position": 0},
        {"id_image": 2, "cover": "0", "position": 0},
    ]
    result = classify_cover_state(images)
    assert result["status"] == "no_cover"
    assert result["chosenCoverId"] == 2


def test_multi_cover_flags_all_cover_ids_and_chooses_lowest_position():
    images = [
        {"id_image": 1, "cover": "1", "position": 3},
        {"id_image": 2, "cover": "1", "position": 0},
        {"id_image": 3, "cover": "0", "position": 1},
    ]
    result = classify_cover_state(images)
    assert result["status"] == "multi_cover"
    assert sorted(result["coverIds"]) == [1, 2]
    assert result["chosenCoverId"] == 2


def test_multi_cover_breaks_position_tie_by_lowest_id():
    images = [
        {"id_image": 7, "cover": "1", "position": 0},
        {"id_image": 4, "cover": "1", "position": 0},
    ]
    result = classify_cover_state(images)
    assert result["status"] == "multi_cover"
    assert result["chosenCoverId"] == 4


def test_boolean_true_is_treated_as_cover():
    images = [
        {"id_image": 1, "cover": True, "position": 0},
        {"id_image": 2, "cover": False, "position": 1},
    ]
    result = classify_cover_state(images)
    assert result == {"status": "ok", "coverIds": [1], "chosenCoverId": 1}
fix-cover-image.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCoverState } from "./fix-cover-image.js";

test("no images means no_images status", () => {
  const result = classifyCoverState([]);
  assert.deepEqual(result, { status: "no_images", coverIds: [], chosenCoverId: null });
});

test("ok when exactly one cover", () => {
  const images = [
    { id_image: 1, cover: "1", position: 0 },
    { id_image: 2, cover: "0", position: 1 },
  ];
  const result = classifyCoverState(images);
  assert.deepEqual(result, { status: "ok", coverIds: [1], chosenCoverId: 1 });
});

test("no_cover picks lowest position", () => {
  const images = [
    { id_image: 5, cover: "0", position: 2 },
    { id_image: 3, cover: "0", position: 0 },
    { id_image: 4, cover: "0", position: 1 },
  ];
  const result = classifyCoverState(images);
  assert.equal(result.status, "no_cover");
  assert.deepEqual(result.coverIds, []);
  assert.equal(result.chosenCoverId, 3);
});

test("no_cover breaks position tie by lowest id", () => {
  const images = [
    { id_image: 9, cover: "0", position: 0 },
    { id_image: 2, cover: "0", position: 0 },
  ];
  const result = classifyCoverState(images);
  assert.equal(result.status, "no_cover");
  assert.equal(result.chosenCoverId, 2);
});

test("multi_cover flags all cover ids and chooses lowest position", () => {
  const images = [
    { id_image: 1, cover: "1", position: 3 },
    { id_image: 2, cover: "1", position: 0 },
    { id_image: 3, cover: "0", position: 1 },
  ];
  const result = classifyCoverState(images);
  assert.equal(result.status, "multi_cover");
  assert.deepEqual([...result.coverIds].sort(), [1, 2]);
  assert.equal(result.chosenCoverId, 2);
});

test("multi_cover breaks position tie by lowest id", () => {
  const images = [
    { id_image: 7, cover: "1", position: 0 },
    { id_image: 4, cover: "1", position: 0 },
  ];
  const result = classifyCoverState(images);
  assert.equal(result.status, "multi_cover");
  assert.equal(result.chosenCoverId, 4);
});

test("boolean true is treated as cover", () => {
  const images = [
    { id_image: 1, cover: true, position: 0 },
    { id_image: 2, cover: false, position: 1 },
  ];
  const result = classifyCoverState(images);
  assert.deepEqual(result, { status: "ok", coverIds: [1], chosenCoverId: 1 });
});

Case studies

CSV import

The migrated catalog with silent blank tiles

A homeware store moved five thousand products from an old cart with a CSV import that carried images but no cover column. The import ran clean, no errors, and the catalog looked complete in the back office grid. Weeks later a customer flagged that half the category page showed blank tiles where a product photo should be.

Running the diagnostic against the full catalog found several hundred products classified no_cover, every one an import casualty. The team reviewed the suggested cover ids, which defaulted to each product's first-position image, and ran the guarded repair to promote them, re-verifying every single one before calling the catalog fixed.

Webservice sync

The nightly sync that started throwing duplicate key errors

A PIM system pushed new product photos every night through the webservice image upload endpoint, and for months it worked fine on brand-new products with no existing images. Then the vendor added a feature to also refresh photos on existing products, and the sync log started filling up with SQL duplicate key errors on ps_image for products that already had a cover set.

The diagnostic confirmed every failing product was carrying two cover-flagged rows from the half-completed sync attempts. Instead of patching the sync tool immediately, the team ran the flag-only report to size the damage, then used the guarded, one-image-at-a-time repair to demote the extra covers before fixing the sync logic that caused the duplicate uploads in the first place.

What good looks like

After this runs on a schedule, no product silently drifts to zero or duplicate cover images without someone finding out. The report gives you the exact products and their suggested fix, so a human can review before anything writes, and the guarded repair only ever changes one image at a time, confirming the state after each write rather than trusting a bulk update against a table that already rejects bad writes on its own.

FAQ

Why does my PrestaShop product show no main image on the storefront?

The storefront main image lookup, Image::getCover, expects exactly one row in ps_image where cover equals 1 for that product. CSV import, product duplication, and interrupted webservice writes can leave a product with zero cover rows, so the lookup finds nothing and the storefront falls back to no image or a 404 link.

Why did uploading a second image through the API throw a duplicate key error?

The ps_image table enforces a unique key on (id_product, cover), so the database only ever allows one row per product where cover is 1. The webservice image upload path never checks for an existing cover before inserting a new image, so pushing a second cover image at a product that already has one throws a duplicate key SQL error, tracked in PrestaShop/PrestaShop#22803 and #23777.

Is it safe to auto fix a product with no cover or two covers?

Flagging is always safe since it only reads. Auto fixing is safe when it is guarded by DRY_RUN, changes one image at a time with individual PUT calls rather than a bulk write, and re-reads the image after every PUT to confirm the result before moving to the next product, since a partial failure from the same unique key constraint must never be retried blindly.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: WebService, adding product image fails when another image is set as cover, issue #22803. github.com/PrestaShop/PrestaShop/issues/22803
  2. PrestaShop GitHub: API gives a 400 or SQL error when uploading a new image to an existing product, issue #23777. github.com/PrestaShop/PrestaShop/issues/23777
  3. PrestaShop Forums: No cover images after product import. prestashop.com/forums/topic/994566-no-cover-images-after-product-import

On the solution:

  1. PrestaShop Developer Documentation: Images webservice resource. devdocs.prestashop-project.org/8/webservice/resources/images
  2. PrestaShop Developer Documentation: Image management, advanced webservice tutorial. devdocs.prestashop-project.org/8/webservice/tutorials/advanced-use/image-management
  3. PrestaShop Developer Documentation: Image management, version 9. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/image-management

Stuck on a tricky one?

If you have a problem in PrestaShop stock, orders, order states, or the webservice API 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 fix your blank product tiles?

If this saved you a confusing missing-photo afternoon or a pile of duplicate key errors, 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 PrestaShop field notes