Skip to content

Sequences (Record Numbering)

A document’s reference — an invoice number, an order reference — is derived from the documents that already exist, not from a stored counter. A Sequence record defines the shape of the number (prefix, suffix, padding, when it resets); the records themselves are the record of which numbers have been used.

A model declares which of its columns are numbered, and the engine fills them.

Not the view ordering field. This page is about numbering records. It is unrelated to the integer sequence field used to order rows in list/kanban views — that one is just a sort key.

Put _numbering on the model, keyed by the column it numbers:

class SaleOrder(Model):
_unique_together = [("company", "name")]
_numbering = {
"name": {
"when": "create",
"sequence": "sale_order_sequence",
"date": "date",
},
}

That is the whole integration — there is no call to make. create fills the column when it arrives empty.

KeyMeaning
when"create" fills the column as the row is written. "assign" leaves it empty until the model asks, by calling await self.assign_number("<column>").
sequenceThe identifier of a Sequence — one series shared by every record.
sequence_pathA path to a Sequence held on the record ("journal__numbering_sequence"), for numbering that varies per record.
dateThe field whose date selects the period for a resetting sequence. None uses today.

when and date are both required, date included when the answer is None; and exactly one of sequence / sequence_path must be named.

Nothing here defaults, because a default is only safe when getting it wrong fails visibly and neither of these does. A document that should be numbered at posting but is filled at create quietly spends a number on every abandoned draft — the numbering develops gaps, nothing errors, and gapless numbering is a legal requirement in many jurisdictions. A date naming a field that does not exist reads as None, which is the legal way of saying this series never resets — so the numbering silently stops restarting each year.

Every one of these is checked when the model loads, not when a document is finally numbered: a spec that names a column, a date field or a sequence_path the model does not have refuses to compose.

There is no scope key — see below.

A document that earns its number later declares when: "assign" and asks for it at the moment it earns it:

class FinancialDocument(Model):
_unique_together = [("company", "number")]
_numbering = {
"number": {
"when": "assign",
"sequence_path": "journal__numbering_sequence",
"date": "date",
},
}
async def post(self):
await self.assign_number("number")
...

assign_number does nothing when the column already holds a value, so re-posting a cancelled document keeps the number it was issued. Until then the column is empty — not a "Draft" placeholder. A placeholder is a value, so every draft in a company shares it and no unique constraint can be applied; an empty column says what is true, and repeated NULLs never collide.

The boundary the numbering is isolated by is derived, not declared: it is the other members of the _unique_together tuple the numbered column appears in.

_unique_together = [("company", "number")] # ← numbers per company
_numbering = {"number": {...}} # ← says nothing about scope

A column under no unique constraint numbers once for the whole install.

It is derived because it is not a second fact. A scope FINER than the constraint is a collision waiting to happen, and this is the trap worth understanding: an invoice numbers from its journal’s series, so isolating it per journal looks right. It is not. Two journals may be pointed at one Sequence — a second bank account reusing the payment series is an ordinary thing to want — and each would then read only its own documents, take the same value, and the second to post would fail on the constraint, which never heard about journals. A scope coarser than the constraint is safe but skips numbers. So the only correct value is the constraint’s own boundary, which means declaring it separately is writing one value twice and hoping — and nothing catches the day the two disagree, because each reads plausibly on its own.

The scope is not what separates one series from another — the prefix is. Numbering reads the highest value already carrying this period’s prefix, so two journals with different sequences stay independent inside one company scope without being told to. The numbering lock is keyed the same way, on the scope and the prefix — and never on the model, so an invoice and a bill never wait for each other while two journals genuinely sharing a series do.

A numbered column covered by two unique constraints has no single boundary, and the model refuses to compose rather than picking one.

When the choice of series is a decision about the document rather than a path, override _numbering_sequence. A credit note numbers from its journal’s reversal series:

async def _numbering_sequence(cls, field_name, spec, record=None):
if field_name == "number":
doc_type = await cls.numbering_field(record, "type")
journal = await cls.numbering_field(record, "journal")
if journal is not None and doc_type in ("Credit Note", "Debit Note"):
await journal.fetch_related("reversal_numbering_sequence", "numbering_sequence")
return journal.reversal_numbering_sequence or journal.numbering_sequence
return await super()._numbering_sequence(field_name, spec, record)

Read the record’s fields through await cls.numbering_field(record, "<field>"). The override is called from two places with two different shapes — at create the row does not exist yet, so it is handed the vals; at assign_number it is handed the saved record — and that accessor resolves either, fetching a relation off a saved record for you. Reaching for the attribute directly works in one case and not the other.

A series belongs to one column on one model. Never copy a number a record earned into another table: the copy is stored under that table’s own unique constraint, so one value ends up policed in two places and “which numbers has this series spent?” no longer has a single table to ask.

Invoicing is the worked example. Three kinds of record, three kinds of series:

RecordNumbers from
A document (invoice, credit note, bill, debit note)its document series — INV-, CN-, BILL-, DN-
A paymentthe payment series — PAY-
A journal entryits own journal’s series — SJE-, PJE-, BJE-, CJE-, MJE-

