The deploy readers: what they need to see, and what a partial answer means
Two readers run immediately before a deploy. The statistics reader answers how big the objects a
finding names are; the activity reader answers whether anything is blocking right now. Both read
catalog and state views only: no user table, no EXPLAIN, no lock of their own.
This page is about what happens when they cannot see everything — because on a managed database that is the normal case, and because one of the ways a state view degrades is genuinely dangerous.
The short version
A reading that could not see something says so. It never comes back empty and lets you read silence
as calm. If you take one thing from this page, take this: a partial snapshot is an honest result,
not a defect, and the fix is usually a privilege rather than a configuration change.
Why "empty" and "calm" are the same shape
An activity snapshot with no lock waits is what a healthy server produces. It is also what a reading whose views were all withheld produces. Identical contents, opposite meanings.
They are told apart by the skips, never by the contents — every source that could not be read
leaves a named entry, and the snapshot reports itself partial. A consumer that wants to conclude
something from silence has to pass through that check first.
PostgreSQL
| View | Used for | Without the privilege | You will see |
|---|---|---|---|
pg_class, pg_namespace | row estimates, sizes | readable to any role | — |
pg_relation_size and friends | sizes | readable to any role | — |
pg_stat_all_tables | when statistics were last refreshed | readable to any role | — |
pg_locks | lock waits | readable to any role | — |
pg_stat_activity | long-running sessions | rows present, columns empty | insufficient_privilege, and the reading is partial |
pg_stat_replication | replica lag | not measured here — see the note below | insufficient_privilege, or not_readable on a standby |
Every row above except one was measured against a real PostgreSQL 18 with an account holding nothing
but CONNECT. The exception is pg_stat_replication: the server it was measured on has no replica,
so an empty result there says nothing about the privilege, and this page will not claim otherwise.
What SQLens does with it is settled — it establishes whether the connection reached a primary or a
standby rather than assuming, and a standby is reported as the wrong instance rather than as replicas
that are caught up.
The one that does not refuse
pg_stat_activity is the view worth understanding before you grant anything.
Without pg_monitor membership it does not refuse. It answers with every session listed and the
useful columns — state, xact_start, query_start, wait_event_type — all null for sessions that
are not yours. The query text reads <insufficient privilege>.
A tool that filtered on the duration would therefore return nothing on a server with hours-old transactions and report that nobody is running long. No error, nothing empty enough to be suspicious of. SQLens counts the rows and the visible states in the same statement and reports the difference as a named skip instead.
pg_locks, by contrast, is fully readable to an ordinary role. The lock graph survives where the
session attributes do not, so lock waits are reported honestly even from an unprivileged account.
Granting the minimum
GRANT pg_monitor TO sqlens_preflight;
pg_monitor is a predefined role: it makes the statistics views fully readable and grants no access
to table data. It is what turns the masked view above into a complete one.
Do not use the migration role for this. The role that runs your migrations holds DDL rights on
your schema; a preflight reads and must never be able to write. Give the preflight its own account,
and give that account no more than CONNECT, USAGE on the schemas you audit, and pg_monitor.
MySQL
| View | Used for | Without PROCESS | You will see |
|---|---|---|---|
information_schema.TABLES | row estimates, sizes | readable | — |
mysql.innodb_table_stats | when statistics were last refreshed | refuses | insufficient_privilege; the numbers survive, their age is unknown |
mysql.innodb_index_stats | per-index size | refuses | insufficient_privilege |
information_schema.INNODB_TRX | long-running transactions | refuses (1227) | insufficient_privilege |
performance_schema.metadata_locks | DDL blocked by DML | refuses (1142) | insufficient_privilege |
performance_schema.data_lock_waits | row contention | refuses (1142) | insufficient_privilege |
performance_schema.threads | thread → connection id | refuses (1142) without SELECT on performance_schema | insufficient_privilege |
information_schema.USER_PRIVILEGES | whether this account holds PROCESS | readable to any account, for itself | — |
information_schema.PROCESSLIST | sessions | narrowed to your own, no error | insufficient_privilege, detected via USER_PRIVILEGES |
The one that does not refuse
MySQL's dangerous view is information_schema.PROCESSLIST. Without PROCESS it returns your own
session and nothing else — no error, and one row is what a quiet server looks like too.
It cannot be detected from its own answer — one row is one row. So SQLens asks the server which
privileges the connecting account holds, using information_schema.USER_PRIVILEGES, which every
account may read for itself. A process list read without PROCESS is then reported as a named skip
rather than as a quiet server.
An earlier version inferred it from performance_schema.threads refusing, and that was wrong.
The correction is worth stating because the belief is a common one: threads is gated by SELECT on
performance_schema, not by PROCESS. Measured on 8.4.10:
| Grants | performance_schema.threads | information_schema.PROCESSLIST |
|---|---|---|
| none | refused | 1 row — narrowed |
PROCESS only | refused | 3 rows — whole server |
SELECT on performance_schema only | readable | 1 row — narrowed |
| both | readable | 3 rows |
The third row is why it mattered: the view answered, so the old check concluded there was no problem, while the process list showed only the reader's own session. That combination is not exotic — it is what a carefully set-up monitoring account gets.
One honest remainder in the current approach: PROCESS held through an activated role does not
appear in USER_PRIVILEGES. A negative answer is therefore reported as no PROCESS privilege is
recorded rather than as the list is narrowed, so the error stays on the cautious side.
The grant below is the fix.
Granting the minimum
CREATE USER 'sqlens_preflight'@'%' IDENTIFIED BY 'a-password-you-generate';
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'sqlens_preflight'@'%';
GRANT SELECT ON `your_database`.* TO 'sqlens_preflight'@'%';
PROCESS is what makes the state views answer about the whole server; REPLICATION CLIENT is only
needed if you want replication state. Neither grants access to table data.
Do not reuse the migration account. It holds ALTER and DROP on your schema, and a preflight
that runs as it can do more than read.
MySQL: the activity reading can be a tenth of a second old
information_schema.INNODB_TRX is not a live view. InnoDB serves it from a cache it refuses to
refresh more than once per 100 ms, and that cache is shared by the whole server — measured, not
inferred: a connection that has never read the view still gets the snapshot somebody else's read
left behind.
Two consequences, and only one of them is yours to think about.
- Your reading may be up to 100 ms old, because another tool read the view just before it. It cannot be older than that.
- Reading it costs every other observer the same window. SQLens reads this view once per activity snapshot. If you run a preflight in a loop, you are also nudging any monitoring that reads the same view.
It cannot turn a busy server into a quiet answer. The cache holds a snapshot, not an empty set, so a transaction that was running when the snapshot was taken is in it. The only transaction the window can hide is one that started within the last 100 ms — and a transaction that young is never what "has something been running too long" is asking about.
SQLens therefore does not wait the window off. Doing so would slow every preflight to remove a staleness that cannot reach the verdict.
On a managed database, partial is normal
RDS, Aurora, Cloud SQL and Neon all withhold something by design. You may not be able to grant
pg_monitor at all, and PROCESS may not be available to any account you control.
That is not a defect and SQLens does not treat it as one. What it does is refuse to pretend: the
reading comes back partial, each missing source is named, and nothing silently becomes a clean
result.
Resist the urge to configure the partial away by inflating privileges. A preflight that can see
less is still worth running — the sizes usually read fine, and on PostgreSQL the lock graph does too.
What matters is that you know which half you are looking at.
How to read an estimate
Every number the statistics reader produces says what it is worth.
- A row count is always an estimate. No catalog read on any engine states one exactly, because
counting rows means counting rows — a full table scan, which SQLens will never ask a production
database to perform. PostgreSQL's
reltuplesand MySQL'sTABLE_ROWSare both maintained by the statistics collector, and MySQL documents its InnoDB row counts as varying from the truth by as much as 40–50%. - A size may be exact, depending on the engine. PostgreSQL computes it while answering, so it is a measurement. MySQL derives it from pages allocated times page size, which approximates allocation rather than measuring content — a table you just emptied still reports the pages it holds. The number carries which of the two it is.
- Freshness is four-valued. Measured at a moment the server named; never collected; unknown; or
not applicable because the number had no statistics behind it. "Never collected" and "unknown" are
kept apart because they have different fixes — the first is an
ANALYZE, the second a privilege.
Estimates escalate severity; they never create a finding
This is the rule the whole type exists to enforce. A rule that read a row count would answer differently on the same schema depending on when statistics were last refreshed, and it would have no way to tell you that. So statistics never reach a rule at all. They attach to a finding that already exists, as context, marked for what they are.
If you see a finding whose severity looks high for a small table, check whether the statistics behind it are stale — a table that has never been analyzed reports no row count at all on PostgreSQL, and the snapshot says so.
Capacity: what is readable and what is not
Both engines will tell you how much room a database occupies. Neither will tell you how much is left on the volume underneath it — there is no SQL for it.
So the headroom reading is degraded by nature, and it carries a named reason rather than a zero.
A capacity question is answered null rather than "no": a gate that turned "nobody could look" into
a refusal would block deploys on a fact it invented.
MySQL's DATA_FREE is deliberately not offered as free space. It is reclaimable room inside a
tablespace file — fragmentation, not capacity — and a gate told "there is room" on the strength of it
would wave through the rewrite that fills the disk.
Telling the check how much disk there is
deploy.predeploy.available_disk_bytes is how much free disk the operator says this instance has,
in bytes. It ships as null.
// config/sqlens.php
'deploy' => ['predeploy' => ['available_disk_bytes' => 500 * 1024 * 1024 * 1024]],
The disk-headroom check estimates how much space a pending rewrite needs and holds it against what the instance reports as free. On most instances that second half is unanswerable: free filesystem space is not visible from inside the database at all, and on a managed database it never will be, whatever privileges are granted.
So null means ask the instance, not assume plenty — where the answer does not come back,
the check reports undetermined rather than passing. Setting the value is how an operator who knows
the number supplies it.