Reconciler Number Ranges and Documents

Non-atomic number range storage can duplicate order numbers

Two customers, two orders, and somehow the exact same order number on both invoices. It looks impossible, because Shopware's number range system is supposed to guarantee a unique value every time. It only keeps that promise while the increment storage behind it is a single, consistently configured, atomic backend. Move a number range between storages mid-flight, or misconfigure the Redis eviction policy it depends on, and the same number can go out twice. Here is why that guarantee breaks and a script that finds the duplicates without touching a single live order number.

Python and Node.js Admin API Safe by default (dry run)
The short answer

Shopware 6 generates order numbers through the number_range system, which is atomic only within one consistently configured increment storage, either the database sequence gateway or, for high-throughput shops, Redis. Two real failure modes break that guarantee. First, migrating a number range from one storage to another is explicitly documented as non-atomic, so orders created during the cutover window can collide. Second, if the Redis instance used for number ranges runs an allkeys-lru eviction policy instead of the recommended volatile-lru, the increment key can be evicted under memory pressure and the counter silently restarts from its default, so a later order gets a number that was already issued. Page every order's orderNumber and createdAt through the Admin API, group by orderNumber, and any group with more than one order id is a duplicate. Never renumber the existing orders automatically. Report them for manual reconciliation, and the only safe automated write is advancing the number-range-state lastValue past the highest number on record so no new duplicates get produced. Full code, tests, and sources are below.

The problem in plain words

Every order needs an orderNumber, and Shopware hands those out through the number_range system: a counter that increments by one every time an order is placed. As long as every increment goes through the same atomic storage, this is airtight. Two checkouts racing to get a number at the same instant still each get a distinct value, because the storage serializes the increment.

That guarantee is scoped to a single, consistently configured storage. It is not a property of Shopware's order numbers in general, it is a property of whichever backend, database or Redis, is actually holding the counter at the moment the increment happens. Change which backend is holding it, or let that backend silently forget its own state, and the guarantee is gone even though nothing about the order flow itself changed.

Checkout A requests a number Checkout B requests a number Counter storage mid migration, or key evicted Order A orderNumber 10042 Order B orderNumber 10042 same number, two orders
Both requests race for the same increment. A consistent atomic backend serializes them into distinct numbers. A migration in flight, or a Redis key that was quietly evicted, can let both reads land on the same value.

Why it happens

The counter itself is simple. What breaks it is the backend underneath changing shape or losing its memory without the order flow ever knowing:

See the citations at the end for Shopware's own documentation of both the migration warning and the Redis eviction policy guidance.

The key insight

A duplicate orderNumber is a symptom of the storage, not the order. Detecting it does not require understanding which of the two causes produced it. Grouping every order by orderNumber and flagging any group with more than one order id works regardless of whether the duplicate came from a migration window or an eviction reset. Telling the two causes apart only matters afterward, when you compare the live counter's lastValue against the highest orderNumber already issued. If the counter is behind, that is the tell-tale sign of an eviction reset.

The fix, as a flow

We never renumber an existing order automatically, because that number is already on a customer's invoice. The script pages through every order, groups by orderNumber with a pure function, and reports every duplicate group for manual review. The only write it makes, gated by DRY_RUN, is advancing the number range's lastValue past the highest colliding number, so the counter cannot hand out another duplicate going forward.

Page all orders id, orderNumber, createdAt findDuplicateOrderNumbers group by orderNumber, pure Group size > 1? yes, duplicate Report only, no rewrite no, unique Nothing to do max orderNumber found PATCH number-range-state lastValue, DRY_RUN-gated
Duplicate groups are only ever reported for a human to reconcile. The single automated write advances the counter so the collision cannot repeat.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for paging orders and for reading and patching the number range state.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Page every order's number and creation time

Search orders sorted by orderNumber, asking only for the fields the decision needs: id, orderNumber, and createdAt. Page through with page and a limit of 500 until a page comes back short, optionally bounding the scan with a range filter on createdAt.

step3.py
def page_orders(token, page):
    body = {
        "page": page,
        "limit": 500,
        "filter": [],
        "sort": [{"field": "orderNumber", "order": "ASC"}],
        "total-count-mode": 1,
        "associations": {},
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]

def all_orders(token):
    page = 1
    while True:
        rows = page_orders(token, page)
        for row in rows:
            yield {"id": row["id"], "orderNumber": row["orderNumber"], "createdAt": row["createdAt"]}
        if len(rows) < 500:
            return
        page += 1
step3.js
async function pageOrders(token, page) {
  const body = {
    page,
    limit: 500,
    filter: [],
    sort: [{ field: "orderNumber", order: "ASC" }],
    "total-count-mode": 1,
    associations: {},
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order search`);
  const data = await res.json();
  return data.data;
}

async function* allOrders(token) {
  let page = 1;
  while (true) {
    const rows = await pageOrders(token, page);
    for (const row of rows) {
      yield { id: row.id, orderNumber: row.orderNumber, createdAt: row.createdAt };
    }
    if (rows.length < 500) return;
    page++;
  }
}
4

Group and decide, with one pure function

Keep the grouping in its own function that takes the full list of orders collected from paging and returns only the duplicate groups. A pure function like this is easy to test with fixture arrays. It groups by exact, case-sensitive orderNumber, discards groups of one, and sorts what remains by count descending then orderNumber ascending so the worst collisions surface first.

decide.py
def find_duplicate_order_numbers(orders):
    groups = {}
    for order in orders:
        groups.setdefault(order["orderNumber"], []).append(order["id"])

    duplicates = [
        {"orderNumber": number, "orderIds": ids, "count": len(ids)}
        for number, ids in groups.items()
        if len(ids) > 1
    ]
    duplicates.sort(key=lambda d: (-d["count"], d["orderNumber"]))
    return duplicates
decide.js
export function findDuplicateOrderNumbers(orders) {
  const groups = new Map();
  for (const order of orders) {
    const ids = groups.get(order.orderNumber) || [];
    ids.push(order.id);
    groups.set(order.orderNumber, ids);
  }

  const duplicates = [];
  for (const [orderNumber, orderIds] of groups) {
    if (orderIds.length > 1) {
      duplicates.push({ orderNumber, orderIds, count: orderIds.length });
    }
  }
  duplicates.sort((a, b) => b.count - a.count || (a.orderNumber < b.orderNumber ? -1 : a.orderNumber > b.orderNumber ? 1 : 0));
  return duplicates;
}
5

Advance the counter, never the existing orders

Find the highest numeric orderNumber across every order, including the duplicates. Look up the number-range-state row for the order number range, then, gated by DRY_RUN, PATCH its lastValue to that maximum so the next increment is guaranteed higher than everything already on record. This never touches the duplicate orders themselves.

apply.py
def find_order_number_range_state_id(token):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "numberRange.type.technicalName", "value": "order"}],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/number-range-state",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    rows = r.json()["data"]
    return rows[0]["id"] if rows else None

def advance_counter(token, state_id, new_last_value):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/number-range-state/{state_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json={"lastValue": new_last_value},
        timeout=30,
    )
    r.raise_for_status()
apply.js
async function findOrderNumberRangeStateId(token) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "numberRange.type.technicalName", value: "order" }],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/number-range-state`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on number-range-state search`);
  const data = await res.json();
  return data.data.length ? data.data[0].id : null;
}

async function advanceCounter(token, stateId, newLastValue) {
  const res = await fetch(`${SHOPWARE_URL}/api/number-range-state/${stateId}`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({ lastValue: newLastValue }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on number-range-state patch`);
}
6

Wire it together with a dry run guard

The loop ties every piece together. Page every order, run the pure grouping function, log every duplicate group for manual reconciliation, then, only when DRY_RUN is off, patch number-range-state so the next order number is guaranteed clear of every collision found. The duplicate orders themselves are never rewritten by this script.

Run it safe

Never renumber a live order automatically. Order numbers are customer-facing and can already be on an invoice or in an external system. Start with DRY_RUN=true, review the reported duplicate pairs, and only let the script advance the counter once you trust the list. Reconciling the existing duplicate orders themselves, including which one to renumber and reissue documents for, is a manual, human decision.

The full code

Here is the complete script in one file for each language. It authenticates, pages every order, groups by orderNumber with the pure function, reports every duplicate group, and advances the number range counter past the highest existing value, respecting DRY_RUN.

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

find_duplicate_order_numbers.py
"""Detect and report duplicate Shopware 6 order numbers caused by a non-atomic
number range increment storage, without ever renumbering a live order.

Shopware generates orderNumber through the number_range system, which is atomic only
within a single, consistently configured increment storage: the database sequence
gateway, or Redis for high-throughput shops. Migrating a number range between storages
is explicitly documented as non-atomic, so numbers issued during the cutover window can
collide. Separately, a Redis instance for number ranges configured with allkeys-lru
instead of volatile-lru can evict the increment key under memory pressure, silently
resetting the counter and reissuing an already-used number.

This pages every order's id, orderNumber, and createdAt, groups them with a pure
function, and reports every group with more than one order id. Existing duplicate
orders are only ever reported for manual reconciliation. The one safe automated write,
gated by DRY_RUN, advances the number_range_state lastValue past the highest existing
orderNumber so no new duplicates are produced going forward. Run on demand. 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("find_duplicate_order_numbers")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAGE_LIMIT = 500


def find_duplicate_order_numbers(orders):
    """Pure decision. No I/O. Groups a list of orders by orderNumber and returns
    only the groups with more than one order id, sorted by count desc then
    orderNumber asc.
    """
    groups = {}
    for order in orders:
        groups.setdefault(order["orderNumber"], []).append(order["id"])

    duplicates = [
        {"orderNumber": number, "orderIds": ids, "count": len(ids)}
        for number, ids in groups.items()
        if len(ids) > 1
    ]
    duplicates.sort(key=lambda d: (-d["count"], d["orderNumber"]))
    return duplicates


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def page_orders(token, page):
    body = {
        "page": page,
        "limit": PAGE_LIMIT,
        "filter": [],
        "sort": [{"field": "orderNumber", "order": "ASC"}],
        "total-count-mode": 1,
        "associations": {},
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def all_orders(token):
    page = 1
    while True:
        rows = page_orders(token, page)
        for row in rows:
            yield {"id": row["id"], "orderNumber": row["orderNumber"], "createdAt": row["createdAt"]}
        if len(rows) < PAGE_LIMIT:
            return
        page += 1


def find_order_number_range_state_id(token):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "numberRange.type.technicalName", "value": "order"}],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/number-range-state",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    rows = r.json()["data"]
    return rows[0]["id"] if rows else None


def advance_counter(token, state_id, new_last_value):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/number-range-state/{state_id}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json={"lastValue": new_last_value},
        timeout=30,
    )
    r.raise_for_status()


def max_numeric_order_number(orders):
    values = [int(o["orderNumber"]) for o in orders if str(o["orderNumber"]).isdigit()]
    return max(values) if values else None


def run():
    token = get_token()
    orders = list(all_orders(token))
    duplicates = find_duplicate_order_numbers(orders)

    if not duplicates:
        log.info("Done. 0 duplicate order number(s) found across %d order(s).", len(orders))
        return

    for group in duplicates:
        log.warning(
            "Duplicate orderNumber %s across %d order(s): %s (manual reconciliation required)",
            group["orderNumber"], group["count"], ", ".join(group["orderIds"]),
        )

    highest = max_numeric_order_number(orders)
    if highest is None:
        log.warning("Could not determine a numeric max orderNumber, skipping counter advance.")
        log.info("Done. %d duplicate group(s) found and reported.", len(duplicates))
        return

    state_id = find_order_number_range_state_id(token)
    if not state_id:
        log.warning("Could not find number-range-state for the order type, skipping counter advance.")
        log.info("Done. %d duplicate group(s) found and reported.", len(duplicates))
        return

    log.warning("%s number-range-state %s lastValue to %d",
                "Would advance" if DRY_RUN else "Advancing", state_id, highest)
    if not DRY_RUN:
        advance_counter(token, state_id, highest)

    log.info("Done. %d duplicate group(s) found and reported, counter %s.",
              len(duplicates), "would be advanced" if DRY_RUN else "advanced")


if __name__ == "__main__":
    run()
find-duplicate-order-numbers.js
/**
 * Detect and report duplicate Shopware 6 order numbers caused by a non-atomic
 * number range increment storage, without ever renumbering a live order.
 *
 * Shopware generates orderNumber through the number_range system, which is atomic only
 * within a single, consistently configured increment storage: the database sequence
 * gateway, or Redis for high-throughput shops. Migrating a number range between
 * storages is explicitly documented as non-atomic, so numbers issued during the
 * cutover window can collide. Separately, a Redis instance for number ranges
 * configured with allkeys-lru instead of volatile-lru can evict the increment key
 * under memory pressure, silently resetting the counter and reissuing an already-used
 * number.
 *
 * This pages every order's id, orderNumber, and createdAt, groups them with a pure
 * function, and reports every group with more than one order id. Existing duplicate
 * orders are only ever reported for manual reconciliation. The one safe automated
 * write, gated by DRY_RUN, advances the number_range_state lastValue past the highest
 * existing orderNumber so no new duplicates are produced going forward. Run on demand.
 *
 * Guide: https://www.allanninal.dev/shopware/duplicate-order-numbers-race-or-eviction/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAGE_LIMIT = 500;

export function findDuplicateOrderNumbers(orders) {
  const groups = new Map();
  for (const order of orders) {
    const ids = groups.get(order.orderNumber) || [];
    ids.push(order.id);
    groups.set(order.orderNumber, ids);
  }

  const duplicates = [];
  for (const [orderNumber, orderIds] of groups) {
    if (orderIds.length > 1) {
      duplicates.push({ orderNumber, orderIds, count: orderIds.length });
    }
  }
  duplicates.sort((a, b) => b.count - a.count || (a.orderNumber < b.orderNumber ? -1 : a.orderNumber > b.orderNumber ? 1 : 0));
  return duplicates;
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function pageOrders(token, page) {
  const body = {
    page,
    limit: PAGE_LIMIT,
    filter: [],
    sort: [{ field: "orderNumber", order: "ASC" }],
    "total-count-mode": 1,
    associations: {},
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order search`);
  const data = await res.json();
  return data.data;
}

async function* allOrders(token) {
  let page = 1;
  while (true) {
    const rows = await pageOrders(token, page);
    for (const row of rows) {
      yield { id: row.id, orderNumber: row.orderNumber, createdAt: row.createdAt };
    }
    if (rows.length < PAGE_LIMIT) return;
    page++;
  }
}

async function findOrderNumberRangeStateId(token) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "numberRange.type.technicalName", value: "order" }],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/number-range-state`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on number-range-state search`);
  const data = await res.json();
  return data.data.length ? data.data[0].id : null;
}

async function advanceCounter(token, stateId, newLastValue) {
  const res = await fetch(`${SHOPWARE_URL}/api/number-range-state/${stateId}`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({ lastValue: newLastValue }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on number-range-state patch`);
}

function maxNumericOrderNumber(orders) {
  const values = orders
    .map((o) => o.orderNumber)
    .filter((n) => /^\d+$/.test(String(n)))
    .map(Number);
  return values.length ? Math.max(...values) : null;
}

export async function run() {
  const token = await getToken();
  const orders = [];
  for await (const order of allOrders(token)) orders.push(order);

  const duplicates = findDuplicateOrderNumbers(orders);

  if (!duplicates.length) {
    console.log(`Done. 0 duplicate order number(s) found across ${orders.length} order(s).`);
    return;
  }

  for (const group of duplicates) {
    console.warn(`Duplicate orderNumber ${group.orderNumber} across ${group.count} order(s): ${group.orderIds.join(", ")} (manual reconciliation required)`);
  }

  const highest = maxNumericOrderNumber(orders);
  if (highest === null) {
    console.warn("Could not determine a numeric max orderNumber, skipping counter advance.");
    console.log(`Done. ${duplicates.length} duplicate group(s) found and reported.`);
    return;
  }

  const stateId = await findOrderNumberRangeStateId(token);
  if (!stateId) {
    console.warn("Could not find number-range-state for the order type, skipping counter advance.");
    console.log(`Done. ${duplicates.length} duplicate group(s) found and reported.`);
    return;
  }

  console.warn(`${DRY_RUN ? "Would advance" : "Advancing"} number-range-state ${stateId} lastValue to ${highest}`);
  if (!DRY_RUN) await advanceCounter(token, stateId, highest);

  console.log(`Done. ${duplicates.length} duplicate group(s) found and reported, counter ${DRY_RUN ? "would be advanced" : "advanced"}.`);
}

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

Add a test

The grouping function is the part most worth testing, because it decides which order pairs get reported as duplicates. Because find_duplicate_order_numbers is pure, the test needs no network and no live Shopware store, just plain fixture arrays.

test_duplicate_order_numbers.py
from find_duplicate_order_numbers import find_duplicate_order_numbers


def order(order_id, order_number, created_at):
    return {"id": order_id, "orderNumber": order_number, "createdAt": created_at}


def test_no_duplicates_returns_empty_list():
    orders = [
        order("id-1", "10001", "2026-07-01T10:00:00Z"),
        order("id-2", "10002", "2026-07-01T10:05:00Z"),
        order("id-3", "10003", "2026-07-01T10:10:00Z"),
    ]
    assert find_duplicate_order_numbers(orders) == []


def test_one_duplicate_pair_is_reported():
    orders = [
        order("id-1", "10001", "2026-07-01T10:00:00Z"),
        order("id-2", "10002", "2026-07-01T10:05:00Z"),
        order("id-3", "10002", "2026-07-01T10:05:01Z"),
    ]
    result = find_duplicate_order_numbers(orders)
    assert result == [{"orderNumber": "10002", "orderIds": ["id-2", "id-3"], "count": 2}]


def test_three_way_duplicate_is_reported_with_count_three():
    orders = [
        order("id-1", "10005", "2026-07-01T10:00:00Z"),
        order("id-2", "10005", "2026-07-01T10:00:01Z"),
        order("id-3", "10005", "2026-07-01T10:00:02Z"),
    ]
    result = find_duplicate_order_numbers(orders)
    assert result == [{"orderNumber": "10005", "orderIds": ["id-1", "id-2", "id-3"], "count": 3}]


def test_duplicate_still_counted_when_one_order_is_cancelled_or_deleted_state():
    # orderNumber uniqueness is required regardless of the order's own status,
    # so a duplicate pair is still reported even if one side was cancelled.
    orders = [
        order("id-1", "10009", "2026-07-01T10:00:00Z"),
        order("id-2", "10009", "2026-07-02T09:00:00Z"),
    ]
    result = find_duplicate_order_numbers(orders)
    assert result == [{"orderNumber": "10009", "orderIds": ["id-1", "id-2"], "count": 2}]


def test_sorted_by_count_desc_then_order_number_asc():
    orders = [
        order("id-1", "20001", "2026-07-01T10:00:00Z"),
        order("id-2", "20001", "2026-07-01T10:00:01Z"),
        order("id-3", "10000", "2026-07-01T09:00:00Z"),
        order("id-4", "10000", "2026-07-01T09:00:01Z"),
        order("id-5", "10000", "2026-07-01T09:00:02Z"),
    ]
    result = find_duplicate_order_numbers(orders)
    assert [d["orderNumber"] for d in result] == ["10000", "20001"]
duplicate-order-numbers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateOrderNumbers } from "./find-duplicate-order-numbers.js";

const order = (id, orderNumber, createdAt) => ({ id, orderNumber, createdAt });

test("no duplicates returns empty list", () => {
  const orders = [
    order("id-1", "10001", "2026-07-01T10:00:00Z"),
    order("id-2", "10002", "2026-07-01T10:05:00Z"),
    order("id-3", "10003", "2026-07-01T10:10:00Z"),
  ];
  assert.deepEqual(findDuplicateOrderNumbers(orders), []);
});

test("one duplicate pair is reported", () => {
  const orders = [
    order("id-1", "10001", "2026-07-01T10:00:00Z"),
    order("id-2", "10002", "2026-07-01T10:05:00Z"),
    order("id-3", "10002", "2026-07-01T10:05:01Z"),
  ];
  assert.deepEqual(findDuplicateOrderNumbers(orders), [
    { orderNumber: "10002", orderIds: ["id-2", "id-3"], count: 2 },
  ]);
});

test("three-way duplicate is reported with count three", () => {
  const orders = [
    order("id-1", "10005", "2026-07-01T10:00:00Z"),
    order("id-2", "10005", "2026-07-01T10:00:01Z"),
    order("id-3", "10005", "2026-07-01T10:00:02Z"),
  ];
  assert.deepEqual(findDuplicateOrderNumbers(orders), [
    { orderNumber: "10005", orderIds: ["id-1", "id-2", "id-3"], count: 3 },
  ]);
});

test("duplicate still counted when one order is cancelled or deleted state", () => {
  const orders = [
    order("id-1", "10009", "2026-07-01T10:00:00Z"),
    order("id-2", "10009", "2026-07-02T09:00:00Z"),
  ];
  assert.deepEqual(findDuplicateOrderNumbers(orders), [
    { orderNumber: "10009", orderIds: ["id-1", "id-2"], count: 2 },
  ]);
});

test("sorted by count desc then order number asc", () => {
  const orders = [
    order("id-1", "20001", "2026-07-01T10:00:00Z"),
    order("id-2", "20001", "2026-07-01T10:00:01Z"),
    order("id-3", "10000", "2026-07-01T09:00:00Z"),
    order("id-4", "10000", "2026-07-01T09:00:01Z"),
    order("id-5", "10000", "2026-07-01T09:00:02Z"),
  ];
  const result = findDuplicateOrderNumbers(orders);
  assert.deepEqual(result.map((d) => d.orderNumber), ["10000", "20001"]);
});

Case studies

Redis migration

The scale up that collided ninety minutes in

A fast-growing shop moved its order number range from the database sequence gateway to Redis increment storage ahead of a flash sale, expecting the switch to be instant. The migration itself completed in under a minute, but for a window afterward some in-flight checkout requests still resolved against the old storage while newly started ones already hit Redis.

Running the detection script the next morning surfaced four duplicate orderNumber pairs, all created within that same ninety minute window. Each pair was reported with both order ids and timestamps for the finance team to manually renumber the later order and reissue its invoice. The script's counter advance step ensured no fifth collision could happen from that same gap.

Redis eviction

The allkeys-lru policy nobody remembered setting

A store had shared one Redis instance between session caching and the number range increment storage for over a year without issue, until a marketing campaign drove a spike in both traffic and cart abandonment, filling Redis to its memory limit. The instance was configured with allkeys-lru, so Redis evicted whatever key it judged least recently used, including the order number counter.

The counter came back from its configured default on the very next order, and the detection script's duplicate group turned up an order number issued a full six months earlier next to one issued that afternoon. Comparing the live number-range-state lastValue against the highest existing orderNumber confirmed the counter was now far behind, the tell-tale sign of an eviction reset. Switching the policy to volatile-lru and setting a TTL-free key for the counter alone stopped it from recurring.

What good looks like

After this runs, a duplicate order number stops being an invisible accounting mistake and becomes a reported, timestamped pair that a human can reconcile deliberately, including deciding which order to renumber and which documents to reissue. The counter itself gets nudged past every collision found, so the same gap cannot produce a fifth or sixth duplicate while the real fix, correcting the storage migration process or the Redis eviction policy, gets scheduled.

FAQ

Why do two Shopware orders sometimes get the same order number?

Shopware guarantees a unique orderNumber only when the number range increment storage is a single, consistently configured, atomic backend, either the database sequence gateway or Redis. Migrating a number range between storages is explicitly documented as non-atomic, so numbers generated during that cutover window can collide. Separately, if the Redis instance used for number ranges is configured with allkeys-lru instead of volatile-lru, the increment key can be evicted under memory pressure and the counter silently restarts, handing out an orderNumber that was already used.

Is it safe to auto-fix duplicate order numbers by renumbering an order?

No. Order numbers are customer-facing, they appear on invoices and other documents, and they may already be referenced in external systems like accounting or shipping. Renumbering a live order automatically can break that trail. The safe response is to flag and report each duplicate pair for a human to reconcile manually, never to silently rewrite an existing orderNumber.

What is the one safe automated write once duplicates are found?

The only safe automated write is advancing the number_range_state lastValue past the highest orderNumber already on record, so the next increment is guaranteed higher than every number that exists today. That stops new duplicates from being produced going forward. It does not touch the existing duplicate orders themselves, which still need manual review and, if needed, manual renumbering and reissued documents.

Related field notes

Citations

On the problem:

  1. Shopware Developer Documentation: Number Ranges, including the non-atomic storage migration warning. developer.shopware.com/docs/guides/hosting/performance/number-ranges.html
  2. Shopware Developer Documentation: Redis, including volatile-lru versus allkeys-lru eviction risk for number range increments. developer.shopware.com/docs/guides/hosting/infrastructure/redis.html
  3. Community script for finding duplicate order numbers in the Shopware s_order table, a real-world recurring problem. gist.github.com/maxout/eda029850442e5663d087d886aa7bae5

On the solution:

  1. Shopware Developer Documentation: Number Ranges. developer.shopware.com/docs/guides/hosting/performance/number-ranges.html
  2. Shopware Developer Documentation: Redis. developer.shopware.com/docs/guides/hosting/infrastructure/redis.html
  3. NumberRange, Admin API Reference. shopware.stoplight.io/docs/admin-api/a9621db91f093-number-range

Stuck on a tricky one?

If you have a problem in Shopware orders, payments, number ranges, or documents 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 catch a collision before it spread?

If this saved you from a pile of duplicate invoices or an awkward accounting reconciliation, 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 Shopware field notes