Generating Compliant Sequential Invoice Numbers

The moment you discover that two invoices share a number — or that an auditor found a gap between INV-2026-000041 and INV-2026-000043 — you learn that invoice numbering is a concurrency problem dressed as a formatting problem. Many tax jurisdictions (most of the EU, much of Latin America, and others) legally require invoice numbers to be sequential and gap-free per issuing legal entity, which quietly rules out the database tools engineers reach for first. This page sits under Invoicing & Credit Notes and covers exactly how to produce compliant numbers: why a Postgres SEQUENCE is the wrong tool, how to serialize finalization with row locks or advisory locks, how to handle year and prefix resets, and how to make finalization idempotent so a retry never burns a number. You reach for this when finalization is about to go concurrent and “gap-free” stops being free.

Trade-offs

There are three viable mechanisms and one trap. The trap is the bare SEQUENCE; the three real options differ in how they serialize concurrent finalizers and how they behave under contention.

Mechanism Gap-free? Concurrency model Contention cost Notes
Postgres SEQUENCE No Lock-free, caches values None Leaks numbers on rollback — non-compliant
Counter row + SELECT ... FOR UPDATE Yes Row lock per legal entity Serializes per entity Simplest compliant option
pg_advisory_xact_lock Yes Named lock per entity Serializes per entity No hot row; lock auto-released at commit
External coordinator (Redis/DO) Yes Single-writer Network hop Only if numbering must span databases

For a single Postgres-backed billing system the counter-row approach is the default: it is one table, it is transactional, and a rollback genuinely un-assigns the number because the increment lives in the same transaction. Advisory locks are the better fit when you want to avoid a hot row or coordinate numbering that is not naturally tied to a single table row.

Step-by-Step Implementation

1. Create a per-entity, per-year counter table

Scope the counter by both the legal entity (each issuer has its own series) and the period (so you can reset annually where the jurisdiction expects INV-2026-... to restart at 1).

CREATE TABLE invoice_sequences (
    legal_entity_id UUID    NOT NULL,
    document_type   TEXT    NOT NULL,   -- 'invoice' | 'credit_note'
    period_year     INT     NOT NULL,
    prefix          TEXT    NOT NULL,   -- e.g. 'INV'
    last_value      BIGINT  NOT NULL DEFAULT 0,
    PRIMARY KEY (legal_entity_id, document_type, period_year)
);

2. Increment under a lock inside the finalization transaction

The whole point is that the increment and the invoice’s status change commit together. With the counter row, a single UPDATE ... RETURNING takes the row lock and hands you the next value atomically.

-- runs inside the same transaction that finalizes the invoice
UPDATE invoice_sequences
   SET last_value = last_value + 1
 WHERE legal_entity_id = $1
   AND document_type   = 'invoice'
   AND period_year     = $2
RETURNING prefix, period_year, last_value;

Format the result into the human-facing number. Zero-pad so numbers sort lexically and look uniform:

def format_number(prefix: str, year: int, value: int) -> str:
    return f"{prefix}-{year}-{value:06d}"   # INV-2026-000042

3. Or serialize with a transactional advisory lock

If you prefer not to contend on a counter row — or your “next value” lives across a couple of tables — take a transaction-scoped advisory lock keyed by a hash of the entity and year. It auto-releases at commit or rollback, so there is no cleanup and no risk of a stuck lock.

def next_number_with_advisory_lock(entity_id: str, year: int) -> str:
    lock_key = stable_hash(entity_id, "invoice", year)  # 64-bit int
    with db.transaction():
        # ✅ blocks only other finalizers for THIS entity+year
        db.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
        current = db.fetchval(
            "SELECT last_value FROM invoice_sequences "
            "WHERE legal_entity_id=%s AND document_type='invoice' AND period_year=%s",
            [entity_id, year],
        )
        nxt = (current or 0) + 1
        upsert_sequence(entity_id, year, nxt)
        return format_number("INV", year, nxt)

4. Handle the year and prefix reset

At the year boundary you typically want the series to restart. Because the counter is keyed by period_year, the first finalization in a new year simply finds no row and inserts one starting at 1 — no special-case code, just an upsert. Decide your reset policy explicitly: some jurisdictions want a continuous lifetime series (never reset), others expect a per-year series. Encode it in the key you choose.

def ensure_sequence_row(entity_id: str, year: int, prefix: str) -> None:
    # idempotent: creates the new-year series on first use, no-op thereafter
    db.execute(
        "INSERT INTO invoice_sequences "
        "(legal_entity_id, document_type, period_year, prefix, last_value) "
        "VALUES (%s, 'invoice', %s, %s, 0) ON CONFLICT DO NOTHING",
        [entity_id, year, prefix],
    )

5. Make finalization idempotent

A finalization call can be retried — a timeout, a redelivered queue message, a double click. The retry must return the number already assigned, not consume a new one. Guard on the invoice’s own state: if it is already finalized, hand back its stored number without touching the counter.

def finalize(invoice_id: str) -> str:
    with db.transaction():
        inv = load_for_update(invoice_id)
        if inv.invoice_number is not None:
            return inv.invoice_number          # ⚠️ retry — reuse, never re-number

        ensure_sequence_row(inv.legal_entity_id, now().year, "INV")
        number = increment_and_format(inv.legal_entity_id, now().year)
        set_finalized(invoice_id, number)      # status + number commit together
        return number

Verification & Testing

The non-negotiable test is concurrency: fire many finalizations at once and assert the numbers form a contiguous, duplicate-free run. A reconciliation query then proves gap-freeness against live data.

def test_no_gaps_under_concurrency():
    drafts = [create_draft(ENTITY) for _ in range(500)]
    numbers = run_in_parallel(finalize, drafts, workers=32)
    seq = sorted(int(n.split("-")[-1]) for n in numbers)
    assert len(set(seq)) == 500                       # no duplicates
    assert seq == list(range(1, 501))                 # gap-free 1..500

def test_finalize_is_idempotent():
    d = create_draft(ENTITY)
    first = finalize(d)
    second = finalize(d)                              # retry
    assert first == second                            # same number, no burn

Run a standing reconciliation query in production to catch any gap a bug might introduce:

-- any row where the next number isn't exactly +1 reveals a gap
SELECT invoice_number
FROM (
  SELECT invoice_number,
         (regexp_replace(invoice_number, '\D', '', 'g'))::BIGINT AS n,
         LAG((regexp_replace(invoice_number, '\D', '', 'g'))::BIGINT)
           OVER (PARTITION BY legal_entity_id ORDER BY finalized_at) AS prev_n
  FROM invoices
  WHERE status = 'finalized' AND legal_entity_id = $1 AND finalized_at >= $2
) g
WHERE prev_n IS NOT NULL AND n <> prev_n + 1;          -- must return zero rows

Gotchas & Production Pitfalls

  • Reaching for nextval(). A Postgres sequence is lock-free precisely because it does not roll back; a failed finalization leaves a permanent hole. It is the single most common cause of “gap-free” violations. Use a counter row or advisory lock instead.
  • Assigning the number outside the finalization transaction. If you grab the next number, then commit the invoice in a separate transaction, a crash in between burns a number with no document attached. Keep the increment and the status flip in one transaction.
  • Sharing one global counter across legal entities. Numbering must be per issuing entity. A single global counter both leaks one entity’s volume to another and usually violates the requirement that each entity’s series be independent and gap-free.
  • Forgetting the year-boundary race. The first two finalizations after midnight on January 1 can race to create the new-year row. Use INSERT ... ON CONFLICT DO NOTHING to make row creation idempotent, then increment.
  • Non-idempotent retries. A redelivered finalization message that re-increments the counter creates a gap (the first number is now attached to nothing useful) or a duplicate. Always check whether the invoice already carries a number before assigning one.
  • Mixing credit notes into the invoice series. Credit notes need their own gap-free series. Sharing the invoice counter makes both series non-contiguous from each authority’s point of view; key the counter by document_type.