Build a Reusable Field-Level Audit Log with JSONB in YugabyteDB

This tip is Part 2 of a three-part series exploring different ways to track data changes in YugabyteDB.

In Part 1, we stored the current and immediately preceding values in the same row. That approach is simple and effective, but it preserves only one level of history.

In this tip, we will build a complete field-level audit history that records:

  • ● The application user who made the change
  • ● The table and row that changed
  • ● The field that changed
  • ● The old value
  • ● The new value
  • ● The date and time of the change

The solution combines a centralized audit table, a reusable PL/pgSQL trigger function, JSONB representations of the OLD and NEW rows, and transaction-local application context.

The same trigger function can be attached to multiple tables without writing a separate column-comparison function for each one.

What the Audit Log Captures

Audit Attribute Purpose
actor_user Application user responsible for the change
table_schema and table_name Source table containing the changed row
row_identifier JSONB object containing the row’s identifying column values
field_changed Individual column whose value changed
old_value and new_value Before-and-after values stored as JSONB
changed_at Timestamp assigned when the change is recorded

How the Pattern Works

When a row is updated, the trigger performs the following actions:

  • 1. Converts the complete OLD and NEW rows to JSONB.
  • 2. Expands the new JSONB object into individual column/value pairs.
  • 3. Retrieves the corresponding old value for each column.
  • 4. Compares the old and new values.
  • 5. Inserts one audit record for every field that changed.
  • 6. Retrieves the application user from transaction-local context.

YSQL’s to_jsonb() function can convert a compound SQL row into JSONB, and jsonb_each() expands a JSONB object into a set of key/value pairs.

Step 1: Create a Generic Source Table

Create two schemas:

				
					CREATE SCHEMA demo_data;
CREATE SCHEMA audit_data;
				
			

Create a generic source table:

				
					CREATE TABLE demo_data.sample_record (
    record_id      BIGINT PRIMARY KEY,
    record_label   TEXT NOT NULL,
    record_status  TEXT NOT NULL,
    priority_level INTEGER NOT NULL
);
				
			

Insert a sample row:

				
					INSERT INTO demo_data.sample_record (
    record_id,
    record_label,
    record_status,
    priority_level
)
VALUES (
    1001,
    'Example Record',
    'pending',
    1
);
				
			

Verify the row:

				
					SELECT *
FROM demo_data.sample_record
WHERE record_id = 1001;
				
			

Output:

				
					.record_id |  record_label  | record_status | priority_level
-----------+----------------+---------------+----------------
      1001 | Example Record | pending       |              1
(1 row)
				
			

Step 2: Create the Central Audit Table

Create one table to store field-level changes from every audited source table:

				
					CREATE TABLE audit_data.field_change_log (
    audit_id       BIGINT GENERATED BY DEFAULT AS IDENTITY,
    actor_user     TEXT NOT NULL,
    table_schema   TEXT NOT NULL,
    table_name     TEXT NOT NULL,
    row_identifier JSONB NOT NULL,
    field_changed  TEXT NOT NULL,
    old_value      JSONB,
    new_value      JSONB,
    changed_at     TIMESTAMPTZ NOT NULL
                   DEFAULT clock_timestamp(),
    PRIMARY KEY (audit_id HASH)
);
				
			

The row_identifier column is JSONB so that one audit table can support different primary-key structures.

A table with a single-column identifier might produce:

				
					{
  "record_id": 1001
}
				
			

A source table with a composite identifier could produce:

				
					{
  "group_id": 10,
  "record_id": 1001
}
				
			

The old and new values are also stored as JSONB. This retains the JSON representation of strings, numbers, Boolean values, arrays, objects, and nulls rather than immediately converting every value to text.

The source table does not need JSONB columns: The trigger temporarily converts the relational OLD and NEW rows to JSONB. The application can continue using normal relational tables and data types.

Step 3: Create the Reusable Trigger Function

Create the generic field-level auditing function:

				
					CREATE OR REPLACE FUNCTION audit_data.capture_field_changes()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $function$
DECLARE
    old_row          JSONB;
    new_row          JSONB;
    row_identifier   JSONB := '{}'::JSONB;
    column_name      TEXT;
    old_column_value JSONB;
    new_column_value JSONB;
    audit_user       TEXT;
    change_timestamp TIMESTAMPTZ;
    argument_index   INTEGER;
BEGIN
    IF TG_OP <> 'UPDATE' THEN
        RAISE EXCEPTION
            'audit_data.capture_field_changes() supports UPDATE only';
    END IF;

    IF TG_NARGS = 0 THEN
        RAISE EXCEPTION
            'Supply at least one row-identifier column';
    END IF;

    old_row := pg_catalog.to_jsonb(OLD);
    new_row := pg_catalog.to_jsonb(NEW);

    audit_user := COALESCE(
        NULLIF(
            pg_catalog.current_setting(
                'app.audit_user',
                true
            ),
            ''
        ),
        session_user::TEXT
    );

    change_timestamp := pg_catalog.clock_timestamp();

    /*
     * Build a JSONB object containing the source row's
     * identifying column values.
     */
    FOR argument_index IN 0..(TG_NARGS - 1) LOOP
        IF NOT (
            new_row ? TG_ARGV[argument_index]
        ) THEN
            RAISE EXCEPTION
                'Identifier column "%" does not exist on %.%',
                TG_ARGV[argument_index],
                TG_TABLE_SCHEMA,
                TG_TABLE_NAME;
        END IF;

        row_identifier :=
            row_identifier ||
            pg_catalog.jsonb_build_object(
                TG_ARGV[argument_index],
                new_row -> TG_ARGV[argument_index]
            );
    END LOOP;

    /*
     * Compare every column in the OLD and NEW rows.
     */
    FOR column_name, new_column_value IN
        SELECT key, value
        FROM pg_catalog.jsonb_each(new_row)
    LOOP
        old_column_value := old_row -> column_name;

        IF old_column_value IS DISTINCT FROM new_column_value THEN
            INSERT INTO audit_data.field_change_log (
                actor_user,
                table_schema,
                table_name,
                row_identifier,
                field_changed,
                old_value,
                new_value,
                changed_at
            )
            VALUES (
                audit_user,
                TG_TABLE_SCHEMA,
                TG_TABLE_NAME,
                row_identifier,
                column_name,
                old_column_value,
                new_column_value,
                change_timestamp
            );
        END IF;
    END LOOP;

    RETURN NEW;
END;
$function$;
				
			

The trigger function itself has no declared arguments. Values supplied by CREATE TRIGGER are made available through TG_ARGV, while TG_TABLE_SCHEMA, TG_TABLE_NAME, OLD, and NEW provide information about the triggering operation.

Why Use IS DISTINCT FROM?

The function compares the JSONB values with:

				
					IF old_column_value IS DISTINCT FROM new_column_value THEN
				
			

A normal comparison can produce an unknown result when one of its operands is null.

IS DISTINCT FROM treats null as a comparable value:

				
					Old value       New value       Changed?
--------------- --------------- --------
NULL            NULL            No
NULL            "active"        Yes
"pending"       NULL            Yes
"pending"       "active"        Yes
"active"        "active"        No
				
			

This ensures that changes to or from null are captured correctly.

Why Use One Timestamp per Source Row?

The function assigns the timestamp once:

				
					change_timestamp := pg_catalog.clock_timestamp();
				
			

If one source-row update changes several fields, every audit row generated for that source row receives the same timestamp.

This makes it easier to recognize that the individual field-change records came from the same source-row update.

Why Use SECURITY DEFINER?

The function is declared as:

				
					SECURITY DEFINER
				
			

This allows the trigger function to insert into the audit table using the privileges of the function owner. An application role can therefore be denied direct permission to insert, update, or delete audit records while still allowing the trigger to create them.

A SECURITY DEFINER function must use a carefully controlled search_path. The example limits the path to trusted system schemas and schema-qualifies the audit table reference.

Production security: The function should be owned by a trusted role. Restrict direct INSERT, UPDATE, and DELETE access to the audit table according to your security requirements.

Step 4: Attach the Function to the Source Table

Create an AFTER UPDATE trigger:

				
					CREATE TRIGGER capture_sample_record_changes
AFTER UPDATE ON demo_data.sample_record
FOR EACH ROW
EXECUTE FUNCTION audit_data.capture_field_changes(
    'record_id'
);
				
			

The trigger argument tells the function that record_id identifies the source row.

No reference to the remaining source columns is required. The function discovers those columns dynamically by converting OLD and NEW to JSONB.

Step 5: Pass the Application User into the Transaction

Applications often access the database through a shared connection pool using a generic database role.

For example:

				
					application_service
				
			

Recording only session_user would identify the shared database role, not the person or application identity that initiated the request.

Set the application identity inside the same transaction as the update:

				
					BEGIN;

SET LOCAL app.audit_user = 'demo_user_101';

UPDATE demo_data.sample_record
SET record_status  = 'active',
    priority_level = 2
WHERE record_id = 1001;

COMMIT;
				
			

YSQL allows user-defined runtime parameters whose names contain a period. SET LOCAL limits the value to the current transaction, which is important when database connections are reused from a pool.

When the transaction commits or rolls back, the local value is discarded.

Connection-pool safety: Do not use a session-wide setting for the application identity. Set it locally inside the same transaction as the data change so that the identity cannot remain on a pooled connection and be observed by a later request.

Step 6: Review the Audit Records

Query the audit table:

				
					SELECT
    audit_id,
    actor_user,
    table_schema || '.' || table_name AS source_table,
    row_identifier,
    field_changed,
    old_value,
    new_value,
    changed_at
FROM audit_data.field_change_log
ORDER BY audit_id;
				
			

Example output:

				
					.audit_id |  actor_user  |       source_table       |   row_identifier    |  field_changed  | old_value | new_value |          changed_at
----------+--------------+--------------------------+---------------------+-----------------+-----------+-----------+-------------------------------
        1 | demo_user_101| demo_data.sample_record  | {"record_id": 1001} | record_status   | "pending" | "active"  | 2026-07-17 14:15:22.412381+00
        2 | demo_user_101| demo_data.sample_record  | {"record_id": 1001} | priority_level  | 1         | 2         | 2026-07-17 14:15:22.412381+00
(2 rows)
				
			

The source update changed two columns, so the trigger created two audit rows.

Notice that both rows contain:

  • ● The same application user
  • ● The same source table
  • ● The same row identifier
  • ● The same change timestamp
  • ● Different field names and values

Preserve Additional History

Run another update using a different application identity:

				
					BEGIN;

SET LOCAL app.audit_user = 'demo_user_202';

UPDATE demo_data.sample_record
SET record_status = 'paused'
WHERE record_id = 1001;

COMMIT;
				
			

Query the complete history for the row:

				
					SELECT
    actor_user,
    field_changed,
    old_value,
    new_value,
    changed_at
FROM audit_data.field_change_log
WHERE table_schema = 'demo_data'
  AND table_name = 'sample_record'
  AND row_identifier = '{"record_id": 1001}'::JSONB
ORDER BY audit_id;
				
			

Example output:

				
					. actor_user  |  field_changed  | old_value | new_value |          changed_at
--------------+-----------------+-----------+-----------+-------------------------------
 demo_user_101| record_status   | "pending" | "active"  | 2026-07-17 14:15:22.412381+00
 demo_user_101| priority_level  | 1         | 2         | 2026-07-17 14:15:22.412381+00
 demo_user_202| record_status   | "active"  | "paused"  | 2026-07-17 14:31:48.017294+00
(3 rows)
				
			

Unlike the previous-value approach from Tip #1, the earlier values are not overwritten.

What Happens if the Application User Is Missing?

The function retrieves the application user with:

				
					COALESCE(
    NULLIF(
        current_setting(
            'app.audit_user',
            true
        ),
        ''
    ),
    session_user::TEXT
)
				
			

If the application provides an identity, the audit row records a value such as:

				
					demo_user_101
				
			

If the setting is missing or empty, the function falls back to the database session role:

				
					application_service
				
			

The fallback prevents an update from failing, but it also reveals that the application did not provide a more specific identity.

For stricter auditing, replace the fallback with an exception:

				
					audit_user := NULLIF(
    pg_catalog.current_setting(
        'app.audit_user',
        true
    ),
    ''
);

IF audit_user IS NULL THEN
    RAISE EXCEPTION
        'Application audit identity was not supplied';
END IF;
				
			

With this version, an update cannot proceed unless the transaction contains an audit identity.

Framework-Agnostic Application Pattern

Every application follows the same basic sequence:

  • 1. Begin a transaction.
  • 2. Set the transaction-local audit user.
  • 3. Perform the business update.
  • 4. Commit or roll back.

The SQL pattern is:

				
					BEGIN;

SET LOCAL app.audit_user = 'application-identity';

-- Perform one or more data modifications.

COMMIT;
				
			

For application frameworks that use bind parameters, call set_config() through the active transaction:

				
					SELECT set_config(
    'app.audit_user',
    :application_user,
    true
);
				
			

The final argument of true limits the setting to the current transaction.

The pattern works with any language or framework that can control a database transaction, including:

  • ● Java and Spring
  • ● Node.js
  • ● Python
  • ● Go
  • ● .NET

Spring Boot Example

For Spring Boot or Spring Data JPA, set the audit identity from inside the active @Transactional method:

				
					@Service
public class SampleRecordService {

    @PersistenceContext
    private EntityManager entityManager;

    private final SampleRecordRepository repository;

    public SampleRecordService(
            SampleRecordRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void updateStatus(
            long recordId,
            String newStatus,
            String applicationUser) {

        entityManager.createNativeQuery("""
            SELECT set_config(
                'app.audit_user',
                :applicationUser,
                true
            )
            """)
            .setParameter(
                "applicationUser",
                applicationUser
            )
            .getSingleResult();

        SampleRecord record = repository
            .findById(recordId)
            .orElseThrow();

        record.setRecordStatus(newStatus);
    }
}
				
			

The important requirement is not Spring itself. The set_config() call and the data modification must use the same connection and transaction.

Reuse the Function on Other Tables

The function can be attached to another table by specifying that table’s identifying columns.

For a single-column key:

				
					CREATE TRIGGER capture_another_record_changes
AFTER UPDATE ON demo_data.another_record
FOR EACH ROW
EXECUTE FUNCTION audit_data.capture_field_changes(
    'record_id'
);
				
			

For a composite key:

				
					CREATE TRIGGER capture_group_record_changes
AFTER UPDATE ON demo_data.group_record
FOR EACH ROW
EXECUTE FUNCTION audit_data.capture_field_changes(
    'group_id',
    'record_id'
);
				
			

The composite row identifier would look similar to:

				
					{
  "group_id": 10,
  "record_id": 1001
}
				
			
Identifier-column assumption: This example assumes that the columns used to identify a row are immutable. If the application updates primary-key values, consider storing separate old and new row identifiers.

Adding a New Source Column

Suppose a new column is added later:

				
					ALTER TABLE demo_data.sample_record
ADD COLUMN category_code TEXT;
				
			

The trigger function does not need to be changed.

On the next update:

				
					BEGIN;

SET LOCAL app.audit_user = 'demo_user_303';

UPDATE demo_data.sample_record
SET category_code = 'CATEGORY_A'
WHERE record_id = 1001;

COMMIT;
				
			

The new column appears automatically in to_jsonb(NEW), and the trigger records the change:

				
					. actor_user  | field_changed | old_value |   new_value
--------------+---------------+-----------+---------------
 demo_user_303| category_code |           | "CATEGORY_A"
				
			

This is one of the main advantages of dynamically comparing JSONB representations instead of hard-coding every source column into the trigger function.

Transactional Consistency

The source update and its audit inserts execute in the same transaction.

If the source update rolls back, its audit rows also roll back. If the trigger fails, the source operation fails with it. Triggers execute as part of the same transaction as the statement that caused them to fire.

That behavior is useful when the audit history must remain transactionally consistent with the operational data.

Performance Considerations

This audit pattern runs synchronously as part of the original UPDATE transaction.

For each affected source row, the trigger:

  • ● Converts the OLD and NEW row versions to JSONB.
  • ● Compares each column to determine what changed.
  • ● Inserts one audit record for every changed field.

For example:

  • ● Updating one field in one row creates one audit record.
  • ● Updating ten fields in one row creates ten audit records.
  • ● Updating thousands of rows invokes the row-level trigger once for every affected row.

Because the audit inserts are part of the same transaction, the original update does not complete until the corresponding audit records have been written.

Test with a representative workload: Measure the effect using the expected update rate, number of rows per statement, row width, and average number of fields changed. Trigger-based auditing provides transactional consistency, but the extra processing and distributed writes are not free.

Sensitive and Large Columns

A generic trigger examines every column. That might not be desirable for fields containing:

  • ● Secrets or authentication data
  • ● Personally identifiable information
  • ● Large text documents
  • ● Large JSONB values
  • ● Binary data
  • ● Frequently updated timestamps that create unnecessary noise

For these columns, consider modifying the function to:

  • ● Exclude specified field names
  • ● Mask sensitive values
  • ● Record only that the value changed
  • ● Store a hash instead of the full value
  • ● Use a specialized trigger for selected tables

Audit Data Retention

The audit table can grow much faster than the source table because every changed field creates a separate row.

Plan for:

  • ● Retention requirements
  • ● Archival
  • ● Purging
  • ● Table partitioning
  • ● Indexes for common lookup patterns
  • ● Export to external storage

For high-volume or long-term retention requirements, Change Data Capture may be more appropriate than retaining every audit event in the operational database.

Security Considerations

The value stored in app.audit_user is supplied by the application. It identifies the authenticated user according to the application, but it is not independent proof of that identity.

Protect the full path:

  • ● Authenticate the application user.
  • ● Validate the identity before passing it to the database.
  • ● Use bind parameters.
  • ● Restrict direct access to the audit table.
  • ● Prevent ordinary application roles from modifying trigger definitions.
  • ● Protect the owner of the SECURITY DEFINER function.

Limitations

Limitation What It Means
Updates only The trigger function in this tip audits UPDATE operations only. It must be extended to capture inserts, deletes, or operation types.
One audit row per changed field An update that changes several fields creates a separate audit row for each field. Wide updates can therefore generate substantial additional write activity.
Schema changes affect future entries If a source column is renamed, existing audit rows retain the original field name while new audit rows use the renamed field.
Application identity is supplied by the application The value stored in app.audit_user must be populated correctly by the application. It is not independent proof of the user’s identity.
Audit storage can grow quickly Frequently updated tables can produce many audit rows. Retention, archival, partitioning, and purging should be planned in advance.
Large or sensitive values require care Recording complete old and new values may be inappropriate for secrets, personal data, large JSONB documents, binary data, or other large fields.
Indexes depend on query patterns The best indexes depend on whether audit history is searched by source table, row identifier, application user, field name, or timestamp.

Final Takeaway

A reusable JSONB trigger provides a flexible way to capture field-level update history across multiple YugabyteDB tables without writing a separate trigger function for every schema.

The pattern:

  • ● Converts the OLD and NEW row versions to JSONB.
  • ● Identifies only the fields whose values changed.
  • ● Records the old and new values.
  • ● Captures the source table and row identifier.
  • ● Associates the change with an application-level user.
  • ● Stores the audit records in the same transaction as the original update.

The application only needs to set the audit identity inside the active transaction:

				
					BEGIN;

SET LOCAL app.audit_user = 'demo_user_101';

UPDATE demo_data.sample_record
SET record_status = 'active'
WHERE record_id = 1001;

COMMIT;
				
			
Choose this pattern when: You need an immediately queryable, transactionally consistent audit history inside YugabyteDB and can accept the additional trigger processing, audit writes, and storage growth.

For higher-volume workloads, longer retention periods, or audit data that must be delivered to Kafka, object storage, or another downstream platform, Change Data Capture may be a better fit. That approach will be covered in the next tip.

Have Fun!