Skip to content

Diagnostic Orders and Grid Sync

Duplicate or colliding order increment id

Two customers call about the same order number, or your payment gateway return URL lands on the wrong receipt, or your ERP sync starts overwriting one order's data with another's. Magento's internal entity_id primary key never collided. But the human-facing increment_id, the one printed on invoices, used in gateway callbacks, and typed into your ERP, quietly points at two different rows. Here is why the sequence tables behind increment_id can hand out the same number twice, and a small script that finds every collision and flags it without touching the number itself.

Python and Node.js Orders REST API Safe by default (dry run report and flag)
A file cabinet
Photo by Maksym Kaharlytskyi on Unsplash
The short answer

Magento 2 and Adobe Commerce generate increment_id from the sales_sequence_meta and sales_sequence_profile tables, which hold a per-store (or per-store-view) prefix, pad length, and step, not one global counter. After a Magento 1 migration through the data-migration-tool, or after a multi-store-view setup gets reconfigured, the profile's prefix is often set to the wrong scope, store_id instead of store_view_id, or two profiles end up sharing the same underlying sequence table and start value. Two independent order streams then generate the identical padded increment_id, such as 000000045, for different entity_id rows in sales_order. You cannot detect this over REST by asking for the sequence tables directly, since they have no endpoint, but you can page GET /rest/V1/orders, group by increment_id, and flag any group with more than one entity_id. The safe fix is never to rewrite increment_id over the API. It is to report the collision and, only when explicitly told to write, add a visible status history comment so a human corrects the sequence table itself. Full code, tests, and a dry run guard are below.

The problem in plain words

Every Magento order has two identifiers. The entity_id is the plain auto increment primary key on the sales_order table, and it is always unique, Magento's own database guarantees it. The increment_id is a separate, formatted number, the one a customer actually sees, the one on the invoice, the one the payment gateway sends back in its return URL, the one typed into an ERP as the order key.

That second number does not come from a simple counter. It comes from sales_sequence_meta and sales_sequence_profile, a pair of tables that let Magento run a separate numbering sequence per store, with its own prefix, pad length, and step, so a multi-store setup can print different order number formats per storefront. Under normal operation this works fine, every store keeps its own counter and nothing overlaps.

The trouble starts when that per-store scoping gets confused. A Magento 1 to Magento 2 migration through the data-migration-tool, or a later change to how store views map to stores, can leave a sequence profile's prefix keyed to the wrong scope, the store_id instead of the intended store_view_id, or leave two separate profiles pointing at the same underlying sequence table with the same starting value. From that point on, two order streams that Magento believes are independent are actually drawing numbers from the same well. Eventually both streams produce the same padded increment_id for two entirely different orders. The entity_id primary key never breaks, so Magento itself never throws an error. But every increment_id based lookup, a payment gateway return URL, an ERP sync key, a customer typing their order number into a support form, now has a fifty-fifty chance of resolving to the wrong order.

Store A order stream entity_id 501 Store B order stream entity_id 812 Same sales_sequence_profile wrong scope after migration increment_id 000000045 assigned to entity_id 501 increment_id 000000045 also assigned to entity_id 812
Two different entity_id rows end up sharing the same increment_id, because the sequence profiles behind them were never truly independent.

Why it happens

This exact pattern shows up repeatedly across Magento's own issue tracker: duplicate order numbers appearing right after a Magento 1 migration, and sequence prefix or pad length settings that silently refuse to apply cleanly across scopes. See the citations at the end for the specific threads.

The key insight

entity_id staying unique is not proof that everything is fine. It just means Magento's own database constraint never broke. increment_id is a second, separately generated identifier that lives entirely in sales_sequence_meta and sales_sequence_profile, tables with no REST endpoint, so a script cannot ask Magento directly whether two sequence profiles collide. What it can do is ask for every order's entity_id and increment_id pair, group by increment_id, and treat any group with more than one distinct entity_id as proof that a collision already happened, regardless of what caused it in the sequence tables underneath.

The fix, as a flow

We never renumber increment_id over REST, because it is already referenced by payment gateway return URLs, invoices and shipments generated from it, and ERP records, so silently changing it can break reconciliation somewhere you cannot see from the API. Instead we add a job that pages every order, groups by increment_id, reports every collision it finds, and, only when a write is explicitly requested, adds a visible status history comment to the duplicate so a human goes and fixes the sequence table at its source.

Scheduled job runs on a timer Page GET /V1/orders entity_id, increment_id, store_id Group by increment_id findDuplicateIncrementIds More than 1 entity_id? yes no, report ok Report and, if DRY_RUN=false, flag with a comment
The script only ever reports and, when explicitly asked to write, adds a comment. It never renumbers increment_id itself.

Build it step by step

1

Get an admin bearer token

Authenticate the same way as any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export PAGE_SIZE="200"
export DRY_RUN="true"   # start safe, change to false to post flag comments
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export PAGE_SIZE="200"
export DRY_RUN="true"   // start safe, change to false to post flag comments
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps GET and POST and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def magento_post(path, payload):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=payload,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPost(path, payload) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Page every order and pull only the fields we need

Call GET /rest/V1/orders with searchCriteria[pageSize] and searchCriteria[currentPage], sorted by increment_id so identical values land near each other in the response, and pass fields=items[entity_id,increment_id,store_id,created_at,status,customer_email],total_count to keep every page small. Keep requesting pages until current_page * page_size passes total_count.

step3.py
FIELDS = "items[entity_id,increment_id,store_id,created_at,status,customer_email],total_count"

def all_orders(page_size=200):
    current_page = 1
    while True:
        params = {
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": current_page,
            "searchCriteria[sortOrders][0][field]": "increment_id",
            "searchCriteria[sortOrders][0][direction]": "ASC",
            "fields": FIELDS,
        }
        data = magento_get("/orders", params)
        for item in data["items"]:
            yield item
        if current_page * page_size >= data["total_count"]:
            return
        current_page += 1
step3.js
const FIELDS = "items[entity_id,increment_id,store_id,created_at,status,customer_email],total_count";

