Audits

Nineteen audits organized into three families. Catalog and JPA audits run on every supported database platform; runtime audits split into two kinds — plan-based (PostgreSQL 16+ only) and capture-scan (every platform).

Every audit() call returns a List<Finding> — empty when clean. Each Finding is a record (io.github.databaseaudits.audit.finding) carrying the structured facts behind the violation; its description() is the human-readable line shown throughout this page.

Catalog audits

Catalog audits read database metadata (information schema / pg_catalog) over plain JDBC. They are deterministic regardless of test data. All take a schema name and an exclusion set.

PrimaryKeyPresenceAudit

Every base table in the schema must have a PRIMARY KEY. Pass table names to skip as excludedTables. A constant LIQUIBASE_BOOKKEEPING_TABLES is provided for the common case of excluding Liquibase’s own tables.

List<Finding> findings = new PrimaryKeyPresenceAudit(queries, platform)
        .audit("my_schema", PrimaryKeyPresenceAudit.LIQUIBASE_BOOKKEEPING_TABLES);

Finding format: the table name — for example orders.

Fix: Add a PRIMARY KEY to the table, or pass its name as an exclusion.

ForeignKeyIndexAudit

Every foreign key constraint must be backed by an index whose leading columns are the FK columns. An unindexed FK causes slow child→parent lookups and lock-heavy parent DELETE/UPDATE (sequential child scan under a strong lock). Pass constraint names to skip as excludedConstraints.

Note: PostgreSQL and H2 do not auto-create an index for a foreign key. MySQL/MariaDB InnoDB does, so this audit usually passes there, though it still catches an index dropped after the fact on MariaDB (permitted while foreign_key_checks is suspended; MySQL refuses such drops outright).

List<Finding> findings = new ForeignKeyIndexAudit(queries, indexes, platform)
        .audit("my_schema", Set.of("fk_legacy_unindexed_constraint"));

Finding format:

orders.fk_orders_customer  ->  FOREIGN KEY (customer_id) REFERENCES customers

Fix: Add an index whose leading column(s) are the FK column(s), or exclude the constraint name.

ForeignKeyNotNullAudit

Every foreign key column should be NOT NULL unless the relationship is genuinely optional. Pass column identifiers in table.column format to skip as excludedColumns. Composite FKs are reported per column.

List<Finding> findings = new ForeignKeyNotNullAudit(queries, platform)
        .audit("my_schema", Set.of("orders.optional_partner_id"));

Finding format:

orders.customer_id (fk_orders_customer) is nullable

Fix: Make the column NOT NULL, or exclude it if the optional relationship is intentional.

ForeignKeyTypeMatchAudit

Every foreign key column must have exactly the same declared type as the column it references. A type mismatch — an integer referencing a bigint, differing varchar lengths — forces implicit conversions in joins and FK checks, can defeat index use, and caps the child at the narrower range. Pass column identifiers in table.column format to skip as excludedColumns.

List<Finding> findings = new ForeignKeyTypeMatchAudit(queries, platform)
        .audit("my_schema", Set.of());

Finding format:

orders.customer_id is integer but references customers.id which is bigint (fk_orders_customer)

Fix: Align the FK column’s type with its referenced column’s, or exclude the column if the mismatch is deliberate.

DuplicateForeignKeyAudit

No relationship should be enforced by more than one foreign key constraint. A duplicate FK constraint — a Liquibase changeset applied twice, a hand-written constraint duplicating a generated one — doubles constraint-check work on every child write and confuses schema tooling. Two constraints on the same table are duplicates when they declare the same FK columns to the same referenced table and columns, regardless of column declaration order. Pass constraint names to skip as excludedConstraints; excluding one constraint of a duplicate pair drops that relationship below two members, which suppresses the finding entirely.

List<Finding> findings = new DuplicateForeignKeyAudit(foreignKeys)
        .audit("my_schema", Set.of());

Finding format:

orders: FOREIGN KEY (customer_id) REFERENCES customers (id) is duplicated by constraints [fk_orders_customer, fk_orders_customer_legacy]

Fix: Drop all but one of the duplicate constraints, or exclude one to keep the duplication deliberately.

PrimaryKeyTypeAudit

Every primary key should be at least bigint wide. A 32-bit (or narrower) integer primary key overflows at roughly 2.1 billion rows — trivial to fix at design time, brutal to fix live, since widening a primary key in place typically means rewriting the table and every referencing foreign key. Advisory for genuinely small, bounded tables. Pass column identifiers in table.column format to skip as excludedColumns.

List<Finding> findings = new PrimaryKeyTypeAudit(queries, platform)
        .audit("my_schema", Set.of());

Finding format:

orders.id primary key type integer is narrower than bigint — risks key exhaustion

Fix: Migrate the column to BIGINT — widening any referencing foreign key columns in the same change, since ForeignKeyTypeMatchAudit will flag stragglers — or exclude the column if the table is genuinely bounded.

RedundantIndexAudit

No non-unique index should be made redundant by another index whose leading columns are identical. A redundant index wastes write throughput and storage — the wider index already serves the same lookups. Ignores primary key, unique, partial, and expression indexes. Pass index names to skip as excludedIndexes.

List<Finding> findings = new RedundantIndexAudit(indexes)
        .audit("my_schema", Set.of("idx_orders_customer_legacy"));

Finding format:

orders.idx_orders_customer is covered by idx_orders_customer_status

Fix: Drop the narrower index (the wider one already serves its lookups), or exclude the index name if the redundancy is intentional (for example, a collation-specific index).

UniqueIndexNotNullAudit

No UNIQUE index should include a nullable column. On every supported platform a UNIQUE index or constraint permits any number of rows whose key contains a NULL — so a "unique business key" declared on a nullable column silently admits duplicate rows for whichever rows are missing the key. Partial and expression indexes are skipped rather than flagged, since partial uniqueness is often deliberate. Pass index names to skip as excludedIndexes.

List<Finding> findings = new UniqueIndexNotNullAudit(queries, indexes, platform)
        .audit("my_schema", Set.of());

Finding format:

customers.uq_customers_email is UNIQUE over nullable column(s) [email] — rows with NULLs bypass uniqueness

Fix: Make the nullable column NOT NULL, use PostgreSQL 15+ NULLS NOT DISTINCT, or exclude the index if the partial uniqueness is deliberate.

Runtime audits

Runtime audits intercept every SQL statement Hibernate executes via SqlCapturingStatementInspector, then inspect it one of two ways: plan-based audits analyze it via EXPLAIN (GENERIC_PLAN, FORMAT JSON) and are PostgreSQL 16+ only; capture-scan audits scan the captured SQL text directly and run on every supported platform. See Usage — Runtime audits for wiring SqlCapturingStatementInspector and ordering the workload.

Plan-based runtime audits (PostgreSQL 16+ only)

These require preferQueryMode=simple on the JDBC URL. They throw UnsupportedOperationException on any other platform and IllegalStateException when the SQL capture is empty.

WhereClauseIndexAudit

Every SELECT, UPDATE, or DELETE with a WHERE clause should have at least one indexed predicate covering the access path on the filtered table. Under SET enable_seqscan = off, a Seq Scan that still carries a Filter proves no index at all can serve that access — a full table scan is the only option. A Filter on top of an existing Index Scan (for example, a is indexed but b is not in WHERE a = ? AND b = ?) is not flagged, because the scan itself is already index-driven. Pass table names to excludedRelations to skip a whole table; pass SQL substrings to excludedSqlFragments to skip specific statements.

List<Finding> findings = new WhereClauseIndexAudit(explainer, inspector)
        .audit(
            Set.of("audit_log"),                              // skip this table
            List.of("where status = 'LEGACY'")               // skip statements containing this
        );

Finding format:

Seq Scan on 'orders' filtering (status = $1)
      select o1_0.id, o1_0.status from orders o1_0 where o1_0.status = ?

Fix: Add an index on the filtered column(s), or exclude the relation or SQL fragment.

OrderByIndexAudit

Every ORDER BY should be servable by an index (no explicit sorts). Under SET enable_sort = off, a Sort or Incremental Sort that survives means no index can provide the ordering. Advisory — sorts over aggregates, joins, or small result sets are often legitimate and meant to be excluded. The primary target is sorted pagination over large tables (ORDER BY … LIMIT).

List<Finding> findings = new OrderByIndexAudit(explainer, inspector)
        .audit(
            Set.of("lookup_values"),                          // skip this small static table
            List.of("group by")                              // skip aggregate queries
        );

Finding format:

Sort under 'orders' by (created_at DESC)
      select o1_0.id, o1_0.created_at from orders o1_0 order by o1_0.created_at desc

Fix: Add an index matching the ORDER BY columns (including ASC/DESC and NULLS order), or exclude the relation or SQL fragment.

JoinIndexAudit

Every JOIN should be servable through an index on the join key of at least one side. Under SET enable_seqscan = off, enable_hashjoin = off, enable_mergejoin = off, the planner is steered toward a Nested Loop with an inner Index Scan — the shape an indexed join key enables. A Hash Join, Merge Join, or Nested Loop with an inner Seq Scan that survives the penalty proves no index can serve the join. Advisory — `FULL OUTER JOIN`s, joins on small static tables, and joins on expressions are legitimate exclusions.

List<Finding> findings = new JoinIndexAudit(explainer, inspector)
        .audit(
            Set.of("lookup_values"),                         // skip this small static table
            List.of()
        );

Finding format: one of three shapes depending on which plan survived the penalties:

Hash Join on 'orders' joining (order_items.order_id = orders.id)
      select oi1_0.id, o1_0.status from order_items oi1_0 join orders o1_0 on oi1_0.order_id = o1_0.id

Merge Join on 'orders' joining (order_items.order_id = orders.id)
      select oi1_0.id, o1_0.status from order_items oi1_0 join orders o1_0 on oi1_0.order_id = o1_0.id

Nested Loop with inner Seq Scan on 'orders' joining (order_items.order_id = orders.id)
      select oi1_0.id, o1_0.status from order_items oi1_0 join orders o1_0 on oi1_0.order_id = o1_0.id

Fix: Add an index on the joined column(s), or exclude the relation or SQL fragment.

UnusedIndexAudit

Advisory: every index should be used by at least one captured statement’s plan. Every index taxes every write and consumes cache/storage; one that no real query uses is pure cost. The inverse of the other plan audits — it proves one index serves no statement in the whole captured workload, planning each candidate via the natural generic plan (no planner penalties) and walking every Index Name the plan mentions. An index is never reported when it backs a primary key or a unique constraint, when it is partial (a generic plan without bind values usually cannot prove a partial index unusable), when its name appears in the collected usage, or when it covers a foreign key (an index ForeignKeyIndexAudit demands is never reported unused here).

List<Finding> findings = new UnusedIndexAudit(explainer, inspector, indexes, foreignKeys)
        .audit("my_schema", Set.of("idx_kept_for_admin_report"));

Finding format:

orders.idx_orders_legacy_status is used by no captured statement's plan

Fix: Drop the index after confirming against production usage statistics, or exclude it (e.g. an index kept for a rare admin query outside the captured workload).

Capture-scan runtime audits (all platforms)

These scan the captured SQL text directly — no EXPLAIN, no platform restriction. They throw IllegalStateException when the SQL capture is empty.

UnconditionalMutationAudit

No UPDATE or DELETE may run without a WHERE clause. An unconditional mutation rewrites or wipes an entire table — an accidental derived-delete, a @Modifying @Query missing its predicate, or a full-table fixture reset executed against production data. Detection is a token scan of the captured SQL — no EXPLAIN needed. Pass normalized statement strings to excludedStatements to skip deliberate full-table statements.

List<Finding> findings = new UnconditionalMutationAudit(inspector)
        .audit(Set.of("delete from schema_version"));

Finding format: the normalized statement — for example:

delete from session_tokens

Fix: Add a WHERE clause to the statement, or pass the normalized statement text as an exclusion.

OffsetPaginationAudit

Advisory: no query should page with OFFSET (or MySQL/MariaDB’s LIMIT <offset>, <count> comma form). Offset-based pagination — what Spring Data’s Pageable emits by default — makes the database produce and discard every skipped row: page 1000 costs roughly 1000x page 1. Detection is a token scan for " OFFSET " or the MySQL/MariaDB comma form; a bare LIMIT ? with neither is not flagged. Pass SQL fragments to skip as excludedSqlFragments (matched case-insensitively).

List<Finding> findings = new OffsetPaginationAudit(inspector)
        .audit(List.of("from lookup_values"));

Finding format: the normalized statement — for example:

select o1_0.id, o1_0.status from orders o1_0 order by o1_0.created_at desc limit ? offset ?

Fix: Switch to keyset (seek) pagination (WHERE (sort_key, id) > (?, ?) ORDER BY sort_key, id LIMIT ?), or exclude the statement if the pagination is deliberately shallow and bounded.

RepeatedStatementAudit

Advisory: no captured SELECT (or WITH CTE query) shape should run at least as many times as a given threshold — the signature of an N+1 statement burst (one parent query followed by one identical-shaped child SELECT per row). Reads SqlCapturingStatementInspector.executionCounts(), aggregating raw statement variants that normalize identically. Counts accumulate for the capturer’s whole lifetime, so for a sharp signal call inspector.clear() before a representative workload and audit right after; choose a threshold above the workload’s largest expected collection size (a generous threshold like 50 works well as a regression tripwire). Pass legitimately hot statements' SQL fragments to skip as excludedSqlFragments.

List<Finding> findings = new RepeatedStatementAudit(inspector)
        .audit(50, List.of());

Finding format: the count and the normalized statement — for example:

executed 137 times: select i1_0.id, i1_0.order_id from order_items i1_0 where i1_0.order_id = ?

Fix: Eliminate the N+1 with a fetch join, @EntityGraph, or @BatchSize/ hibernate.default_batch_fetch_size, or exclude the statement if the repetition is deliberate.

JPA audits

SchemaEntityValidationAudit

The JPA entity mappings must match the live database schema. This audit walks Hibernate’s fully resolved boot mapping model and, for each mapped table, confirms against the live database metadata that the table exists, every mapped column exists, and each column’s type is compatible — reporting all mismatches in one run. It uses Hibernate’s own type-compatibility rule, so a mapping Hibernate accepts is never flagged.

Run with ddl-auto=none. The audit deliberately does not rely on Hibernate’s ddl-auto=validate startup check, which fails fast on the first mismatch and aborts the EntityManagerFactory build. The boot mapping model is captured during bootstrap by a MappingMetadataIntegrator (auto-registered via META-INF/services).

List<Finding> findings =
    SchemaEntityValidationAudit.forEntityManagerFactory(entityManagerFactory, dataSource).audit();

Finding: e.g. missing column [quantity] in table [public.parent], or wrong column type in column [name] in table [public.parent]; found [integer], but expecting [varchar(255)].

Fix: Reconcile the entity mappings with the Liquibase-built schema (whichever drifted), or pass a known, acceptable mismatch as an excluded relation (a table name or table.column, optionally schema-qualified as schema.table / schema.table.column) to audit(Set<String>).

MissingVersionAttributeAudit

Every mutable root entity should carry a @Version attribute. A mutable entity with no optimistic-locking version lets concurrent transactions silently overwrite each other’s changes (a lost update). Versioning is declared on the inheritance hierarchy’s root, so only root entities are checked; an @Immutable entity is skipped, since it cannot lose an update it never receives. Advisory: append-only or single-writer entities are legitimate exclusions. Pass entities to skip as excludedEntities — fully-qualified name, simple name, or physical table name, matched case-insensitively.

List<Finding> findings = MissingVersionAttributeAudit
        .forEntityManagerFactory(entityManagerFactory)
        .audit(Set.of("ReferenceData"));

Finding: e.g. com.acme.Order (table orders) has no @Version attribute — concurrent updates can silently overwrite each other.

Fix: Add a @Version attribute, or exclude the entity if it is genuinely append-only or single-writer.

EagerCollectionFetchAudit

Every mapped collection should be fetched lazily. @OneToMany/@ManyToMany(fetch = EAGER) (also the JPA default for @ElementCollection) loads the collection on every load of its owning entity — multiplying rows through a join or firing an extra query per owner, compounding across every entity that reaches the collection. Pass a deliberately eager collection’s Hibernate role (e.g. com.acme.Order.items) to skip as excludedRoles.

List<Finding> findings = EagerCollectionFetchAudit
        .forEntityManagerFactory(entityManagerFactory)
        .audit(Set.of());

Finding: e.g. com.acme.Order.items (collection table order_items) is fetched eagerly — loaded on every owner load.

Fix: Switch to FetchType.LAZY, and fetch eagerly only where a specific query genuinely needs the collection, via a fetch join or @EntityGraph.

UnmappedDatabaseObjectAudit

Every physical base table and column in the live schema should be mapped by a JPA entity — the reverse direction of SchemaEntityValidationAudit, which proves every mapping exists in the schema but nothing proves the schema holds only what is mapped. A leftover table keeps accumulating unowned data; a NOT NULL column with no default that no entity maps makes every entity INSERT against that table fail at runtime. Only schemas containing at least one mapped table are scanned, and only base tables (views are ignored). A Hibernate-generated join/collection table (@JoinTable, @ElementCollection’s `@CollectionTable) counts as mapped. Pass a known, acceptable unmapped relation (a table name or table.column, optionally schema-qualified) as excludedRelations — for example migration-tool bookkeeping tables.

List<Finding> findings = UnmappedDatabaseObjectAudit
        .forEntityManagerFactory(entityManagerFactory, dataSource)
        .audit(PrimaryKeyPresenceAudit.LIQUIBASE_BOOKKEEPING_TABLES);

Finding: e.g. unmapped table [public.orphan_table] exists in the schema but no entity maps it, or unmapped column [tenant_id] in table [public.orders] — NOT NULL with no default breaks entity inserts.

Fix: Map the object, drop it via a migration, or exclude it (migration bookkeeping tables, deliberate denormalizations).