Skip to content

pg.tables_without_index_use false-positives on small, correctly-indexed tables #1157

Open

Description

@h0nyik

Nextcloud & app version

  • Nextcloud Server: 35.0.0
  • serverinfo app: 7.0.0
  • Database: PostgreSQL 15.18 (Debian 15.18-0+deb12u1)

Summary

The "Database checks" advisor on Settings → Overview → Database flags "Tables scanning sequentially" (pg.tables_without_index_use) as failing for tables that are only a few kilobytes / a handful of rows, already carry a primary-key index, and for which PostgreSQL's own planner correctly and deliberately chooses a sequential scan over an index scan — because the table is small enough that a seq scan is the cheaper plan.

The rule (apps/serverinfo/lib/Database/RuleSet/Postgres.php) triggers purely on:

seq_scan > 1_000_000 AND idx_scan = 0

This threshold only measures scan count, never table size or row count. Since Nextcloud's own small system/config tables (e.g. oc_users, oc_accounts, oc_preferences) are read on effectively every request, their seq_scan counter climbs into the millions over normal server uptime even though each individual scan touches a single 8 KB heap page. The rule has no way to distinguish "1M scans over 2 rows" (free) from "1M scans over 2M rows" (a real missing-index problem), so it reliably false-positives on any long-running, low-traffic instance.

Steps to reproduce

  1. Run a Nextcloud 35 instance on PostgreSQL for long enough that core tables accumulate >1M sequential scans (any small/medium instance left running for a few weeks will do this — these tables are queried on essentially every authenticated request).
  2. Open Settings → Overview. The "Database configuration" check shows a warning.
  3. Open Settings → Administration → Database (or the servrinfo #database panel) → Database checks → "S problémy" (Failing). See "Tables scanning sequentially", listing oc_users, oc_accounts, oc_preferences, oc_mimetypes, oc_guests_users, oc_circles_membership, oc_deck_boards (exact set depends on the instance, but core low-cardinality tables are the reliable offenders).

Evidence that this is a false positive

Actual state on our instance (2 real user accounts, small install):

table live rows total size seq_scan idx_scan
oc_users 3 40 kB 2,198,160 0
oc_preferences 89 96 kB 2,197,028 0
oc_guests_users 0 32 kB 1,782,632 0
oc_mimetypes 240 88 kB 1,709,915 0
oc_accounts 3 72 kB 1,658,128 0
oc_circles_membership 0 64 kB 1,189,503 0
oc_deck_boards 0 56 kB 1,001,275 0

Indexes already exist and are correct per Nextcloud's own schema (occ db:add-missing-indices, occ db:add-missing-primary-keys, occ db:add-missing-columns all report nothing missing):

$ psql -c "SELECT indexname, indexdef FROM pg_indexes WHERE tablename IN ('oc_users','oc_accounts');"
       indexname       |                                   indexdef
------------------------+------------------------------------------------------------------------------
 oc_accounts_pkey       | CREATE UNIQUE INDEX oc_accounts_pkey ON public.oc_accounts USING btree (uid)
 oc_users_pkey          | CREATE UNIQUE INDEX oc_users_pkey ON public.oc_users USING btree (uid)
 user_uid_lower         | CREATE INDEX user_uid_lower ON public.oc_users USING btree (uid_lower)

And PostgreSQL's planner, with that index available, deliberately picks the sequential scan anyway because it's the actually-cheaper plan for a single-page table:

$ psql -c "EXPLAIN SELECT * FROM oc_users WHERE uid = 'h0nyik';"
                        QUERY PLAN
----------------------------------------------------------
 Seq Scan on oc_users  (cost=0.00..1.02 rows=1 width=954)
   Filter: ((uid)::text = 'h0nyik'::text)

$ psql -c "EXPLAIN SELECT * FROM oc_accounts WHERE uid = 'h0nyik';"
                         QUERY PLAN
-------------------------------------------------------------
 Seq Scan on oc_accounts  (cost=0.00..3.08 rows=1 width=178)
   Filter: ((uid)::text = 'h0nyik'::text)

So the rule's own suggested remedy ("Add appropriate indexes to the affected tables") would not change anything: the index is already there, and the planner already knows about it, and correctly ignores it.

Expected behavior

The rule shouldn't fire for tables where a sequential scan is genuinely the optimal plan. A few ways to fix this that seem consistent with the codebase's existing patterns:

  1. Gate on table size, analogous to the existing MIN_UPTIME_HOURS / COUNTER_SENSITIVE_RULE_IDS gate in Advisor.php that already exists to avoid false positives from counters that haven't accumulated meaningful data yet (restart-sensitive ratios). E.g. only flag a table if pg_total_relation_size(relid) (or n_live_tup) is above some minimum (a table under, say, a few hundred KB / a few thousand rows essentially can't benefit from an index scan on PostgreSQL).
  2. Alternatively, cross-check against pg_stat_user_tables.seq_tup_read / seq_scan (average rows read per scan) — a consistently tiny average strongly indicates the table is trivially small, regardless of the raw scan count.
  3. At minimum, mention table size next to each flagged table name in the details so an admin can immediately tell a real missing-index case from a "this table is 40 kB, ignore" case, without having to run their own pg_stat_user_tables query to figure that out (as I had to here).