An invoice’s ledger entry therefore carries its own number and names the invoice in its reference; it is not a second copy of INV-00042. That is what lets a register’s shift close, a manual adjustment and an invoice all be posted into one journal without any of them spending another’s numbers.

Why derivation still reads more than one table

Section titled “Why derivation still reads more than one table”

_highest_numbered looks at every model naming the same series and isolated by the same scope, not just the caller’s own. That is not a licence to share a series — it covers values that reached a second table anyway: rows written before the rule above, a data import, an API client carrying its own references. Such a row is invisible to a single-table derivation, so the next record takes a number that already exists and the write fails — every time, since a derivation recomputes the same value on each retry.

_numbering merges across __inherit__, keyed by column, so a module adds a number without disturbing the ones already there — the same contribute-don’t-assign rule the view layer uses.

class FinancialDocumentXX(Model):
__inherit__ = "FinancialDocument"
# Adds a second numbered column; the document's own `number` is untouched.
_numbering = {
"fiscal_number": {"when": "assign",
"sequence": "xx_fiscal_sequence", "date": "date"},
}

Remove one by naming it — the column stops being filled, and whatever declared it becomes your responsibility:

_numbering = {"number": {"disabled": True}}

A stored counter is a second opinion about which numbers have been used, and it can be wrong. Anything that writes a number without going through it — a data import, a restore, an API client carrying its own references — leaves it behind, and every document numbered afterwards collides with one that already exists. The save fails, and keeps failing, until an administrator finds and edits the counter.

Deriving removes the failure rather than recovering from it. A document arriving as SO-2026-00500 simply means the next one is 00501.

Sequence still carries a next_number, and await Sequence.get_next("<identifier>") still hands out values from it. That path is for generated names — a lot code, a serial number — not document numbers. The difference is what the value means: a lot code is a label minted on demand, so a gap in the run says nothing and there is no scope to derive within. A document number is a claim about a sequence of documents, which is why it is read back off them.

Never point both at one column. A column filled by _numbering and also written from get_next has two sources disagreeing about what has been issued, and the counter re-issues a number the documents already hold the moment the derived side moves ahead of it.

The prefix and suffix are Python percent-format patterns. The following tokens are substituted from the date you pass (or now). A pattern with no tokens is used verbatim.

TokenMeaningExample
%(year)s4-digit year2026
%(y)s2-digit year26
%(month)sZero-padded month06
%(day)sZero-padded day of month25
%(doy)sDay of year176
%(woy)sWeek of year25
%(weekday)sWeekday number (0 = Sunday)4
%(h24)sHour (24h)14
%(h12)sHour (12h)02
%(min)sMinute09
%(sec)sSecond30

The final value is prefix + zero-padded-number + suffix.

size controls the zero-padding of the numeric portion (default 5). With size: 5, the forty-second document renders as 00042. So a sequence with prefix: "INV-" and size: 5 produces INV-00042; one with prefix: "PKG-%(year)s-" produces PKG-2026-00042.

reset_frequency controls when numbering starts again from 1. Choices: Never, Daily, Weekly, Monthly, Yearly (default Never).

The reset is expressed through the prefix, not a counter: numbering looks for the highest existing value carrying this period’s prefix, so a new period simply finds nothing and starts at 1. Combine a reset with a matching date token so the visible number and the period line up:

prefix: "INV-%(year)s-"
reset_frequency: Yearly

giving INV-2026-00001, INV-2026-00002, …, then INV-2027-00001 in the new year.

Which period applies comes from the date key of the model’s _numbering entry — the field whose date the document is numbered by. With date: None, the period is today’s.

Ship data_type: Sequence records in your module’s data/ directory.

modules/inventory/data/sequences.yaml
- data_type: Sequence
identifier: package_sequence
name: Package Sequence
prefix: PKG-%(year)s-
size: 5
- data_type: Sequence
identifier: serial_number_sequence
name: Serial Number Sequence
prefix: "SN-"
size: 5
FieldTypeMeaning
identifierCharUnique handle named by a model’s _numbering (snake_case).
nameCharHuman-readable label (required).
prefixCharPattern prepended to the number.
suffixCharPattern appended to the number.
sizeIntegerZero-padding width of the number (default 5).
reset_frequencySelectionNever / Daily / Weekly / Monthly / Yearly (default Never).
modelManyToOne (ModelRegistry)Optional. The model this sequence numbers (informational/grouping).
next_numberIntegerThe counter used by get_next for generated names only — ignored by _numbering.

For a numbered column, a Sequence defines the shape of the number. It does not track which numbers have been issued — the records do.

Because sequence_path follows a relation, the series can be chosen from the record rather than fixed. That is how an invoice numbers from its journal’s series while staying scoped to its company:

# One numbering series per journal, created per company at install.
- data_type: Sequence
identifier: customer_invoice_seq
name: Customer Invoices
prefix: INV-%(year)s-
size: 5
reset_frequency: Yearly

The journal points at it, and format: "journal__numbering_sequence" follows that link. To give one company — or one journal — its own series, point it at a different Sequence; no code change. Two journals may share one, and then they share one run of numbers, which is what pointing them at the same series means.