Reconciler Stock & Inventory
Orphaned stock rows remain after a combination is deleted
You delete a product combination in the Back Office, or through the combinations webservice resource, and everything looks clean. But the stock_available row that belonged to it can stay behind, still holding a quantity, still counted in the product's reported total stock, even though no combination points to it anymore. Here is why PrestaShop does not clean that row up on its own, and a small script that finds every orphan and removes it safely.
Deleting a combination removes the product_attribute row, but there is no enforced cascade to the matching stock_available row, so it can be left behind holding stale quantity. Run a script that lists the product's live combinations with GET /api/combinations?filter[id_product]=X, lists every stock row with GET /api/stock_availables?filter[id_product]=X, and diffs the two: any stock row whose id_product_attribute is nonzero and not in the live combination set is an orphan. Sum its quantity to see how much it is inflating the product's displayed total stock, then delete it with DELETE /api/stock_availables/[id]. Full code, tests, and a dry run guard are below.
The problem in plain words
Every product combination in PrestaShop has its own stock row, tied together by id_product_attribute. When you add a combination, PrestaShop creates a stock_available row for it. When you delete that combination, you would expect the matching stock row to go with it. That link is not guaranteed.
Community forum reports and PrestaShop's own issue tracker document this gap: deleting a combination can leave its stock_available row behind. If a new combination is created afterward, PrestaShop assigns it a brand new id_product_attribute, so it never reuses the orphan row. The old row just sits there, still holding whatever quantity or out_of_stock setting it had, unrelated to anything a customer can actually buy.
Why it happens
PrestaShop ties a combination's stock to its id_product_attribute, but the delete path does not enforce a matching cleanup of stock_available in every case. A few recurring ways stores end up with orphan rows:
- A combination is deleted from the Back Office product edit screen, and the
stock_availablerow keyed to itsid_product_attributeis not removed with it. - A combination is deleted through the
combinationswebservice resource, which offers the same gap since it is not guaranteed to cascade the delete to stock either. - A new combination is created later on the same product. It gets a fresh
id_product_attribute, so it never reclaims the orphan row, and the old row keeps accumulating alongside it. - Bulk combination management, generating and regenerating combinations from attribute combinations, deleting and recreating variants during a catalog cleanup, all multiply the same gap across many products at once.
This is a known, recurring defect rather than a one-off misconfiguration. Forum threads and PrestaShop's own core issue tracker document wrong stock quantities showing in the Back Office tied directly to this gap between combinations and stock_available. See the citations at the end for the exact reports.
The Back Office and the product's quantity summary do not ask "which combinations actually exist right now." They sum quantity across every stock_available row tied to the product's id_product, live or not. An orphan row does not need to be visible anywhere in the product edit screen to still be counted. The only reliable way to find one is to compare the stock rows against the combinations that are actually still live, id by id.
The fix, as a flow
We never guess which rows are orphans from the Back Office UI. The script pulls the product's live combinations, pulls every stock row tied to that product, and builds the set of valid id_product_attribute values, always including 0 for the base product's own row. Any stock row outside that set is an orphan, and it is safe to delete because it references a combination that no longer exists.
Build it step by step
Get a webservice key with the right permissions
In the backoffice, go to Advanced Parameters, Webservice, and create a key with read access to combinations and stock_availables, plus delete access to stock_availables. 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.
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
// 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
List the product's live combinations
Ask the combinations resource for every combination that still belongs to the product. Each object's id is the id_product_attribute a live stock row should be keyed to. This is the ground truth for what actually exists right now.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def live_combinations(id_product):
data = api_get("combinations", {"display": "full", "filter[id_product]": id_product})
return data.get("combinations") or []
const BASE_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 url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function liveCombinations(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
return data.combinations || [];
}
List every stock row tied to the product
Ask the stock_availables resource for every row filtered by the same id_product. Read back the row's own id (needed later to delete it), id_product_attribute, quantity, and out_of_stock. This is every row the Back Office will sum into the product's total stock, live or orphaned.
def stock_rows_for_product(id_product):
data = api_get("stock_availables", {"display": "full", "filter[id_product]": id_product})
rows = data.get("stock_availables") or []
return [
{
"id": int(r["id"]),
"id_product_attribute": int(r.get("id_product_attribute") or 0),
"quantity": int(r.get("quantity") or 0),
"out_of_stock": int(r.get("out_of_stock") or 0),
"id_shop": int(r.get("id_shop") or 0),
}
for r in rows
]
async function stockRowsForProduct(idProduct) {
const data = await apiGet("stock_availables", { display: "full", "filter[id_product]": idProduct });
const rows = data.stock_availables || [];
return rows.map((r) => ({
id: Number(r.id),
id_product_attribute: Number(r.id_product_attribute || 0),
quantity: Number(r.quantity || 0),
out_of_stock: Number(r.out_of_stock || 0),
id_shop: Number(r.id_shop || 0),
}));
}
Decide, with one pure function
Keep the actual decision in its own function that takes plain lists in and returns plain data out. Build the set of live ids from the combinations, always including 0 for the base product's own stock row, then return every stock row whose id_product_attribute is not in that set. Nothing here talks to the network, so it is simple to test with hand built fixtures, including the edge case of a stock row that is missing for a combination that does exist, which is a different problem and never flagged as an orphan here.
def find_orphan_stock_rows(combinations, stock_rows):
live_ids = {0} | {int(c["id"]) for c in combinations}
return [row for row in stock_rows if int(row["id_product_attribute"]) not in live_ids]
export function findOrphanStockRows(combinations, stockRows) {
const liveIds = new Set([0, ...combinations.map((c) => Number(c.id))]);
return stockRows.filter((row) => !liveIds.has(Number(row.id_product_attribute)));
}
Re-diff, then delete, to avoid a creation race
Between the moment you detect an orphan and the moment you delete it, someone could create a new combination. Always re-fetch combinations and stock rows and re-run the pure decision function immediately before deleting, then only delete the ids that are still orphans on that fresh check. This is the only place the script writes anything, and all it removes is a row whose id_product_attribute genuinely matches no live combination.
def delete_stock_row(id_stock_available):
r = requests.delete(
f"{BASE_URL}/api/stock_availables/{id_stock_available}",
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
async function deleteStockRow(idStockAvailable) {
const res = await fetch(`${BASE_URL}/api/stock_availables/${idStockAvailable}`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
}
Wire it together with a dry run guard
The run loop pulls the product ids you point it at, finds orphan rows for each, and logs the candidate id_product_attribute, quantity, and id_shop along with how much orphan quantity is inflating that product's displayed total. Leave DRY_RUN on for the first few runs so it only reports. Once you trust the list, switch it off, and the script re-diffs right before each delete.
Always start with DRY_RUN=true. The script only ever deletes a stock_available row after confirming, on a fresh re-fetch immediately beforehand, that its id_product_attribute still matches no live combination.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever removes stock rows that a fresh check confirms are orphaned.
"""Find and remove orphaned PrestaShop stock_available rows left behind
after a product combination is deleted.
There is no enforced cascade between combinations (product_attribute) and
stock_available, so deleting a combination through the Back Office or the
combinations webservice resource can leave its stock row behind. The Back
Office sums quantity across every stock_available row tied to a product, so
an orphan row with nonzero quantity silently inflates the displayed total
stock. This lists live combinations and all stock rows for a product, finds
rows whose id_product_attribute matches no live combination, and deletes
them only after re-confirming on a fresh fetch immediately beforehand.
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("orphaned_stock_rows")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PRODUCT_IDS = [int(p) for p in os.environ.get("PRODUCT_IDS", "").split(",") if p.strip()]
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def live_combinations(id_product):
data = api_get("combinations", {"display": "full", "filter[id_product]": id_product})
return data.get("combinations") or []
def stock_rows_for_product(id_product):
data = api_get("stock_availables", {"display": "full", "filter[id_product]": id_product})
rows = data.get("stock_availables") or []
return [
{
"id": int(r["id"]),
"id_product_attribute": int(r.get("id_product_attribute") or 0),
"quantity": int(r.get("quantity") or 0),
"out_of_stock": int(r.get("out_of_stock") or 0),
"id_shop": int(r.get("id_shop") or 0),
}
for r in rows
]
def find_orphan_stock_rows(combinations, stock_rows):
live_ids = {0} | {int(c["id"]) for c in combinations}
return [row for row in stock_rows if int(row["id_product_attribute"]) not in live_ids]
def delete_stock_row(id_stock_available):
r = requests.delete(
f"{BASE_URL}/api/stock_availables/{id_stock_available}",
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
def run():
total_orphan_quantity = 0
removed = 0
for id_product in PRODUCT_IDS:
combinations = live_combinations(id_product)
stock_rows = stock_rows_for_product(id_product)
orphans = find_orphan_stock_rows(combinations, stock_rows)
for orphan in orphans:
total_orphan_quantity += orphan["quantity"]
log.warning(
"Product %s orphan stock row id=%s id_product_attribute=%s quantity=%s id_shop=%s (%s)",
id_product, orphan["id"], orphan["id_product_attribute"],
orphan["quantity"], orphan["id_shop"],
"would delete" if DRY_RUN else "deleting",
)
if not DRY_RUN:
# Re-fetch and re-diff right before deleting, to avoid a race
# with a combination created between detection and repair.
fresh_combinations = live_combinations(id_product)
fresh_rows = stock_rows_for_product(id_product)
still_orphan_ids = {o["id"] for o in find_orphan_stock_rows(fresh_combinations, fresh_rows)}
if orphan["id"] in still_orphan_ids:
delete_stock_row(orphan["id"])
removed += 1
else:
removed += 1
log.info(
"Done. %d orphan row(s) %s, %d unit(s) of orphaned quantity found.",
removed, "to delete" if DRY_RUN else "deleted", total_orphan_quantity,
)
if __name__ == "__main__":
run()
/**
* Find and remove orphaned PrestaShop stock_available rows left behind
* after a product combination is deleted.
*
* There is no enforced cascade between combinations (product_attribute) and
* stock_available, so deleting a combination through the Back Office or the
* combinations webservice resource can leave its stock row behind. The Back
* Office sums quantity across every stock_available row tied to a product, so
* an orphan row with nonzero quantity silently inflates the displayed total
* stock. This lists live combinations and all stock rows for a product, finds
* rows whose id_product_attribute matches no live combination, and deletes
* them only after re-confirming on a fresh fetch immediately beforehand.
* Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/orphaned-stock-rows-after-combination-delete/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://example.test").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "dummy_key";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PRODUCT_IDS = (process.env.PRODUCT_IDS || "").split(",").map((p) => p.trim()).filter(Boolean).map(Number);
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params) {
const url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function liveCombinations(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct });
return data.combinations || [];
}
async function stockRowsForProduct(idProduct) {
const data = await apiGet("stock_availables", { display: "full", "filter[id_product]": idProduct });
const rows = data.stock_availables || [];
return rows.map((r) => ({
id: Number(r.id),
id_product_attribute: Number(r.id_product_attribute || 0),
quantity: Number(r.quantity || 0),
out_of_stock: Number(r.out_of_stock || 0),
id_shop: Number(r.id_shop || 0),
}));
}
export function findOrphanStockRows(combinations, stockRows) {
const liveIds = new Set([0, ...combinations.map((c) => Number(c.id))]);
return stockRows.filter((row) => !liveIds.has(Number(row.id_product_attribute)));
}
async function deleteStockRow(idStockAvailable) {
const res = await fetch(`${BASE_URL}/api/stock_availables/${idStockAvailable}`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
}
export async function run() {
let totalOrphanQuantity = 0;
let removed = 0;
for (const idProduct of PRODUCT_IDS) {
const combinations = await liveCombinations(idProduct);
const stockRows = await stockRowsForProduct(idProduct);
const orphans = findOrphanStockRows(combinations, stockRows);
for (const orphan of orphans) {
totalOrphanQuantity += orphan.quantity;
console.warn(
`Product ${idProduct} orphan stock row id=${orphan.id} id_product_attribute=${orphan.id_product_attribute} quantity=${orphan.quantity} id_shop=${orphan.id_shop} (${DRY_RUN ? "would delete" : "deleting"})`
);
if (!DRY_RUN) {
// Re-fetch and re-diff right before deleting, to avoid a race
// with a combination created between detection and repair.
const freshCombinations = await liveCombinations(idProduct);
const freshRows = await stockRowsForProduct(idProduct);
const stillOrphanIds = new Set(findOrphanStockRows(freshCombinations, freshRows).map((o) => o.id));
if (stillOrphanIds.has(orphan.id)) {
await deleteStockRow(orphan.id);
removed++;
}
} else {
removed++;
}
}
}
console.log(`Done. ${removed} orphan row(s) ${DRY_RUN ? "to delete" : "deleted"}, ${totalOrphanQuantity} unit(s) of orphaned quantity found.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which stock rows actually get deleted. Because find_orphan_stock_rows is pure, the test needs no PrestaShop instance and no network. It just feeds in plain lists and checks the answer.
from orphaned_stock_rows import find_orphan_stock_rows
def combo(id_):
return {"id": id_}
def row(**over):
base = {"id": 1, "id_product_attribute": 5, "quantity": 0, "out_of_stock": 2}
base.update(over)
return base
def test_no_orphans_when_every_row_matches_a_live_combination():
combinations = [combo(5), combo(6)]
stock_rows = [row(id_product_attribute=5), row(id=2, id_product_attribute=6)]
assert find_orphan_stock_rows(combinations, stock_rows) == []
def test_base_product_row_with_zero_attribute_is_never_an_orphan():
stock_rows = [row(id=1, id_product_attribute=0)]
assert find_orphan_stock_rows([], stock_rows) == []
def test_empty_combinations_list_only_keeps_the_zero_row():
stock_rows = [row(id=1, id_product_attribute=0), row(id=2, id_product_attribute=7, quantity=4)]
result = find_orphan_stock_rows([], stock_rows)
assert result == [row(id=2, id_product_attribute=7, quantity=4)]
def test_stock_row_for_deleted_combination_is_an_orphan():
combinations = [combo(5)]
stock_rows = [row(id=1, id_product_attribute=5), row(id=2, id_product_attribute=9, quantity=3)]
result = find_orphan_stock_rows(combinations, stock_rows)
assert result == [row(id=2, id_product_attribute=9, quantity=3)]
def test_duplicate_stock_rows_for_the_same_orphaned_attribute_are_all_returned():
stock_rows = [
row(id=2, id_product_attribute=9, quantity=3),
row(id=3, id_product_attribute=9, quantity=2),
]
result = find_orphan_stock_rows([], stock_rows)
assert result == stock_rows
def test_combination_present_but_stock_row_missing_is_not_flagged_as_orphan():
# A live combination with no matching stock row is a different problem,
# not something find_orphan_stock_rows reports on.
combinations = [combo(5), combo(6)]
stock_rows = [row(id=1, id_product_attribute=5)]
assert find_orphan_stock_rows(combinations, stock_rows) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanStockRows } from "./orphaned-stock-rows.js";
const combo = (id) => ({ id });
const row = (over = {}) => ({ id: 1, id_product_attribute: 5, quantity: 0, out_of_stock: 2, ...over });
test("no orphans when every row matches a live combination", () => {
const combinations = [combo(5), combo(6)];
const stockRows = [row({ id_product_attribute: 5 }), row({ id: 2, id_product_attribute: 6 })];
assert.deepEqual(findOrphanStockRows(combinations, stockRows), []);
});
test("base product row with zero attribute is never an orphan", () => {
const stockRows = [row({ id: 1, id_product_attribute: 0 })];
assert.deepEqual(findOrphanStockRows([], stockRows), []);
});
test("empty combinations list only keeps the zero row", () => {
const stockRows = [row({ id: 1, id_product_attribute: 0 }), row({ id: 2, id_product_attribute: 7, quantity: 4 })];
const result = findOrphanStockRows([], stockRows);
assert.deepEqual(result, [row({ id: 2, id_product_attribute: 7, quantity: 4 })]);
});
test("stock row for deleted combination is an orphan", () => {
const combinations = [combo(5)];
const stockRows = [row({ id: 1, id_product_attribute: 5 }), row({ id: 2, id_product_attribute: 9, quantity: 3 })];
const result = findOrphanStockRows(combinations, stockRows);
assert.deepEqual(result, [row({ id: 2, id_product_attribute: 9, quantity: 3 })]);
});
test("duplicate stock rows for the same orphaned attribute are all returned", () => {
const stockRows = [
row({ id: 2, id_product_attribute: 9, quantity: 3 }),
row({ id: 3, id_product_attribute: 9, quantity: 2 }),
];
const result = findOrphanStockRows([], stockRows);
assert.deepEqual(result, stockRows);
});
test("combination present but stock row missing is not flagged as orphan", () => {
const combinations = [combo(5), combo(6)];
const stockRows = [row({ id: 1, id_product_attribute: 5 })];
assert.deepEqual(findOrphanStockRows(combinations, stockRows), []);
});
Case studies
The apparel store that kept "selling out" of colors it no longer had
An apparel brand regularly retired old colorways and added new ones for the season, deleting the old combinations from the product edit screen. Months later, a few products showed high total stock in the Back Office even though the currently listed sizes and colors were all near zero.
The reconciler found stock rows tied to id_product_attribute values from colorways deleted two seasons earlier, some still holding dozens of units. Removing the orphan rows brought the displayed total back in line with what customers could actually order.
The integration that deleted combinations in bulk
A PIM integration synced variant changes by deleting and recreating combinations through the combinations webservice resource whenever an attribute set changed upstream. It worked, but every recreate cycle had a chance of leaving the previous cycle's stock row behind.
Running the check across the product catalog on a schedule caught the drift early. The fix never touched the integration. It just cleared out stock rows whose id_product_attribute no longer matched anything the combinations resource reported.
After this runs on a schedule, a deleted combination never leaves a hidden quantity behind to inflate what a product claims to have in stock. Every stock_available row left standing is tied to a combination that genuinely exists, the displayed total matches the live combinations, and nothing gets removed without a fresh re-check immediately before the delete.
FAQ
Why does PrestaShop leave a stock_available row behind after I delete a combination?
There is no enforced cascade between combinations (product_attribute) and stock_available. When a combination is deleted through the Back Office or the combinations webservice resource, its stock_available row, keyed by id_product_attribute, is not reliably removed, so it can be left behind holding whatever quantity it had.
How does an orphaned stock row affect my reported stock?
The Back Office and the product's stock summary sum quantity across every stock_available row tied to a product. An orphan row with a nonzero quantity or an out_of_stock override still gets counted in that sum even though no live combination corresponds to it anymore, so the displayed total stock is inflated versus the true sum over live combinations.
Is it safe to delete orphaned stock_available rows automatically?
Yes, once confirmed. An orphan row references an id_product_attribute that no longer exists in the live combinations list, so removing it cannot affect any real combination. The safe pattern is to re-fetch and re-diff immediately before deleting, so a combination created between detection and repair is never mistaken for an orphan.
Related field notes
Citations
On the problem:
- Rows not removed from ps_stock_available upon combination removal. Wrong stock quantity. forum.prestashop.com/topic/1079762
- Wrong quantities showing in BO for products with combinations. github.com/PrestaShop/PrestaShop/issues/12814
- stock_available cleanup for non-existing products. github.com/PrestaShop/PrestaShop/issues/13682
On the solution:
- PrestaShop Developer Documentation: Combinations webservice resource. devdocs.prestashop-project.org/9/webservice/resources/combinations/
- PrestaShop Developer Documentation: Stock availables webservice resource. devdocs.prestashop-project.org/9/webservice/resources/stock_availables/
- PrestaShop Developer Documentation: Webservice reference. devdocs.prestashop-project.org/9/webservice/reference/
Stuck on a tricky one?
If you have a problem in PrestaShop stock, combinations, orders, 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.
Did this clean up your stock counts?
If this saved you a pile of confused stock numbers or a wrong "in stock" message, 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