Skip to content

Reconciler Stock & Inventory

Duplicate key error when updating stock quantity concurrently

Two stock updates land at almost the same moment, maybe a webservice PUT racing a checkout, and PrestaShop throws a duplicate entry error for key product_sqlstock. The row you expected to update never gets touched, and somewhere there is now an orphan stock row fighting the real one. Here is why the check-then-act pattern in StockAvailable lets this happen and a script that finds the duplicate rows so you can review and merge them safely.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Stacks of wooden pallets
Photo by Sergej on Unsplash
The short answer

The ps_stock_available table has a unique key named product_sqlstock on id_product, id_product_attribute, id_shop, and id_shop_group. StockAvailable::setQuantity() selects a row for that key first, then decides whether to update or insert. Two near-simultaneous writes can both miss each other's row and both try to insert, so the second one hits a duplicate entry error. Run a small Python or Node.js script that pulls every stock_availables row for a product, groups them by that same natural key, and flags any group with more than one row as a true duplicate for you to review before merging. Full code, tests, and a dry run guard are below.

The problem in plain words

PrestaShop keeps one stock row per product, per combination, per shop, and per shop group in ps_stock_available. A unique key called product_sqlstock is supposed to guarantee there is never more than one row for a given id_product, id_product_attribute, id_shop, and id_shop_group.

The trouble is how PrestaShop decides whether to write that row. StockAvailable::setQuantity(), and the lookup behind it in getStockAvailableByProduct(), first runs a SELECT to see if a matching row already exists, and only afterward decides to UPDATE it or INSERT a new one. That is a check, then an act, done as two separate steps. If two requests run this sequence close enough together, both SELECTs can run before either INSERT lands, so both requests conclude no row exists yet and both try to insert one. The database only lets one of those inserts succeed. The other one collides with the unique key and MySQL throws a duplicate entry error on product_sqlstock.

Request A webservice PUT stock Request B order state change Both SELECT the same key, see no row INSERT A succeeds INSERT B collides Duplicate entry product_sqlstock Orphan row id_shop=0/0
Both requests select the same key, see nothing yet, and both try to insert. One insert wins, the other collides with the unique key or lands as an orphan row scoped to shop 0.

Why it happens

The root cause is a classic check-then-act race sitting on top of a unique key that is otherwise doing its job correctly. A few situations make it show up in real stores:

This is a documented pattern, not a one-off bug in your store. The core team has tracked the duplicate entry on product_sqlstock as a race in StockAvailable, and a separate report covers the multistore case where changing an order status with a warehouse per shop throws the same error. See the citations at the end for both.

The key insight

You cannot fix a race after the fact by writing faster. Once the duplicate rows exist, the safest move is not to guess which one is right and blindly delete the other. It is to enumerate every stock_availables row for the affected product, group them by the same natural key the unique index uses, and only act on a group once you can see both rows side by side and a human agrees on the merge.

The fix, as a flow

We do not try to prevent the race inside PrestaShop's core code. We detect its aftermath. A script pulls the stock_availables for a product from the Webservice API, groups them by id_product, id_product_attribute, id_shop, and id_shop_group, and any group with more than one row is a true duplicate the unique index should have prevented. We also cross-check against combinations so a stock row pointing at a combination that no longer exists is flagged too. Everything is reported first; nothing is deleted until DRY_RUN is turned off and a human has looked at the before and after quantities.

GET stock_availables and combinations Group by natural key product, combo, shop, group Any group with more than one row? Dry run? off? yes no, report only Log duplicate group for review PUT keep row then DELETE extra
The script only ever reports duplicate groups by default. Merging the keep row and deleting the extra one only happens with DRY_RUN off, after a human reviews the quantities.

Build it step by step

1

Get a Webservice key

In the PrestaShop back office, go to Advanced Parameters, Webservice, and create a key with access to the stock_availables and combinations resources. 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 write
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 write
2

Talk to the Webservice API