Additional note

There currently appears to be no way for an admin to acknowledge/dismiss one specific rule result once it's understood to be a non-issue for their instance — every page load re-evaluates and re-flags it (checked Advisor.php, CheckSummary.php, DatabaseController.php, and the core SetupCheckManager — no skip/ignore/acknowledge mechanism beyond the automatic "required value missing" / uptime-gate skips). Even if the size-based fix above isn't adopted, a persistent per-instance "don't show this rule again" would at least let an admin who has verified a result is a non-issue stop seeing it flagged as failing on every visit.

Happy to provide any more detail (pg_stat_user_tables dumps, full EXPLAIN ANALYZE, etc.) if useful.

Activity

  1. awrede commented on Oct 10, 2026

    @awrede

    Same here on another instance, with a different set of tables. That suggests this isn't specific to any one app.

    Setup: Nextcloud AIO, NC 35.0.1, serverinfo 7.0.0, PostgreSQL 18.6, about 27 users. pg_stat has never been reset. The missing-indices, missing-columns and missing-primary-keys checks all report nothing missing.

             relname          | n_live_tup | seq_scan | idx_scan | size  | indexes
    --------------------------+------------+----------+----------+-------+--------
     oc_users                 |         27 | 60568343 |        0 | 40 kB |  2
     oc_group_folders_groups  |          3 | 34171775 |        0 | 72 kB |  4
     oc_external_applicable   |          1 | 30193923 |        0 | 56 kB |  3
     oc_external_mounts       |          1 | 25655254 |        0 | 24 kB |  1
     oc_flow_operations_scope |          2 | 21954363 |        0 | 40 kB |  2
     oc_share_external        |          5 | 13833375 |        0 | 64 kB |  3
     oc_guests_users          |         12 | 13610380 |        0 | 64 kB |  3
     oc_flow_operations       |          2 | 11360658 |        0 | 32 kB |  1
     oc_vcategory             |         37 |  9951275 |        0 | 88 kB |  5
     oc_group_folders         |          2 |  9471716 |        0 | 48 kB |  2
     oc_external_options      |          6 |  4544919 |        0 | 40 kB |  2
     oc_external_config       |         10 |  4544919 |        0 | 48 kB |  2
     oc_group_folders_acl     |          1 |  4288269 |        0 | 56 kB |  3
     oc_group_admin           |         15 |  2453995 |        0 | 40 kB |  2
     oc_deck_assigned_labels  |         24 |  2317324 |        0 | 56 kB |  3
    

    All 15 tables are at most 37 rows and 88 kB, and every one of them already has indexes. The planner picks a sequential scan because that is the cheaper plan here. Admins can't fix this by adding indexes.

    #1167 stops this notice from making the overview check fail, which helps. But the rule still reports a problem that isn't there. Could it also look at table size? Some options:

    • skip tables below a size floor, e.g. pg_relation_size(relid) > 10 MB or n_live_tup > 10000, or
    • look at the average rows read per seq scan (seq_tup_read / seq_scan) instead of the scan count. In the table above that average is between 1 and about 37 rows. A real missing index would show thousands or more.

    With either change, this rule would catch real missing indexes and stop flagging small config tables.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions