How to Add Audit Columns (Created By, Updated By) Automatically in Oracle APEX

Add audit columns to a table, fill them with a trigger that records the APEX user, show them on a form, and test changes from APEX and SQL.

Audit columns answer the first questions about any record: who created it, when, and who changed it last. Filling them in each form's processes is easy to forget and does not cover changes made outside APEX, so the database should fill them, whatever program writes the row.

For inserts, the columns need no code: a DEFAULT ON NULL clause fills them declaratively. Only updates need a small trigger.

The one APEX-specific detail is the user name: APEX applications connect to the database as one schema, so the database must read the APEX user from the session context. This guide adds four audit columns to a table, shows them on a form, and tests changes from APEX and from SQL.

Step 1: Add the Columns with Defaults and an Update Trigger

The table is PRODUCTS. Add CREATED_BY, CREATED_ON, UPDATED_BY, and UPDATED_ON with DEFAULT ON NULL, and a row-level trigger for updates:

Example:

alter table products add (
    created_by varchar2(255) default on null
               coalesce(sys_context('APEX$SESSION', 'APP_USER'),
                        sys_context('USERENV', 'SESSION_USER')),
    created_on date default on null sysdate,
    updated_by varchar2(255) default on null
               coalesce(sys_context('APEX$SESSION', 'APP_USER'),
                        sys_context('USERENV', 'SESSION_USER')),
    updated_on date default on null sysdate
);

create or replace trigger products_audit_trg
before update on products
for each row
begin
    :new.created_by := :old.created_by;
    :new.created_on := :old.created_on;
    :new.updated_by := coalesce(sys_context('APEX$SESSION', 'APP_USER'),
                                sys_context('USERENV', 'SESSION_USER'));
    :new.updated_on := sysdate;
end;
/

Output:

Table PRODUCTS altered.

Trigger PRODUCTS_AUDIT_TRG compiled
  • DEFAULT ON NULL fills a column on insert when the INSERT leaves it out and also when the INSERT passes NULL, as generic code can. A plain DEFAULT leaves an explicit NULL as NULL. So inserts need no trigger code at all.
  • sys_context('APEX$SESSION', 'APP_USER') returns the user signed in to the APEX application, such as SALES_USER. Outside an APEX request it returns NULL.
  • sys_context('USERENV', 'SESSION_USER') is the database user. COALESCE uses it when there is no APEX user, so changes from SQL scripts, jobs, or other programs are recorded too.
  • The trigger runs only on update. It sets the updated values and copies the old created values back, so CREATED_BY and CREATED_ON cannot be changed, even by an UPDATE statement that sets them.
  • DEFAULT ON NULL also makes the columns NOT NULL. Rows that already exist get the current user and time when the columns are added.

Without the session context, the defaults and the trigger would see only a database user, the same one for every user of the application, so the audit columns could not tell users apart.

Why Updates Still Need the Trigger

Oracle Database 26ai has DEFAULT ON NULL FOR INSERT AND UPDATE, which sounds like it could replace the trigger for UPDATED_BY and UPDATED_ON too. It does not: on update, it applies only when the UPDATE statement sets the column to NULL. This test uses a scratch table, AUDIT_DEMO, and an APEX session for DEMO_DEV:

Example:

create table audit_demo (
    id         number generated by default as identity primary key,
    name       varchar2(30),
    updated_by varchar2(255) default on null for insert and update
               coalesce(sys_context('APEX$SESSION', 'APP_USER'),
                        sys_context('USERENV', 'SESSION_USER')),
    updated_on date default on null for insert and update sysdate
);

insert into audit_demo (name) values ('First');
commit;

select name, updated_by, to_char(updated_on, 'HH24:MI:SS') as updated
from   audit_demo;

exec dbms_session.sleep(3)
exec apex_session.create_session(p_app_id => 400, p_page_id => 1, p_username => 'DEMO_DEV')

update audit_demo set name = 'Changed';

select name, updated_by, to_char(updated_on, 'HH24:MI:SS') as updated
from   audit_demo;

update audit_demo set updated_by = null, updated_on = null;

select name, updated_by, to_char(updated_on, 'HH24:MI:SS') as updated
from   audit_demo;

exec apex_session.delete_session
drop table audit_demo purge;

Output:

Table AUDIT_DEMO created.

1 row inserted.

Commit complete.

NAME     UPDATED_BY    UPDATED
________ _____________ ___________
First    APXSOL        00:33:04

PL/SQL procedure successfully completed.

PL/SQL procedure successfully completed.

1 row updated.

NAME       UPDATED_BY    UPDATED
__________ _____________ ___________
Changed    APXSOL        00:33:04

1 row updated.

NAME       UPDATED_BY    UPDATED
__________ _____________ ___________
Changed    DEMO_DEV      00:33:07

PL/SQL procedure successfully completed.

Table AUDIT_DEMO dropped.

The ordinary update three seconds later, by DEMO_DEV, left UPDATED_BY as APXSOL and the time unchanged. Only the update that set both columns to NULL filled them with DEMO_DEV and the new time. Every update statement would have to do that, so a BEFORE UPDATE trigger is the dependable way to stamp updates.

Step 2: Show the Values on the Form

The form page on PRODUCTS gets four Display Only items for the audit columns, with the form region as their source. Set Query Only on for them, so the form reads the columns but never writes them; the defaults and the trigger are the only writers:

Page Designer showing P111_CREATED_BY as a Display Only item with source column CREATED_BY and Query Only on
An audit item: source column CREATED_BY, Query Only.

Give the four items a server-side condition, Item is NOT NULL on the primary key item P111_PRODUCT_ID, so they are hidden on the empty form for a new product.

Testing It

ORDER_EDITOR created a product, Webcam HD, in the form; the defaults filled the created and updated columns. Then DEMO_DEV opened it and changed the price from 70 to 65, and the trigger set the updated columns. The form shows both users:

Oracle APEX Product form for Webcam HD showing Created By ORDER_EDITOR, Updated By DEMO_DEV and the two timestamps, marked with a red box
Created by one user, updated by another.

An update from SQLcl, connected as the schema, that also tries to overwrite CREATED_BY:

Example:

update products
set    price      = 60,
       created_by = 'SOMEONE_ELSE'
where  name = 'Webcam HD';

select name, price, created_by, updated_by
from   products
where  name = 'Webcam HD';

commit;

Output:

1 row updated.

NAME            PRICE CREATED_BY      UPDATED_BY
____________ ________ _______________ _____________
Webcam HD          60 ORDER_EDITOR    APXSOL

Commit complete.

UPDATED_BY is the database user APXSOL, because there was no APEX session, and CREATED_BY is still ORDER_EDITOR: the trigger kept the original value.

An insert from SQL that passes NULL for the audit columns, which a plain DEFAULT would leave empty:

Example:

insert into products (name, category, price, created_by, created_on)
values ('Desk Lamp', 'Accessories', 25, null, null);

select name, created_by, updated_by
from   products
where  name = 'Desk Lamp';

rollback;

Output:

1 row inserted.

NAME         CREATED_BY    UPDATED_BY
____________ _____________ _____________
Desk Lamp    APXSOL        APXSOL

Rollback complete.

DEFAULT ON NULL filled both columns with the database user.

Things to Know

  • Rows that existed before the columns were added get the user and time of the ALTER TABLE. Update them once with a value such as MIGRATION if reports should show that the real creator is unknown.
  • A default does not stop an INSERT that supplies its own CREATED_BY. If that matters, also set the created values in a BEFORE INSERT trigger, or allow inserts only through the application.
  • Use the same defaults and update trigger on every table that needs auditing, or generate them with a script from USER_TABLES.
  • SYSDATE is the database server time. For applications used in several time zones, use TIMESTAMP WITH TIME ZONE columns and SYSTIMESTAMP, and show the values in the user's time zone.
  • Audit columns record only the latest change. For a full history of every change, add a history table filled by a trigger, or use Flashback Data Archive.

Related Guides

Conclusion

To add audit columns in Oracle APEX, add CREATED_BY, CREATED_ON, UPDATED_BY, and UPDATED_ON with DEFAULT ON NULL, so inserts fill them declaratively, and set the updated values in a BEFORE UPDATE trigger that also protects the created values.

Read the APEX user with sys_context('APEX$SESSION', 'APP_USER') and fall back to the database user, and show the columns on forms as Query Only items.

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

2 Comments
Oldest
Newest Most Voted