Every call is plain HTTP with the key as the Basic auth username. Ask for JSON with output_format=JSON, since the default is XML. A small helper sends the request and raises if PrestaShop returns an error status.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["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=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;

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

async function apiGet(path, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
3

Enumerate the stock rows and the live combinations

Pull every stock_availables row for the product with display=full so you get the quantity and shop fields, and pull the product's combinations so you know which id_product_attribute values should legitimately exist.

step3.py
def stock_rows_for_product(id_product):
    data = api_get("stock_availables", {
        "filter[id_product]": id_product,
        "display": "full",
    })
    return data.get("stock_availables") or []

def live_combination_ids(id_product):
    data = api_get("combinations", {
        "filter[id_product]": id_product,
        "display": "full",
    })
    rows = data.get("combinations") or []
    return {int(row["id"]) for row in rows}
step3.js
async function stockRowsForProduct(idProduct) {
  const data = await apiGet("stock_availables", {
    "filter[id_product]": idProduct,
    display: "full",
  });
  return data.stock_availables || [];
}

async function liveCombinationIds(idProduct) {
  const data = await apiGet("combinations", {
    "filter[id_product]": idProduct,
    display: "full",
  });
  const rows = data.combinations || [];
  return new Set(rows.map((row) => Number(row.id)));
}
4

Decide, with one pure function

The decision that matters is grouping rows by the same natural key the unique index uses, and keeping only the groups with more than one row. Within each duplicate group, sort so the row with a real id_shop (not 0) and the highest id sits first, since that is usually the row to keep. Keeping this pure and free of any HTTP call means we can test it with plain lists.

decide.py
def natural_key(row):
    return (
        int(row["id_product"]),
        int(row["id_product_attribute"]),
        int(row["id_shop"]),
        int(row["id_shop_group"]),
    )

def find_duplicate_stock_rows(rows):
    groups = {}
    for row in rows:
        key = natural_key(row)
        groups.setdefault(key, []).append(row)

    duplicates = []
    for key, group in groups.items():
        if len(group) <= 1:
            continue
        ordered = sorted(
            group,
            key=lambda r: (int(r["id_shop"]) != 0, int(r["id"])),
            reverse=True,
        )
        duplicates.append(ordered)
    return duplicates
decide.js
function naturalKey(row) {
  return [
    Number(row.id_product),
    Number(row.id_product_attribute),
    Number(row.id_shop),
    Number(row.id_shop_group),
  ].join("|");
}

export function findDuplicateStockRows(rows) {
  const groups = new Map();
  for (const row of rows) {
    const key = naturalKey(row);
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(row);
  }

  const duplicates = [];
  for (const group of groups.values()) {
    if (group.length <= 1) continue;
    const ordered = [...group].sort((a, b) => {
      const aKeep = Number(a.id_shop) !== 0 ? 1 : 0;
      const bKeep = Number(b.id_shop) !== 0 ? 1 : 0;
      if (aKeep !== bKeep) return bKeep - aKeep;
      return Number(b.id) - Number(a.id);
    });
    duplicates.push(ordered);
  }
  return duplicates;
}
5

Cross-check against live combinations

A stock row can also be an orphan on its own, with only one row, if it references an id_product_attribute that no longer exists on the product. Flag those the same way, since they will not show up as a duplicate group but are still wrong.

orphans.py
def find_orphaned_combination_rows(rows, live_ids):
    orphans = []
    for row in rows:
        attr_id = int(row["id_product_attribute"])
        if attr_id != 0 and attr_id not in live_ids:
            orphans.append(row)
    return orphans
orphans.js
export function findOrphanedCombinationRows(rows, liveIds) {
  return rows.filter((row) => {
    const attrId = Number(row.id_product_attribute);
    return attrId !== 0 && !liveIds.has(attrId);
  });
}
6

Report by default, merge only when confirmed

By default the job only logs each duplicate group and each orphan, with the before quantities, so a human can decide the right merged quantity. Only when DRY_RUN is false does it PUT the merged body to the keep row and DELETE the rest, and it still logs every quantity it touches.

Run it safe

Always start with DRY_RUN=true. Collapsing two stock rows into one means choosing a quantity, and choosing wrong can under-sell or over-sell real stock. Read the reported groups, agree on the right quantity per your own business rule, then switch DRY_RUN off to let it write and delete.

The full code

Here is the complete script 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 because merging and deleting only happen for groups it already reported and only once you turn dry run off.

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.
find_duplicate_stock.py
"""Find and, if confirmed, merge duplicate PrestaShop stock_available rows.

ps_stock_available has a unique key (product_sqlstock) on id_product,
id_product_attribute, id_shop, and id_shop_group. StockAvailable::setQuantity()
selects a row for that key then decides to update or insert. Two near
simultaneous writes can both miss each other's row and both try to insert,
so the second one hits a duplicate entry error on product_sqlstock, or in
multistore installs lands as an orphan row scoped to id_shop=0/id_shop_group=0.

This script enumerates stock_availables for a product, groups them by that
same natural key, and reports any group with more than one row. It also
flags stock rows whose id_product_attribute no longer exists on the product.
By default it only reports. Set DRY_RUN=false to let it PUT the merged
keep row and DELETE the extra rows, after you confirm the quantities.
"""
import os
import logging
import requests

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

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


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=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def api_put(path, body):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def api_delete(path):
    r = requests.delete(
        f"{PRESTASHOP_URL}/api/{path}",
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()


def natural_key(row):
    return (
        int(row["id_product"]),
        int(row["id_product_attribute"]),
        int(row["id_shop"]),
        int(row["id_shop_group"]),
    )


def find_duplicate_stock_rows(rows):
    groups = {}
    for row in rows:
        key = natural_key(row)
        groups.setdefault(key, []).append(row)

    duplicates = []
    for key, group in groups.items():
        if len(group) <= 1:
            continue
        ordered = sorted(
            group,
            key=lambda r: (int(r["id_shop"]) != 0, int(r["id"])),
            reverse=True,
        )
        duplicates.append(ordered)
    return duplicates


def find_orphaned_combination_rows(rows, live_ids):
    orphans = []
    for row in rows:
        attr_id = int(row["id_product_attribute"])
        if attr_id != 0 and attr_id not in live_ids:
            orphans.append(row)
    return orphans


def stock_rows_for_product(id_product):
    data = api_get("stock_availables", {
        "filter[id_product]": id_product,
        "display": "full",
    })
    return data.get("stock_availables") or []


def live_combination_ids(id_product):
    data = api_get("combinations", {
        "filter[id_product]": id_product,
        "display": "full",
    })
    rows = data.get("combinations") or []
    return {int(row["id"]) for row in rows}


def merge_duplicate_group(group):
    keep, *rest = group
    quantities = [int(row["quantity"]) for row in group]
    merged_quantity = max(quantities)
    body = dict(keep)
    body["quantity"] = merged_quantity
    log.info(
        "Merging stock rows for product %s attribute %s: keep id=%s quantity %s -> %s, dropping id(s) %s",
        keep["id_product"], keep["id_product_attribute"], keep["id"],
        keep["quantity"], merged_quantity, [row["id"] for row in rest],
    )
    if not DRY_RUN:
        api_put(f"stock_availables/{keep['id']}", body)
        for row in rest:
            api_delete(f"stock_availables/{row['id']}")
    return keep["id"], merged_quantity


def run(id_product):
    rows = stock_rows_for_product(id_product)
    live_ids = live_combination_ids(id_product)

    duplicates = find_duplicate_stock_rows(rows)
    orphans = find_orphaned_combination_rows(rows, live_ids)

    for group in duplicates:
        log.warning(
            "Duplicate stock rows for key %s: %s",
            natural_key(group[0]), [row["id"] for row in group],
        )
        merge_duplicate_group(group)

    for row in orphans:
        log.warning(
            "Orphaned stock row id=%s references missing combination id_product_attribute=%s",
            row["id"], row["id_product_attribute"],
        )

    log.info(
        "Done. %d duplicate group(s), %d orphaned row(s) for product %s.",
        len(duplicates), len(orphans), id_product,
    )


if __name__ == "__main__":
    target_product = os.environ.get("TARGET_ID_PRODUCT")
    if not target_product:
        raise SystemExit("Set TARGET_ID_PRODUCT to the product id to check.")
    run(int(target_product))
find-duplicate-stock.js
/**
 * Find and, if confirmed, merge duplicate PrestaShop stock_available rows.
 *
 * ps_stock_available has a unique key (product_sqlstock) on id_product,
 * id_product_attribute, id_shop, and id_shop_group. StockAvailable::setQuantity()
 * selects a row for that key then decides to update or insert. Two near
 * simultaneous writes can both miss each other's row and both try to insert,
 * so the second one hits a duplicate entry error on product_sqlstock, or in
 * multistore installs lands as an orphan row scoped to id_shop=0/id_shop_group=0.
 *
 * This script enumerates stock_availables for a product, groups them by that
 * same natural key, and reports any group with more than one row. By default
 * it only reports. Set DRY_RUN=false to let it PUT the merged keep row and
 * DELETE the extra rows, after you confirm the quantities.
 *
 * Guide: https://www.allanninal.dev/prestashop/stock-available-duplicate-key-error/
 */
import { pathToFileURL } from "node:url";

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

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

function naturalKey(row) {
  return [
    Number(row.id_product),
    Number(row.id_product_attribute),
    Number(row.id_shop),
    Number(row.id_shop_group),
  ].join("|");
}

export function findDuplicateStockRows(rows) {
  const groups = new Map();
  for (const row of rows) {
    const key = naturalKey(row);
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(row);
  }

  const duplicates = [];
  for (const group of groups.values()) {
    if (group.length <= 1) continue;
    const ordered = [...group].sort((a, b) => {
      const aKeep = Number(a.id_shop) !== 0 ? 1 : 0;
      const bKeep = Number(b.id_shop) !== 0 ? 1 : 0;
      if (aKeep !== bKeep) return bKeep - aKeep;
      return Number(b.id) - Number(a.id);
    });
    duplicates.push(ordered);
  }
  return duplicates;
}

export function findOrphanedCombinationRows(rows, liveIds) {
  return rows.filter((row) => {
    const attrId = Number(row.id_product_attribute);
    return attrId !== 0 && !liveIds.has(attrId);
  });
}

async function apiGet(path, params = {}) {
  const qs = new URLSearchParams({ ...params, output_format: "JSON" });
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?${qs}`, {
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiPut(path, body) {
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}?output_format=JSON`, {
    method: "PUT",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function apiDelete(path) {
  const res = await fetch(`${PRESTASHOP_URL}/api/${path}`, {
    method: "DELETE",
    headers: { Authorization: authHeader() },
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
}

async function stockRowsForProduct(idProduct) {
  const data = await apiGet("stock_availables", {
    "filter[id_product]": idProduct,
    display: "full",
  });
  return data.stock_availables || [];
}

async function liveCombinationIds(idProduct) {
  const data = await apiGet("combinations", {
    "filter[id_product]": idProduct,
    display: "full",
  });
  const rows = data.combinations || [];
  return new Set(rows.map((row) => Number(row.id)));
}

async function mergeDuplicateGroup(group) {
  const [keep, ...rest] = group;
  const quantities = group.map((row) => Number(row.quantity));
  const mergedQuantity = Math.max(...quantities);
  const body = { ...keep, quantity: mergedQuantity };
  console.warn(
    `Merging stock rows for product ${keep.id_product} attribute ${keep.id_product_attribute}: ` +
    `keep id=${keep.id} quantity ${keep.quantity} -> ${mergedQuantity}, dropping id(s) ${rest.map((r) => r.id)}`
  );
  if (!DRY_RUN) {
    await apiPut(`stock_availables/${keep.id}`, body);
    for (const row of rest) await apiDelete(`stock_availables/${row.id}`);
  }
  return { keepId: keep.id, mergedQuantity };
}

export async function run(idProduct) {
  const rows = await stockRowsForProduct(idProduct);
  const liveIds = await liveCombinationIds(idProduct);

  const duplicates = findDuplicateStockRows(rows);
  const orphans = findOrphanedCombinationRows(rows, liveIds);

  for (const group of duplicates) {
    console.warn(`Duplicate stock rows for key ${naturalKey(group[0])}: ${group.map((r) => r.id)}`);
    await mergeDuplicateGroup(group);
  }

  for (const row of orphans) {
    console.warn(`Orphaned stock row id=${row.id} references missing combination id_product_attribute=${row.id_product_attribute}`);
  }

  console.log(`Done. ${duplicates.length} duplicate group(s), ${orphans.length} orphaned row(s) for product ${idProduct}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const targetProduct = process.env.TARGET_ID_PRODUCT;
  if (!targetProduct) {
    console.error("Set TARGET_ID_PRODUCT to the product id to check.");
    process.exit(1);
  }
  run(Number(targetProduct)).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The grouping decision is the part most worth testing, because it decides which rows get treated as duplicates and which row is kept. Because we kept find_duplicate_stock_rows pure, the test needs no network and no PrestaShop store. It just feeds in plain lists and checks the answer.

test_stock_duplicates.py
from find_duplicate_stock import find_duplicate_stock_rows, find_orphaned_combination_rows


def row(**over):
    base = {"id": 1, "id_product": 10, "id_product_attribute": 0, "id_shop": 1, "id_shop_group": 1, "quantity": 5}
    base.update(over)
    return base


def test_no_duplicates_when_all_keys_unique():
    rows = [row(id=1), row(id=2, id_product_attribute=2)]
    assert find_duplicate_stock_rows(rows) == []


def test_finds_duplicate_group_for_same_key():
    rows = [row(id=1, quantity=5), row(id=2, quantity=8)]
    groups = find_duplicate_stock_rows(rows)
    assert len(groups) == 1
    assert len(groups[0]) == 2


def test_keep_candidate_is_highest_id_within_a_group():
    rows = [row(id=1, id_shop=0, id_shop_group=0), row(id=2, id_shop=0, id_shop_group=0)]
    groups = find_duplicate_stock_rows(rows)
    assert groups[0][0]["id"] == 2  # highest id wins, both rows share the same shop scope


def test_rows_with_different_shop_scope_are_not_grouped_together():
    rows = [row(id=9, id_shop=0, id_shop_group=0), row(id=2, id_shop=1, id_shop_group=1)]
    assert find_duplicate_stock_rows(rows) == []  # different id_shop means a different natural key


def test_orphaned_rows_flagged_when_combination_missing():
    rows = [row(id=1, id_product_attribute=99)]
    assert find_orphaned_combination_rows(rows, {1, 2}) == rows


def test_no_orphan_for_simple_product_row():
    rows = [row(id=1, id_product_attribute=0)]
    assert find_orphaned_combination_rows(rows, {1, 2}) == []


def test_no_orphan_when_combination_still_live():
    rows = [row(id=1, id_product_attribute=2)]
    assert find_orphaned_combination_rows(rows, {1, 2}) == []
stock-duplicates.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateStockRows, findOrphanedCombinationRows } from "./find-duplicate-stock.js";

const row = (over = {}) => ({
  id: 1, id_product: 10, id_product_attribute: 0, id_shop: 1, id_shop_group: 1, quantity: 5,
  ...over,
});

test("no duplicates when all keys are unique", () => {
  const rows = [row({ id: 1 }), row({ id: 2, id_product_attribute: 2 })];
  assert.deepEqual(findDuplicateStockRows(rows), []);
});

test("finds a duplicate group for the same natural key", () => {
  const rows = [row({ id: 1, quantity: 5 }), row({ id: 2, quantity: 8 })];
  const groups = findDuplicateStockRows(rows);
  assert.equal(groups.length, 1);
  assert.equal(groups[0].length, 2);
});

test("keep candidate is the highest id when shop is tied", () => {
  const rows = [row({ id: 1, id_shop: 0, id_shop_group: 0 }), row({ id: 2, id_shop: 0, id_shop_group: 0 })];
  const groups = findDuplicateStockRows(rows);
  assert.equal(groups[0][0].id, 2);
});

test("orphaned rows are flagged when the combination is missing", () => {
  const rows = [row({ id: 1, id_product_attribute: 99 })];
  assert.deepEqual(findOrphanedCombinationRows(rows, new Set([1, 2])), rows);
});

test("no orphan for a simple product row with attribute 0", () => {
  const rows = [row({ id: 1, id_product_attribute: 0 })];
  assert.deepEqual(findOrphanedCombinationRows(rows, new Set([1, 2])), []);
});

test("no orphan when the combination is still live", () => {
  const rows = [row({ id: 1, id_product_attribute: 2 })];
  assert.deepEqual(findOrphanedCombinationRows(rows, new Set([1, 2])), []);
});

Case studies

Webservice sync

Two integrations updating the same SKU

A store ran a nightly stock sync from its warehouse system alongside a same-day correction tool a warehouse manager used during the day. On the days both happened to fire within a few seconds of each other, the second webservice PUT started failing with a duplicate entry error on product_sqlstock, and nobody could tell which write actually landed.

Running the reconciler against the affected product ids showed exactly two rows sharing the same key, one with the warehouse quantity and one with the manual correction. Once the team agreed the warehouse number was the source of truth, they let the script merge with that quantity and delete the extra row, and the duplicate entry errors stopped.

Multistore

Orphan rows scoped to shop zero

A multistore install had orders coming in on two shops sharing a warehouse. Under load, some order state changes left behind stock rows with id_shop=0 and id_shop_group=0 instead of the real shop id, alongside the legitimate per-shop row for the same product and combination.

The script's grouping caught these because both rows shared the same id_product and id_product_attribute, differing only in shop scope, which is still the same natural key PrestaShop's unique index protects. The team reviewed each group, confirmed the shop-zero row was the orphan, and let the script keep the real per-shop row and delete the rest.

What good looks like

After running this as a periodic check, duplicate and orphaned stock rows get caught within a day instead of surfacing as a confusing error mid-checkout. The report shows exactly which rows collided and what their quantities were, so merging is a reviewed decision instead of a guess, and the unique key on product_sqlstock goes back to doing what it was meant to do.

FAQ

Why does PrestaShop throw a duplicate entry error for product_sqlstock?

ps_stock_available has a unique key named product_sqlstock on the combination of id_product, id_product_attribute, id_shop, and id_shop_group. StockAvailable::setQuantity() first checks for an existing row for that key and then decides to update or insert. When two requests run that check at nearly the same moment, both can miss each other's row and both try to insert, so the second insert collides with the unique key and MySQL raises a duplicate entry error.

Can I safely auto-merge duplicate stock_available rows?

Not blindly. Collapsing two rows into one means picking a quantity, and picking wrong can under-sell or over-sell stock. The safe pattern is to run the script in DRY_RUN first so it only reports the duplicate groups, review the before and after quantities yourself, and only let it write and delete once a human has confirmed the merge is correct.

Why do duplicate stock rows show id_shop 0 and id_shop_group 0?

In a multistore setup, the shop context can resolve incorrectly during a race between two writes, and PrestaShop inserts a stock row scoped to id_shop=0 and id_shop_group=0 instead of the real shop. That orphan row still collides with or shadows the correct per-shop row, which is why multistore installs see this error more often than single shop stores.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: StockAvailable setQuantity duplicate data on key product_sqlstock. github.com/PrestaShop/PrestaShop/issues/11806
  2. PrestaShop GitHub: error when changing order status in multishop with a separate warehouse. github.com/PrestaShop/PrestaShop/issues/38869
  3. PrestaShop Forums: duplicate entry xx-xxx-0-0 for key product_sqlstock. prestashop.com/forums/topic/1054644

On the solution:

  1. PrestaShop Developer Documentation: the stock_availables resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables
  2. PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock
  3. PrestaShop Developer Documentation: create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az

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 untangle your duplicate stock rows?

If this saved you a scary manual SQL fix or a real duplicate entry error, 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