async function* allOrders(pageSize = 200) {
  let currentPage = 1;
  while (true) {
    const params = {
      "searchCriteria[pageSize]": pageSize,
      "searchCriteria[currentPage]": currentPage,
      "searchCriteria[sortOrders][0][field]": "increment_id",
      "searchCriteria[sortOrders][0][direction]": "ASC",
      fields: FIELDS,
    };
    const data = await magentoGet("/orders", params);
    for (const item of data.items) yield item;
    if (currentPage * pageSize >= data.total_count) return;
    currentPage += 1;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes a plain list of orders and groups them by incrementId. It is a pure map-reduce with no I/O, which makes it easy to test, as we do later. Only groups with more than one distinct entityId are collisions. Every group's members are sorted by createdAt ascending, so the first order ever created under that number is always members[0], and the groups themselves come back sorted by incrementId so the report reads in a stable order.

decide.py
def find_duplicate_increment_ids(orders):
    groups = {}
    for o in orders:
        groups.setdefault(o["incrementId"], []).append({
            "entityId": o["entityId"],
            "storeId": o["storeId"],
            "createdAt": o["createdAt"],
        })

    duplicates = []
    for increment_id, members in groups.items():
        distinct_entity_ids = {m["entityId"] for m in members}
        if len(distinct_entity_ids) <= 1:
            continue
        sorted_members = sorted(members, key=lambda m: m["createdAt"])
        duplicates.append({"incrementId": increment_id, "members": sorted_members})

    duplicates.sort(key=lambda d: d["incrementId"])
    return duplicates
decide.js
export function findDuplicateIncrementIds(orders) {
  const groups = new Map();
  for (const o of orders) {
    const list = groups.get(o.incrementId) || [];
    list.push({ entityId: o.entityId, storeId: o.storeId, createdAt: o.createdAt });
    groups.set(o.incrementId, list);
  }

  const duplicates = [];
  for (const [incrementId, members] of groups) {
    const distinctEntityIds = new Set(members.map((m) => m.entityId));
    if (distinctEntityIds.size <= 1) continue;
    const sortedMembers = [...members].sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
    duplicates.push({ incrementId, members: sortedMembers });
  }

  duplicates.sort((a, b) => (a.incrementId < b.incrementId ? -1 : a.incrementId > b.incrementId ? 1 : 0));
  return duplicates;
}
5

Cross check each collision, then flag, never renumber

For each colliding entityId, call GET /rest/V1/orders/{entity_id} to confirm the exact store_id and timestamp, which usually shows the pattern clearly, all the members share a prefix but differ in store_id, or they cluster right at a migration cutover. Report every collision either way. Only when DRY_RUN=false is explicitly set, post a non destructive status history comment on every duplicate after members[0] with POST /rest/V1/orders/{id}/comments, so support and ERP staff see a visible marker. Nothing here ever calls a save on increment_id itself.

flag.py
FLAG_COMMENT = (
    "Duplicate increment_id detected - flagged for manual sequence-table correction."
)

def flag_duplicate_order(entity_id):
    payload = {
        "statusHistory": {
            "comment": FLAG_COMMENT,
            "is_customer_notified": 0,
            "is_visible_on_front": 0,
        }
    }
    return magento_post(f"/orders/{entity_id}/comments", payload)
flag.js
const FLAG_COMMENT =
  "Duplicate increment_id detected - flagged for manual sequence-table correction.";

async function flagDuplicateOrder(entityId) {
  const payload = {
    statusHistory: {
      comment: FLAG_COMMENT,
      is_customer_notified: 0,
      is_visible_on_front: 0,
    },
  };
  return magentoPost(`/orders/${entityId}/comments`, payload);
}
6

Wire it together with a dry run guard

The loop pages every order, groups them with the pure function, and prints one report line per colliding incrementId with every member's entityId, storeId, and createdAt. Leave DRY_RUN on for the first runs so nothing is written, just reported. Only flip it to false once you have reviewed the report and are ready to leave a marker for staff. Run it on a schedule that fits how often you migrate stores or touch sequence configuration, for example nightly.

Run it safe

This script never rewrites increment_id. With DRY_RUN=true, the default, it only prints the collision report. With DRY_RUN=false, it additionally posts a non destructive status history comment on the duplicate orders, never a save that changes the order number. The real fix, correcting sales_sequence_profile.prefix and pad length or reseeding the sales_sequence_XXX table, is a CLI or database migration task outside REST.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages every order, groups them with the pure function, prints a full collision report, and only posts flag comments when explicitly told to write. It is safe to run again and again because it never renumbers anything.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
find_duplicate_increment_ids.py
"""Find and flag duplicate or colliding order increment_id values in Magento 2.

Magento generates increment_id from the sales_sequence_meta and
sales_sequence_profile tables, which store a per-store prefix, pad length,
and step rather than one global counter. A Magento 1 migration, or a
multi-store-view reconfiguration, can leave two profiles pointing at the
same underlying sequence table, so two independent order streams end up
producing the same increment_id for two different entity_id rows. This
never rewrites increment_id. It pages every order, groups by increment_id
with a pure function, always reports collisions, and only when DRY_RUN is
explicitly false posts a non destructive status history comment flagging
the duplicate for manual sequence-table correction. Run on a schedule.
Safe to run again and again.
"""
import os
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "200"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

FIELDS = "items[entity_id,increment_id,store_id,created_at,status,customer_email],total_count"

FLAG_COMMENT = (
    "Duplicate increment_id detected - flagged for manual sequence-table correction."
)


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def magento_post(path, payload):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=payload,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def all_orders(page_size=200):
    current_page = 1
    while True:
        params = {
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": current_page,
            "searchCriteria[sortOrders][0][field]": "increment_id",
            "searchCriteria[sortOrders][0][direction]": "ASC",
            "fields": FIELDS,
        }
        data = magento_get("/orders", params)
        for item in data["items"]:
            yield item
        if current_page * page_size >= data["total_count"]:
            return
        current_page += 1


def normalize_order(item):
    return {
        "entityId": item.get("entity_id"),
        "incrementId": item.get("increment_id"),
        "storeId": item.get("store_id"),
        "createdAt": item.get("created_at"),
    }


def find_duplicate_increment_ids(orders):
    groups = {}
    for o in orders:
        groups.setdefault(o["incrementId"], []).append({
            "entityId": o["entityId"],
            "storeId": o["storeId"],
            "createdAt": o["createdAt"],
        })

    duplicates = []
    for increment_id, members in groups.items():
        distinct_entity_ids = {m["entityId"] for m in members}
        if len(distinct_entity_ids) <= 1:
            continue
        sorted_members = sorted(members, key=lambda m: m["createdAt"])
        duplicates.append({"incrementId": increment_id, "members": sorted_members})

    duplicates.sort(key=lambda d: d["incrementId"])
    return duplicates


def flag_duplicate_order(entity_id):
    payload = {
        "statusHistory": {
            "comment": FLAG_COMMENT,
            "is_customer_notified": 0,
            "is_visible_on_front": 0,
        }
    }
    return magento_post(f"/orders/{entity_id}/comments", payload)


def run():
    raw_items = list(all_orders(PAGE_SIZE))
    orders = [normalize_order(item) for item in raw_items]

    duplicates = find_duplicate_increment_ids(orders)

    if not duplicates:
        log.info("Done. 0 duplicate increment_id group(s) found.")
        return

    flagged = 0
    for dup in duplicates:
        member_summary = ", ".join(
            f"entity_id={m['entityId']} store_id={m['storeId']} created_at={m['createdAt']}"
            for m in dup["members"]
        )
        log.warning("increment_id %s has %d order(s): %s", dup["incrementId"], len(dup["members"]), member_summary)

        for member in dup["members"][1:]:
            log.warning(
                "  -> %s entity_id %s.", "would flag" if DRY_RUN else "flagging", member["entityId"]
            )
            if not DRY_RUN:
                flag_duplicate_order(member["entityId"])
            flagged += 1

    log.info(
        "Done. %d duplicate increment_id group(s), %d order(s) %s.",
        len(duplicates), flagged, "to flag" if DRY_RUN else "flagged",
    )


if __name__ == "__main__":
    run()
find-duplicate-increment-ids.js
/**
 * Find and flag duplicate or colliding order increment_id values in Magento 2.
 *
 * Magento generates increment_id from the sales_sequence_meta and
 * sales_sequence_profile tables, which store a per-store prefix, pad length,
 * and step rather than one global counter. A Magento 1 migration, or a
 * multi-store-view reconfiguration, can leave two profiles pointing at the
 * same underlying sequence table, so two independent order streams end up
 * producing the same increment_id for two different entity_id rows. This
 * never rewrites increment_id. It pages every order, groups by increment_id
 * with a pure function, always reports collisions, and only when DRY_RUN is
 * explicitly false posts a non destructive status history comment flagging
 * the duplicate for manual sequence-table correction. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/magento/duplicate-order-increment-id/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 200);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const FIELDS = "items[entity_id,increment_id,store_id,created_at,status,customer_email],total_count";

const FLAG_COMMENT =
  "Duplicate increment_id detected - flagged for manual sequence-table correction.";

export function findDuplicateIncrementIds(orders) {
  const groups = new Map();
  for (const o of orders) {
    const list = groups.get(o.incrementId) || [];
    list.push({ entityId: o.entityId, storeId: o.storeId, createdAt: o.createdAt });
    groups.set(o.incrementId, list);
  }

  const duplicates = [];
  for (const [incrementId, members] of groups) {
    const distinctEntityIds = new Set(members.map((m) => m.entityId));
    if (distinctEntityIds.size <= 1) continue;
    const sortedMembers = [...members].sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
    duplicates.push({ incrementId, members: sortedMembers });
  }

  duplicates.sort((a, b) => (a.incrementId < b.incrementId ? -1 : a.incrementId > b.incrementId ? 1 : 0));
  return duplicates;
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPost(path, payload) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function* allOrders(pageSize = 200) {
  let currentPage = 1;
  while (true) {
    const params = {
      "searchCriteria[pageSize]": pageSize,
      "searchCriteria[currentPage]": currentPage,
      "searchCriteria[sortOrders][0][field]": "increment_id",
      "searchCriteria[sortOrders][0][direction]": "ASC",
      fields: FIELDS,
    };
    const data = await magentoGet("/orders", params);
    for (const item of data.items) yield item;
    if (currentPage * pageSize >= data.total_count) return;
    currentPage += 1;
  }
}

function normalizeOrder(item) {
  return {
    entityId: item.entity_id,
    incrementId: item.increment_id,
    storeId: item.store_id,
    createdAt: item.created_at,
  };
}

async function flagDuplicateOrder(entityId) {
  const payload = {
    statusHistory: {
      comment: FLAG_COMMENT,
      is_customer_notified: 0,
      is_visible_on_front: 0,
    },
  };
  return magentoPost(`/orders/${entityId}/comments`, payload);
}

export async function run() {
  const rawItems = [];
  for await (const item of allOrders(PAGE_SIZE)) rawItems.push(item);
  const orders = rawItems.map(normalizeOrder);

  const duplicates = findDuplicateIncrementIds(orders);

  if (duplicates.length === 0) {
    console.log("Done. 0 duplicate increment_id group(s) found.");
    return;
  }

  let flagged = 0;
  for (const dup of duplicates) {
    const memberSummary = dup.members
      .map((m) => `entity_id=${m.entityId} store_id=${m.storeId} created_at=${m.createdAt}`)
      .join(", ");
    console.warn(`increment_id ${dup.incrementId} has ${dup.members.length} order(s): ${memberSummary}`);

    for (const member of dup.members.slice(1)) {
      console.warn(`  -> ${DRY_RUN ? "would flag" : "flagging"} entity_id ${member.entityId}.`);
      if (!DRY_RUN) await flagDuplicateOrder(member.entityId);
      flagged++;
    }
  }

  console.log(
    `Done. ${duplicates.length} duplicate increment_id group(s), ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`
  );
}

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

Add a test

The grouping rule is the part most worth testing, because it decides which orders get reported and flagged as duplicates. Because we kept find_duplicate_increment_ids pure, the test needs no network and no Magento store. It just feeds in plain fixture orders and checks the answer.

test_duplicate_increment_ids.py
from find_duplicate_increment_ids import find_duplicate_increment_ids


def order(**over):
    base = {
        "entityId": 501,
        "incrementId": "000000045",
        "storeId": 1,
        "createdAt": "2026-07-01T10:00:00Z",
    }
    base.update(over)
    return base


def test_no_duplicates_when_all_increment_ids_unique():
    orders = [order(entityId=1, incrementId="1"), order(entityId=2, incrementId="2")]
    assert find_duplicate_increment_ids(orders) == []


def test_detects_collision_across_two_entity_ids():
    orders = [
        order(entityId=501, incrementId="000000045", createdAt="2026-07-01T10:00:00Z"),
        order(entityId=812, incrementId="000000045", createdAt="2026-07-02T09:00:00Z"),
    ]
    result = find_duplicate_increment_ids(orders)
    assert len(result) == 1
    assert result[0]["incrementId"] == "000000045"
    assert len(result[0]["members"]) == 2


def test_same_entity_id_repeated_is_not_a_collision():
    orders = [order(entityId=501, incrementId="000000045"), order(entityId=501, incrementId="000000045")]
    assert find_duplicate_increment_ids(orders) == []


def test_members_sorted_by_created_at_ascending():
    orders = [
        order(entityId=812, incrementId="000000045", createdAt="2026-07-02T09:00:00Z"),
        order(entityId=501, incrementId="000000045", createdAt="2026-07-01T10:00:00Z"),
    ]
    result = find_duplicate_increment_ids(orders)
    assert result[0]["members"][0]["entityId"] == 501
    assert result[0]["members"][1]["entityId"] == 812


def test_groups_sorted_by_increment_id_ascending():
    orders = [
        order(entityId=1, incrementId="000000099"), order(entityId=2, incrementId="000000099"),
        order(entityId=3, incrementId="000000010"), order(entityId=4, incrementId="000000010"),
    ]
    result = find_duplicate_increment_ids(orders)
    assert [d["incrementId"] for d in result] == ["000000010", "000000099"]


def test_three_way_collision_is_one_group_with_three_members():
    orders = [
        order(entityId=1, incrementId="000000005", createdAt="2026-07-01T00:00:00Z"),
        order(entityId=2, incrementId="000000005", createdAt="2026-07-02T00:00:00Z"),
        order(entityId=3, incrementId="000000005", createdAt="2026-07-03T00:00:00Z"),
    ]
    result = find_duplicate_increment_ids(orders)
    assert len(result) == 1
    assert len(result[0]["members"]) == 3


def test_empty_input_returns_empty_list():
    assert find_duplicate_increment_ids([]) == []
duplicate-increment-ids.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateIncrementIds } from "./find-duplicate-increment-ids.js";

const order = (over = {}) => ({
  entityId: 501,
  incrementId: "000000045",
  storeId: 1,
  createdAt: "2026-07-01T10:00:00Z",
  ...over,
});

test("no duplicates when all increment_ids unique", () => {
  const orders = [order({ entityId: 1, incrementId: "1" }), order({ entityId: 2, incrementId: "2" })];
  assert.deepEqual(findDuplicateIncrementIds(orders), []);
});

test("detects collision across two entity ids", () => {
  const orders = [
    order({ entityId: 501, incrementId: "000000045", createdAt: "2026-07-01T10:00:00Z" }),
    order({ entityId: 812, incrementId: "000000045", createdAt: "2026-07-02T09:00:00Z" }),
  ];
  const result = findDuplicateIncrementIds(orders);
  assert.equal(result.length, 1);
  assert.equal(result[0].incrementId, "000000045");
  assert.equal(result[0].members.length, 2);
});

test("same entity id repeated is not a collision", () => {
  const orders = [order({ entityId: 501, incrementId: "000000045" }), order({ entityId: 501, incrementId: "000000045" })];
  assert.deepEqual(findDuplicateIncrementIds(orders), []);
});

test("members sorted by createdAt ascending", () => {
  const orders = [
    order({ entityId: 812, incrementId: "000000045", createdAt: "2026-07-02T09:00:00Z" }),
    order({ entityId: 501, incrementId: "000000045", createdAt: "2026-07-01T10:00:00Z" }),
  ];
  const result = findDuplicateIncrementIds(orders);
  assert.equal(result[0].members[0].entityId, 501);
  assert.equal(result[0].members[1].entityId, 812);
});

test("groups sorted by incrementId ascending", () => {
  const orders = [
    order({ entityId: 1, incrementId: "000000099" }), order({ entityId: 2, incrementId: "000000099" }),
    order({ entityId: 3, incrementId: "000000010" }), order({ entityId: 4, incrementId: "000000010" }),
  ];
  const result = findDuplicateIncrementIds(orders);
  assert.deepEqual(result.map((d) => d.incrementId), ["000000010", "000000099"]);
});

test("three way collision is one group with three members", () => {
  const orders = [
    order({ entityId: 1, incrementId: "000000005", createdAt: "2026-07-01T00:00:00Z" }),
    order({ entityId: 2, incrementId: "000000005", createdAt: "2026-07-02T00:00:00Z" }),
    order({ entityId: 3, incrementId: "000000005", createdAt: "2026-07-03T00:00:00Z" }),
  ];
  const result = findDuplicateIncrementIds(orders);
  assert.equal(result.length, 1);
  assert.equal(result[0].members.length, 3);
});

test("empty input returns empty list", () => {
  assert.deepEqual(findDuplicateIncrementIds([]), []);
});

Case studies

Magento 1 migration

The wrong scope carried across the migration

A multi-store retailer migrated from Magento 1 using the data-migration-tool. The migration ran cleanly, orders imported, and everything looked fine for weeks. Then a customer support ticket cited an order number that pointed at someone else's order when a staff member looked it up in the admin grid.

Running the collision report over /rest/V1/orders surfaced a cluster of duplicate increment_id values, every one of them landing right at the migration cutover timestamp, with members split across two different store_id values that had shared numbering under Magento 1 but were never reconciled after the move. The report gave support a clear, safe list to work from while an engineer corrected the affected sales_sequence_profile rows directly.

Store view reconfiguration

Two profiles quietly sharing one sequence table

A store added a new store view for a regional storefront and reorganized its store view mapping in the process. Nobody touched the sequence tables directly, but the reorganization left two sales_sequence_profile rows referencing the same underlying sales_sequence_XXX table and start value.

A nightly run of the script caught the first collision the same day it happened, before any invoice or shipment had been generated against the wrong order, since the duplicate had only just been created. The status history comment gave the ERP team a clear signal to hold sync on that order number until the sequence table was corrected.

What good looks like

After this runs on a schedule, a colliding increment_id is caught within one polling cycle instead of surviving until a payment gateway callback or an ERP sync resolves to the wrong order. The report carries every member of the collision, its entity_id, store_id, and created_at, and the only write this script ever makes is a visible, non destructive comment on the duplicate, so whoever responds knows exactly which sales_sequence_profile row to go fix, rather than guessing at a number that was never wrong in the database's own eyes.

FAQ

Why do two Magento orders end up with the same increment_id?

Magento generates increment_id from the sales_sequence_meta and sales_sequence_profile tables, which store a per-store prefix, pad length, and step rather than one global counter. After a Magento 1 migration, or after a multi-store-view setup gets reconfigured, the profile's prefix is often set to the store_id instead of the store_view_id, or two profiles end up pointing at the same underlying sequence table and start value. Independent order streams then generate the same padded increment_id for different entity_id rows, even though the internal entity_id primary key stays unique.

How do I find duplicate increment_id values without database access?

Page through GET /rest/V1/orders with a Bearer admin token, requesting only entity_id, increment_id, store_id, created_at, status, and customer_email through the fields query parameter, and sorting by increment_id to make grouping cheaper. Build a map of increment_id to the list of entity_id and store_id pairs that used it. Any increment_id with more than one distinct entity_id is a collision, and calling GET /rest/V1/orders/{entity_id} for each member confirms the store_id and timestamp pattern behind it.

Is it safe to fix a duplicate increment_id automatically over the REST API?

No. increment_id is referenced externally by payment gateway return URLs, invoices and shipments already generated from it, and ERP records, so silently renumbering it over REST can break reconciliation elsewhere. The safe corrective action is a dry run guarded report that only adds a visible status history comment to the duplicate order for manual review. The actual repair, correcting sales_sequence_profile prefix and pad length or reseeding the sequence table, has to be done through CLI or a database migration script, per Adobe's sequence documentation.

Related field notes

Citations

On the problem:

  1. GitHub Issue: after migration order increment id is duplicate. github.com/magento/data-migration-tool/issues/731
  2. GitHub Issue: order increment id collision. github.com/magento/magento2/issues/33457
  3. GitHub Issue: sales sequence prefix and pad length will not change. github.com/magento/magento2/issues/7397

On the solution:

  1. Adobe Commerce: search using REST endpoints. developer.adobe.com/commerce/webapi/rest/use-rest/performing-searches
  2. Adobe Commerce: order processing tutorial. developer.adobe.com/commerce/webapi/rest/tutorials/orders
  3. Adobe Commerce: REST API reference. developer.adobe.com/commerce/webapi/rest/reference

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce orders, catalog data, cron, or inventory 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 your ERP did?

If this saved you a confusing support ticket or a wrong 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 Magento field notes