How to Generate Gap-Free Document Numbers in Oracle APEX

Build a counter table and a numbering function, assign the number when an APEX form saves, and test rollbacks, concurrent users and a new year.

Invoices, receipts and credit notes often need numbers without gaps, such as INV-2026-0001, INV-2026-0002 and so on. Auditors and tax rules may ask why a number is missing. A sequence or identity column cannot promise this, because a rolled-back insert or lost cache values leave holes in the numbers.

The usual fix is a counter table. Each save locks one counter row, adds 1, and uses the new value in the same transaction. If the save fails, the counter rolls back too.

This guide builds the counter, a numbering function and an Oracle APEX form that assigns the number at save, and tests rollbacks and two users at once.

The guide uses invoices as the example. The same steps work for orders, receipts or any other document table.

Step 1: Create the Tables

The example needs three tables: BILLING_CUSTOMERS for the customer list, DOC_COUNTERS for the last number used per document type and year, and INVOICES for the documents. Create them with a few customers:

Example:

create table billing_customers (
  customer_id   number generated by default on null as identity,
  customer_name varchar2(100) not null,
  email         varchar2(200),
  constraint billing_customers_pk primary key (customer_id),
  constraint billing_customers_name_uk unique (customer_name)
);

create table doc_counters (
  doc_type  varchar2(10) not null,
  doc_year  number(4) not null,
  last_no   number(9) default on null 0 not null,
  constraint doc_counters_pk primary key (doc_type, doc_year),
  constraint doc_counters_type_ck check (doc_type = upper(doc_type)),
  constraint doc_counters_last_no_ck check (last_no >= 0)
);

create table invoices (
  invoice_id    number generated by default on null as identity,
  invoice_no    varchar2(20) not null,
  invoice_date  date default on null trunc(sysdate) not null,
  customer_id   number not null,
  amount        number(12,2) not null,
  status        varchar2(10) default on null 'OPEN' not null,
  constraint invoices_pk primary key (invoice_id),
  constraint invoices_no_uk unique (invoice_no),
  constraint invoices_customer_fk foreign key (customer_id)
    references billing_customers (customer_id),
  constraint invoices_amount_ck check (amount > 0),
  constraint invoices_status_ck check (status in ('OPEN', 'PAID', 'CANCELLED'))
);

create index invoices_customer_ix on invoices (customer_id);

insert into billing_customers (customer_name, email) values ('Northwind Traders', 'ap@northwind.example');
insert into billing_customers (customer_name, email) values ('Blue Harbor Foods', 'billing@blueharbor.example');
insert into billing_customers (customer_name, email) values ('Summit Office Supply', 'accounts@summit.example');

commit;

select table_name, constraint_name, constraint_type
  from user_constraints
 where table_name in ('BILLING_CUSTOMERS', 'DOC_COUNTERS', 'INVOICES')
   and constraint_name not like 'SYS%'
 order by table_name, constraint_name;

Output:

Table BILLING_CUSTOMERS created.

Table DOC_COUNTERS created.

Table INVOICES created.

Index INVOICES_CUSTOMER_IX created.

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

TABLE_NAME           CONSTRAINT_NAME              CONSTRAINT_TYPE
____________________ ____________________________ __________________
BILLING_CUSTOMERS    BILLING_CUSTOMERS_NAME_UK    U
BILLING_CUSTOMERS    BILLING_CUSTOMERS_PK         P
DOC_COUNTERS         DOC_COUNTERS_LAST_NO_CK      C
DOC_COUNTERS         DOC_COUNTERS_PK              P
DOC_COUNTERS         DOC_COUNTERS_TYPE_CK         C
INVOICES             INVOICES_AMOUNT_CK           C
INVOICES             INVOICES_CUSTOMER_FK         R
INVOICES             INVOICES_NO_UK               U
INVOICES             INVOICES_PK                  P
INVOICES             INVOICES_STATUS_CK           C

10 rows selected.
  • DOC_COUNTERS_PK on DOC_TYPE and DOC_YEAR gives one counter row per document type and year, so numbering starts again at 1 each year.
  • INVOICES_NO_UK makes sure no invoice number can ever be used twice, even if some code path forgets to use the counter.
  • INVOICE_ID stays the primary key. It may have gaps, and that is fine, because users never see it.

Step 2: Create the Numbering Function

The function adds 1 to the counter row and returns the formatted number. The UPDATE ... RETURNING statement locks the counter row until the transaction ends, just like SELECT ... FOR UPDATE would, and reads the new value in one step:

Example:

create or replace function next_doc_no (
  p_doc_type in varchar2,
  p_doc_date in date default sysdate
) return varchar2
is
  l_year doc_counters.doc_year%type := extract(year from p_doc_date);
  l_no   doc_counters.last_no%type;
begin
  update doc_counters
     set last_no = last_no + 1
   where doc_type = p_doc_type
     and doc_year = l_year
  returning last_no into l_no;

  if sql%rowcount = 0 then
    begin
      insert into doc_counters (doc_type, doc_year, last_no)
      values (p_doc_type, l_year, 1);
      l_no := 1;
    exception
      when dup_val_on_index then
        update doc_counters
           set last_no = last_no + 1
         where doc_type = p_doc_type
           and doc_year = l_year
        returning last_no into l_no;
    end;
  end if;

  return p_doc_type || '-' || l_year || '-' || lpad(l_no, 4, '0');
end next_doc_no;
/

Output:

Function NEXT_DOC_NO compiled
  • When no row exists yet for the type and year, the function inserts one with LAST_NO 1.
  • If another session inserts the same first row at the same moment, the insert fails with DUP_VAL_ON_INDEX and the function updates the row instead. Step 9 tests this case.
  • The function does not commit, and it is not an autonomous transaction. The number belongs to the caller's transaction, so a rollback also returns the number.

Now insert three sample invoices with the function:

Example:

insert into invoices (invoice_no, invoice_date, customer_id, amount)
select next_doc_no('INV', date '2026-10-01'), date '2026-10-01', customer_id, 1250
  from billing_customers where customer_name = 'Northwind Traders';

insert into invoices (invoice_no, invoice_date, customer_id, amount)
select next_doc_no('INV', date '2026-10-02'), date '2026-10-02', customer_id, 480.5
  from billing_customers where customer_name = 'Blue Harbor Foods';

insert into invoices (invoice_no, invoice_date, customer_id, amount)
select next_doc_no('INV', date '2026-10-03'), date '2026-10-03', customer_id, 2999
  from billing_customers where customer_name = 'Summit Office Supply';

commit;

select i.invoice_id, i.invoice_no, to_char(i.invoice_date, 'YYYY-MM-DD') as invoice_date, c.customer_name, i.amount, i.status
  from invoices i
  join billing_customers c on c.customer_id = i.customer_id
 order by i.invoice_no;

select doc_type, doc_year, last_no from doc_counters;

Output:

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

   INVOICE_ID INVOICE_NO       INVOICE_DATE    CUSTOMER_NAME              AMOUNT STATUS
_____________ ________________ _______________ _______________________ _________ _________
            1 INV-2026-0001    2026-10-01      Northwind Traders            1250 OPEN
            2 INV-2026-0002    2026-10-02      Blue Harbor Foods           480.5 OPEN
            3 INV-2026-0003    2026-10-03      Summit Office Supply         2999 OPEN

DOC_TYPE       DOC_YEAR    LAST_NO
___________ ___________ __________
INV                2026          3

Step 3: See Why a Sequence Leaves Gaps

This test inserts an invoice, rolls it back, and inserts it again. Compare the identity column INVOICE_ID with the INVOICE_NO from the counter:

Example:

insert into invoices (invoice_no, customer_id, amount)
values (next_doc_no('INV'), 1, 300);

select invoice_id, invoice_no, amount
  from invoices
 order by invoice_id;

rollback;

insert into invoices (invoice_no, customer_id, amount)
values (next_doc_no('INV'), 1, 300);

commit;

select invoice_id, invoice_no, amount
  from invoices
 order by invoice_id;

Output:

1 row inserted.

   INVOICE_ID INVOICE_NO          AMOUNT
_____________ ________________ _________
            1 INV-2026-0001         1250
            2 INV-2026-0002        480.5
            3 INV-2026-0003         2999
            4 INV-2026-0004          300

Rollback complete.

1 row inserted.

Commit complete.

   INVOICE_ID INVOICE_NO          AMOUNT
_____________ ________________ _________
            1 INV-2026-0001         1250
            2 INV-2026-0002        480.5
            3 INV-2026-0003         2999
            5 INV-2026-0004          300

The rolled-back insert used INVOICE_ID 4, and the identity column did not give it back. The second insert got INVOICE_ID 5. The counter update was rolled back with the insert, so the second insert got INV-2026-0004 again, and the invoice numbers have no gap.

Step 4: Create the Report and Form Pages

In App Builder, open the application and click Create Page. On the Component tab, click Interactive Report:

Create a Page dialog with the Interactive Report page type marked with a red box
The Interactive Report page type.

Enter Page Number 340 and Name Invoices. Turn on Include Form Page, enter Form Page Number 341 and Form Page Name Invoice, and set Form Page Mode to Modal Dialog:

Create Interactive Report wizard with Page Number 340, Name Invoices and the form page settings marked with red boxes
A report page and a modal form page.

Scroll down to Data Source. Keep Source Type Table and enter INVOICES as the Table / View Name. Keep the Navigation settings, so the report page gets a menu entry, and click Next:

Data Source section of the wizard with Table / View Name INVOICES marked with a red box
The INVOICES table as the source.

On the last screen, keep Primary Key Column 1 as INVOICE_ID and click Create Page:

Primary Key screen of the wizard with Primary Key Column 1 INVOICE_ID marked with a red box
INVOICE_ID as the primary key.

The wizard created page 340 with the report and page 341 with the form. It also created a list of values for the CUSTOMER_ID foreign key, so the form shows customer names in a select list.

Step 5: Make the Invoice Number Read-Only

Users should not type the invoice number. Open page 341 in Page Designer, select P341_INVOICE_NO, and set Identification, Type to Display Only:

Page Designer with P341_INVOICE_NO selected and Type Display Only marked with a red box
The invoice number is shown, not entered.

Also set Appearance, Template to Optional - Floating. A Display Only item has no Value Required setting, so the form no longer asks the user for a number. Keep Source, Query Only turned off: the form must still write this item to the INVOICE_NO column. The process in Step 7 fills it at save.

While you are on the form, change the label of P341_CUSTOMER_ID from Customer Id to Customer.

Step 6: Cancel Invoices Instead of Deleting Them

A deleted invoice would leave a gap, so the form should not offer Delete. Under Dialog Footer, right-click the DELETE button and choose Delete. Then select the Close Dialog process and change its Server-side Condition Value from CREATE,SAVE,DELETE to CREATE,SAVE.

Users mark an invoice as cancelled with the status instead. Select P341_STATUS, set Type to Select List, set List of Values, Type to Static Values, and enter the values Open (OPEN), Paid (PAID) and Cancelled (CANCELLED). Turn off Display Null Value:

Page Designer with P341_STATUS as a Select List and the Static Values list of values marked with red boxes
Status as a select list.

Scroll down to Default and set Type to Static and Static Value to OPEN:

Default group of P341_STATUS with Type Static and Static Value OPEN marked with a red box
New invoices start as Open.

Step 7: Assign the Number at Save

Open the Processing tab, right-click Processes and choose Create Process. Set Name to Assign Invoice Number and Type to Execute Code, and enter this PL/SQL Code:

Example:

:P341_INVOICE_NO := next_doc_no(
    p_doc_type => 'INV',
    p_doc_date => apex_session_state.get_timestamp('P341_INVOICE_DATE'));
Page Designer with the Assign Invoice Number process, Name, Type Execute Code and the PL/SQL Code marked with red boxes
The process that takes the next number.

APEX_SESSION_STATE.GET_TIMESTAMP reads the date item and converts it with the item's format mask. So the year in the number comes from the invoice date, not from today's date.

Set Execution, Sequence to 5. Process form Invoice has sequence 10, so the new process runs first. Then go to Server-side Condition and set When Button Pressed to CREATE:

Server-side Condition of the process with When Button Pressed CREATE marked with a red box
The number is assigned only when a new invoice is created.

The process sets P341_INVOICE_NO in session state, and the form's Automatic Row Processing then inserts the row with that value. Both run in the same database transaction. Save the page.

Step 8: Test the Form

Run page 340. Click the Invoice No column heading and choose Sort Descending, so the newest number is at the top:

Invoices interactive report sorted by Invoice No descending with six invoices
The report with six invoices.

Click Create. The Invoice No field is empty, and Status is already Open:

Invoice modal dialog with the empty Invoice No display field marked with a red box
No number before the save.

First, test a failed save. Enter Invoice Date 10/15/2026, choose Northwind Traders, enter Amount 0 and click Create. The process takes the next number, but the insert fails on the check constraint:

Invoice dialog showing the error ORA-02290 check constraint INVOICES_AMOUNT_CK violated
The insert fails after the number was taken.

APEX rolled back the request, so the counter is still at 6:

Example:

select doc_type, doc_year, last_no
  from doc_counters;

Output:

DOC_TYPE       DOC_YEAR    LAST_NO
___________ ___________ __________
INV                2026          6

Now change Amount to 350 and click Create again. The dialog closes with Row created, and the new invoice gets INV-2026-0007, the next number with no gap:

Invoices report with the new row INV-2026-0007 for Northwind Traders marked with a red box
The new invoice got the next number.

Click the edit icon of INV-2026-0007. The number is shown read-only. Change Status to Paid and click Apply Changes:

Invoice dialog for INV-2026-0007 with the Invoice No and the Status Paid marked with red boxes
Editing keeps the number.

The report showed INV-2026-0007 with status PAID afterwards. The process did not run, because the SAVE button was pressed, not CREATE, so the number did not change.

Step 9: Test Two Users at the Same Time

The counter row lock makes concurrent saves wait for each other. To see it, run two database sessions. Session A takes a number and waits 15 seconds before it commits:

Example:

set serveroutput on
declare
  l_no invoices.invoice_no%type;
begin
  l_no := next_doc_no('INV');
  insert into invoices (invoice_no, customer_id, amount)
  values (l_no, 2, 640);
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' Session A got ' || l_no);
  dbms_session.sleep(15);
  commit;
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' Session A committed');
end;
/

Session B starts a few seconds later and asks for a number while A has not committed yet:

Example:

set serveroutput on
declare
  l_no invoices.invoice_no%type;
begin
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' Session B asks for a number');
  l_no := next_doc_no('INV');
  insert into invoices (invoice_no, customer_id, amount)
  values (l_no, 3, 815);
  commit;
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' Session B got ' || l_no);
end;
/

Output of session A:

02:56:53 Session A got INV-2026-0005
02:57:08 Session A committed

PL/SQL procedure successfully completed.

Output of session B:

02:56:59 Session B asks for a number
02:57:08 Session B got INV-2026-0006

PL/SQL procedure successfully completed.

Session B asked at 02:56:59 and waited until A committed at 02:57:08. Then it got INV-2026-0006. No duplicate and no gap.

The same test with the date 2027-01-04 checks the first number of a new year, where both sessions try to insert the counter row:

Example:

set serveroutput on
declare
  l_no invoices.invoice_no%type;
begin
  l_no := next_doc_no('INV', date '2027-01-04');
  insert into invoices (invoice_no, invoice_date, customer_id, amount)
  values (l_no, date '2027-01-04', 2, 640);
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' Session A got ' || l_no);
  dbms_session.sleep(15);
  commit;
  dbms_output.put_line(to_char(systimestamp, 'HH24:MI:SS') || ' Session A committed');
end;
/

Output of session A:

02:57:23 Session A got INV-2027-0001
02:57:38 Session A committed

PL/SQL procedure successfully completed.

Output of session B:

02:57:29 Session B asks for a number
02:57:38 Session B got INV-2027-0002

PL/SQL procedure successfully completed.

Session B waited on the unique index until A committed, got DUP_VAL_ON_INDEX inside the function, and then updated the new row to 2. In this example, the two 2027 test invoices and their counter row were deleted again afterwards.

Step 10: Check the Numbers in SQL

This query lists the invoices and compares the count with the highest number per year:

Example:

select invoice_id, invoice_no, amount, status
  from invoices
 order by invoice_no;

select extract(year from invoice_date) as doc_year,
       count(*) as invoices,
       max(to_number(substr(invoice_no, -4))) as highest_no
  from invoices
 group by extract(year from invoice_date);

select doc_type, doc_year, last_no
  from doc_counters;

Output:

   INVOICE_ID INVOICE_NO          AMOUNT STATUS
_____________ ________________ _________ _________
            1 INV-2026-0001         1250 OPEN
            2 INV-2026-0002        480.5 OPEN
            3 INV-2026-0003         2999 OPEN
            5 INV-2026-0004          300 OPEN
            6 INV-2026-0005          640 OPEN
            7 INV-2026-0006          815 OPEN
           22 INV-2026-0007          350 PAID

7 rows selected.

   DOC_YEAR    INVOICES    HIGHEST_NO
___________ ___________ _____________
       2026           7             7

DOC_TYPE       DOC_YEAR    LAST_NO
___________ ___________ __________
INV                2026          7

Seven invoices and a highest number of 7 means there is no gap in 2026. INVOICE_ID has gaps: 4 is missing, and it jumped from 7 to 22. Rolled-back and deleted test rows used some of these values, and Oracle does not reuse identity values. These are the gaps a sequence would have put into the invoice numbers.

Things to Know

  • The counter row stays locked until the transaction commits or rolls back. Keep the work after the number short, because every other save of the same document type and year waits.
  • Assign the number in a process that runs at save and in the same transaction as the insert, as in Step 7. Then a failed insert also gives the number back.
  • The process runs only for the CREATE button, so editing an invoice never changes its number.
  • The failed save showed the raw ORA-02290 message. Add a validation such as Amount greater than 0 for a friendlier message; validations run before processes, so no number is taken then.
  • Deleting a numbered row creates a gap. This guide removes the Delete button and uses the status Cancelled instead.
  • Rows inserted outside this form, for example in SQL scripts, must call NEXT_DOC_NO as well. The INVOICES_NO_UK constraint stops duplicates in any case.

Related Guides

Conclusion

To generate gap-free document numbers in Oracle APEX, keep the last number per document type and year in a counter table. Increase it with UPDATE ... RETURNING in a function, and call that function from a page process that runs just before the form's row processing, only for CREATE.

The tests showed that a failed save gives the number back, that a second session waits for the first one instead of getting a duplicate, and that a new year starts at 1. Use the identity column as the key, and show users only the counter-based number.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted